1 Commits
Author SHA1 Message Date
ayushyaandClaude Sonnet 5 faaec5365f Add device sync: mirror folders/files locally, background WorkManager engine
Build APK / build (push) Successful in 7m33s
- 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 <noreply@anthropic.com>
2026-09-19 00:52:56 -04:00
21 changed files with 2023 additions and 42 deletions
+4 -4
View File
@@ -54,10 +54,10 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
- UI settings that persist across launches — split into **global** (theme - UI settings that persist across launches — split into **global** (theme
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur, mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
actions, login lock — see below) and **per-account** (grid/list view, actions, login lock, sync-on-cellular — see below) and **per-account**
favorites-only, show-hidden, storage scope, Photos sort, Files' (grid/list view, favorites-only, show-hidden, storage scope, Photos sort,
per-folder sort map, cache policy) — see `server.md` for exactly which is Files' per-folder sort map, cache policy, synced folders) — see
which and why `server.md` for exactly which is which and why
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/ - **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via `lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
`AppLockService` (a thin wrapper over `local_auth` — this app never `AppLockService` (a thin wrapper over `local_auth` — this app never
+151 -6
View File
@@ -157,12 +157,12 @@ rather than needing a rewrite for multi-account support.
(`views/move_copy_destination_picker.dart`) is covered in (`views/move_copy_destination_picker.dart`) is covered in
`architecture.md`, including why it can't reuse the Files tab's shared `architecture.md`, including why it can't reuse the Files tab's shared
navigation state the way `ShareUploadView` does. navigation state the way `ShareUploadView` does.
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath, - **Uploads**: there's no more in-app-only upload path - `NextcloudService`/
{onProgress})`) stream the local file via `Dio().put()` with an explicit `ServerProvider.uploadFileFromPath` were removed once the Files tab's "+"
`Content-Length` and `onSendProgress`, mirroring the download path. The → "Upload File" was unified with the share-to-upload flow (below). Every
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the upload, however it's triggered, now goes through `ShareUploadView`
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination (caller-chosen destination via `navigateToAbsoluteFolder`, then
by navigating there first (`navigateToAbsoluteFolder`), then uploading. `UploadService`/`ShareUploadService.kt`).
- **Receiving a shared file from another app**: hand-rolled in - **Receiving a shared file from another app**: hand-rolled in
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
`android:launchMode` `singleTask` in the manifest so a second share while `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 backing out (system back while picking, or a picked-item mismatch) with
`RESULT_CANCELED` instead. `RESULT_CANCELED` instead.
## Device sync
Mirrors selected folders to app-private local storage
(`getExternalFilesDir(null)/sync/<accountId>/...` - 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/<package>/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 ## Multi-account storage & session persistence
[`AccountStore`](../../lib/services/account_store.dart) owns everything [`AccountStore`](../../lib/services/account_store.dart) owns everything
+11 -1
View File
@@ -63,7 +63,17 @@ widgets. Key points:
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see 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 `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) - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) — the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
for the video player's seek bar, plus a perpetually-animated for the video player's seek bar, plus a perpetually-animated
+12
View File
@@ -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 { flutter {
source = "../.." source = "../.."
} }
+18
View File
@@ -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 { *; }
+7
View File
@@ -76,6 +76,13 @@
android:name=".DownloadService" android:name=".DownloadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync"/> android:foregroundServiceType="dataSync"/>
<!-- The "Keep local"/"Use server" actions on a sync-conflict
notification (SyncWorker/SyncEngine) - must be a manifest-
registered receiver, not a runtime-registered one, since it
needs to work even when the app process isn't running. -->
<receiver
android:name=".SyncConflictReceiver"
android:exported="false"/>
<!-- Hands back the file(s) a caller picked via the GET_CONTENT filter <!-- Hands back the file(s) a caller picked via the GET_CONTENT filter
above - only the app's own cache/picker/ subfolder is exposed, above - only the app's own cache/picker/ subfolder is exposed,
see file_paths.xml. --> see file_paths.xml. -->
@@ -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<ConflictResolveWorker>()
.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()
}
}
@@ -12,12 +12,22 @@ import android.provider.OpenableColumns
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider 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.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import org.json.JSONArray
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit
// FlutterFragmentActivity (not the default FlutterActivity) is required by // FlutterFragmentActivity (not the default FlutterActivity) is required by
// local_auth's Android implementation, which hosts its biometric/device // 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 downloadServiceChannelName = "dev.ayushya.noo/download_service"
private val pickIntentChannelName = "dev.ayushya.noo/pick_intent" private val pickIntentChannelName = "dev.ayushya.noo/pick_intent"
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new" 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 private val notificationPermissionRequestCode = 4202
// Lazy, not a field initializer - `packageName` reads through the // Lazy, not a field initializer - `packageName` reads through the
// Activity's base Context, which isn't attached yet while this class's // 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 val mainHandler = Handler(Looper.getMainLooper())
private var newShareSink: EventChannel.EventSink? = null private var newShareSink: EventChannel.EventSink? = null
private var newPickSink: EventChannel.EventSink? = null private var newPickSink: EventChannel.EventSink? = null
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -114,6 +127,74 @@ class MainActivity : FlutterFragmentActivity() {
newPickSink = null 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<String, Any?> {
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<String>("accountId"),
serverUrl = call.argument<String>("serverUrl"),
username = call.argument<String>("username"),
authHeader = call.argument<String>("authHeader"),
fileId = call.argument<String>("fileId"),
remotePath = call.argument<String>("remotePath"),
relPath = call.argument<String>("relPath"),
resolution = call.argument<String>("resolution"),
)
result.success(null)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -262,6 +343,85 @@ class MainActivity : FlutterFragmentActivity() {
result.success(null) 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<String>("accountId")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
val authHeader = call.argument<String>("authHeader")
val foldersJson = call.argument<String>("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<String>("folders")
val wifiOnly = call.argument<Boolean>("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<SyncWorker>(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<SyncWorker>().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 /// 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 /// Intent) as another app's GET_CONTENT picker - the mimeType filter and
/// multi-select flag the caller asked for, plus a best-effort display /// multi-select flag the caller asked for, plus a best-effort display
@@ -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),
)
}
}
@@ -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<String, FileState> {
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<String, FileState>()
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<String, FileState>) {
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<RemoteEntry> = 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<RemoteEntry> {
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 = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<oc:fileid/>
</d:prop>
</d:propfind>"""
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<RemoteEntry> {
val marker = "/remote.php/dav/files/$username"
val selfPath = cleanPath.trimEnd('/')
val responses = doc.getElementsByTagNameNS("DAV:", "response")
val entries = mutableListOf<RemoteEntry>()
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<RemoteEntry> {
val result = mutableListOf<RemoteEntry>()
val queue = ArrayDeque<String>()
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<RemoteEntry>,
state: Map<String, FileState>,
priorFolderFileIds: Set<String>,
syncRoot: File,
): List<SyncAction> {
val actions = mutableListOf<SyncAction>()
val seenFileIds = mutableSetOf<String>()
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>): String? {
val normalizedItem = itemPath.trimEnd('/')
for (folder in syncedFolders) {
val normalizedFolder = folder.trimEnd('/')
if (normalizedItem == normalizedFolder || normalizedItem.startsWith("$normalizedFolder/")) {
return folder
}
}
return null
}
}
@@ -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<String>,
val conflicts: List<Conflict>,
)
@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<Conflict>) {
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) }
}
}
@@ -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<Pair<SyncEngine.RemoteEntry, String>>()
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<String>()
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<Pair<SyncEngine.RemoteEntry, String>>,
) {
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)
}
}
}
+38
View File
@@ -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<dynamic, dynamic> 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 }
+180
View File
@@ -14,11 +14,13 @@ import '../models/nextcloud_share.dart';
import '../models/nextcloud_sharee.dart'; import '../models/nextcloud_sharee.dart';
import '../models/pick_request.dart'; import '../models/pick_request.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../models/sync_status.dart';
import '../services/account_store.dart'; import '../services/account_store.dart';
import '../services/app_lock_service.dart'; import '../services/app_lock_service.dart';
import '../services/login_flow_service.dart'; import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart'; import '../services/nextcloud_service.dart';
import '../services/pick_intent_service.dart'; import '../services/pick_intent_service.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } 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 _prefLoginLockEnabled = 'ui_login_lock_enabled';
static const _prefLockAccountSwitching = 'ui_lock_account_switching'; static const _prefLockAccountSwitching = 'ui_lock_account_switching';
static const _prefLockHiddenFiles = 'ui_lock_hidden_files'; 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<SharedPreferences> _prefsFuture = final Future<SharedPreferences> _prefsFuture =
SharedPreferences.getInstance(); SharedPreferences.getInstance();
@@ -127,6 +132,23 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
double _bottomBarBlur = 28; double _bottomBarBlur = 28;
bool _tapTabToScrollTop = true; 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<String> _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<String> _syncingFileIds = {};
Set<String> _syncedFileIds = {};
List<SyncConflictInfo> _syncConflicts = [];
StreamSubscription<SyncStatusSnapshot>? _syncStatusSub;
// App lock (PIN/biometric via the device's own credential, not our own // App lock (PIN/biometric via the device's own credential, not our own
// storage - see AppLockService). Global, not per-account: it guards // storage - see AppLockService). Global, not per-account: it guards
// access to the app/its accounts, not any one account's content. // access to the app/its accounts, not any one account's content.
@@ -255,6 +277,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
ServerProvider() { ServerProvider() {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_init(); _init();
_subscribeToSyncStatus();
} }
Future<void> _init() async { Future<void> _init() async {
@@ -265,6 +288,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
await Future.wait([_loadPreferences(), _restoreSession()]); 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 /// Starts/stops the periodic folder-listing refresh as the app leaves and
/// returns to the foreground - only relevant under [CachePolicy.interval]. /// returns to the foreground - only relevant under [CachePolicy.interval].
/// Also re-locks the app on backgrounding when login lock is set up - a /// 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 lockHiddenFiles => _lockHiddenFiles;
bool get needsUnlock => _loginLockEnabled && !_isUnlocked; bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
List<String> get syncedPaths => List.unmodifiable(_syncedPaths);
bool get syncEverything => _syncEverything;
bool get syncOnCellular => _syncOnCellular;
bool get isSyncingNow => _isSyncingNow;
List<SyncConflictInfo> 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; String get currentFolderPath => _currentFolderPath;
AppTab? get requestedTab => _requestedTab; AppTab? get requestedTab => _requestedTab;
@@ -621,6 +690,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching; prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
_lockHiddenFiles = _lockHiddenFiles =
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles; prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
_applyAccountPrefs(prefs, _activeAccountId); _applyAccountPrefs(prefs, _activeAccountId);
final savedOrderNames = prefs.getStringList(_prefTabOrder); final savedOrderNames = prefs.getStringList(_prefTabOrder);
@@ -709,6 +779,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_photosSortAscending = true; _photosSortAscending = true;
_cachePolicy = CachePolicy.never; _cachePolicy = CachePolicy.never;
_cacheIntervalMinutes = 5; _cacheIntervalMinutes = 5;
_syncedPaths = [];
_syncEverything = false;
return; return;
} }
@@ -767,6 +839,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
orElse: () => CachePolicy.never, orElse: () => CachePolicy.never,
); );
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5; _cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders));
if (syncedFoldersJson != null) {
try {
_syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast<String>();
} 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 /// 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; if (gen != _sessionGeneration) return false;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
unawaited(SyncService.reschedule(this));
unawaited(SyncService.getStatus().then(_applySyncStatus));
return true; return true;
} }
} catch (e) { } catch (e) {
@@ -1131,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
final prefs = await _prefsFuture; final prefs = await _prefsFuture;
await _accountStore.saveActiveAccountId(prefs, null); await _accountStore.saveActiveAccountId(prefs, null);
_applyAccountPrefs(prefs, null); _applyAccountPrefs(prefs, null);
_isSyncingNow = false;
_syncingFileIds = {};
_syncedFileIds = {};
_syncConflicts = [];
notifyListeners(); notifyListeners();
unawaited(SyncService.cancel());
} }
/// Ends the active session but keeps this account saved - unlike /// Ends the active session but keeps this account saved - unlike
@@ -1473,6 +1565,93 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_persistFolderSort(); _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<void> 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 `<externalFilesDir>/sync/<accountId>/...`
/// 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<String?> 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<AppTab> order) { void setTabOrder(List<AppTab> order) {
_tabOrder = order; _tabOrder = order;
notifyListeners(); notifyListeners();
@@ -2154,6 +2333,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
_pollTimer?.cancel(); _pollTimer?.cancel();
_pollTimeoutTimer?.cancel(); _pollTimeoutTimer?.cancel();
_cacheRefreshTimer?.cancel(); _cacheRefreshTimer?.cancel();
_syncStatusSub?.cancel();
super.dispose(); super.dispose();
} }
} }
+2
View File
@@ -37,6 +37,8 @@ class AccountStore {
'ui_files_type_filter', 'ui_files_type_filter',
'ui_cache_policy', 'ui_cache_policy',
'ui_cache_interval_minutes', 'ui_cache_interval_minutes',
'ui_synced_folders',
'ui_sync_everything',
]; ];
String accountPrefKey(String accountId, String baseKey) => String accountPrefKey(String accountId, String baseKey) =>
+144
View File
@@ -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<String> syncingFileIds;
final Set<String> syncedFileIds;
final List<SyncConflictInfo> conflicts;
const SyncStatusSnapshot({
required this.accountId,
required this.syncing,
required this.syncingFileIds,
required this.syncedFileIds,
required this.conflicts,
});
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
return SyncStatusSnapshot(
accountId: map['accountId'] as String?,
syncing: map['syncing'] as bool? ?? false,
syncingFileIds: ((map['syncingFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
syncedFileIds: ((map['syncedFileIds'] as List?) ?? const [])
.cast<String>()
.toSet(),
conflicts: ((map['conflicts'] as List?) ?? const [])
.cast<Map<dynamic, dynamic>>()
.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<void> 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<void> cancel() async {
await _channel.invokeMethod('cancel');
}
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
/// independent of the periodic schedule.
static Future<void> 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<SyncStatusSnapshot> getStatus() async {
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
'getSyncStatus',
);
return SyncStatusSnapshot.fromMap(result ?? const {});
}
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
/// started/stopped, per-file progress, new/resolved conflicts.
static Stream<SyncStatusSnapshot> get statusStream {
return _statusChannel.receiveBroadcastStream().map(
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
);
}
/// 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<void> 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,
});
}
}
+126
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../models/app_tab.dart'; import '../models/app_tab.dart';
import '../models/saved_account.dart'; import '../models/saved_account.dart';
import '../providers/server_provider.dart'; import '../providers/server_provider.dart';
import '../services/sync_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../widgets/frosted_glass_container.dart'; import '../widgets/frosted_glass_container.dart';
import '../widgets/seek_bar_painter.dart'; import '../widgets/seek_bar_painter.dart';
@@ -237,6 +238,17 @@ class AccountView extends StatelessWidget {
const _SecurityCard(), const _SecurityCard(),
const SizedBox(height: 24), 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 // Material You Design Settings
Text( Text(
'Material You Aesthetics', '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<void> _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<ServerProvider>();
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 { class _BottomBarAppearanceCard extends StatefulWidget {
const _BottomBarAppearanceCard(); const _BottomBarAppearanceCard();
+93 -17
View File
@@ -1,8 +1,10 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
@@ -21,6 +23,7 @@ import '../widgets/share_sheet.dart';
import '../widgets/sort_menu_button.dart'; import '../widgets/sort_menu_button.dart';
import '../widgets/sticky_header_delegate.dart'; import '../widgets/sticky_header_delegate.dart';
import '../widgets/swipeable_item.dart'; import '../widgets/swipeable_item.dart';
import '../widgets/sync_status_badge.dart';
import '../widgets/synced_header_scaffold.dart'; import '../widgets/synced_header_scaffold.dart';
import 'file_viewer_screen.dart'; import 'file_viewer_screen.dart';
import 'move_copy_destination_picker.dart'; import 'move_copy_destination_picker.dart';
@@ -284,6 +287,18 @@ class _FilesViewState extends State<FilesView>
label: 'Rename', label: 'Rename',
onTap: () => _renameItem(provider, selected.single), 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) if (selected.length == 1)
SelectionAction( SelectionAction(
icon: Icons.info_outline_rounded, icon: Icons.info_outline_rounded,
@@ -803,6 +818,46 @@ class _FilesViewState extends State<FilesView>
if (files.isEmpty) return; if (files.isEmpty) return;
final messenger = ScaffoldMessenger.of(context); 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 { try {
await DownloadService.startDownload(provider, files); await DownloadService.startDownload(provider, files);
messenger.showSnackBar( messenger.showSnackBar(
@@ -930,17 +985,29 @@ class _FilesViewState extends State<FilesView>
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
children: [ children: [
SelectableThumbnail( Stack(
isSelected: isSelected, clipBehavior: Clip.none,
size: 44, children: [
checkmarkSize: 24, SelectableThumbnail(
child: ItemThumbnail( isSelected: isSelected,
item: item, size: 44,
provider: provider, checkmarkSize: 24,
size: 44, child: ItemThumbnail(
borderRadius: 12, item: item,
iconSize: 22, provider: provider,
), size: 44,
borderRadius: 12,
iconSize: 22,
),
),
Positioned(
right: -2,
bottom: -2,
child: SyncStatusBadge(
status: provider.syncStatusFor(item),
),
),
],
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
@@ -1036,12 +1103,21 @@ class _FilesViewState extends State<FilesView>
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
? null ? null
: () => _toggleSelection(item), : () => _toggleSelection(item),
child: SelectableThumbnail( child: Stack(
isSelected: isSelected, children: [
checkmarkSize: 32, SelectableThumbnail(
child: isMedia isSelected: isSelected,
? _buildMediaGridContent(context, item, provider) checkmarkSize: 32,
: _buildPlainGridContent(context, item), child: isMedia
? _buildMediaGridContent(context, item, provider)
: _buildPlainGridContent(context, item),
),
Positioned(
right: 6,
bottom: 6,
child: SyncStatusBadge(status: provider.syncStatusFor(item)),
),
],
), ),
), ),
); );
+11 -3
View File
@@ -236,9 +236,17 @@ class _ShareSheetState extends State<ShareSheet> {
setState(() => _isSharingFile = true); setState(() => _isSharingFile = true);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
final tempDir = await getTemporaryDirectory(); // Already mirrored locally by device sync? Share that copy straight
final tempPath = p.join(tempDir.path, widget.item.name); // away instead of a fresh WebDAV fetch - see
await provider.service!.downloadToFile(widget.item.path, tempPath); // 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; if (!mounted) return;
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams( ShareParams(
+34
View File
@@ -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),
);
}
}
+131 -11
View File
@@ -1,7 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/nextcloud_item.dart'; import '../models/nextcloud_item.dart';
import '../models/sync_status.dart';
import '../providers/server_provider.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) { String formatBytes(int bytes) {
if (bytes <= 0) return '0 B'; if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
@@ -188,7 +204,9 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
stretch: widget.selectionBar == null, stretch: widget.selectionBar == null,
expandedHeight: widget.selectionBar != null expandedHeight: widget.selectionBar != null
? kToolbarHeight ? kToolbarHeight
: (_headerLocked ? 190 : kToolbarHeight), : (_headerLocked
? 190.0 + provider.syncConflicts.length * 52.0
: kToolbarHeight),
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
backgroundColor: colorScheme.surfaceContainer, backgroundColor: colorScheme.surfaceContainer,
surfaceTintColor: colorScheme.surfaceContainer, surfaceTintColor: colorScheme.surfaceContainer,
@@ -278,7 +296,11 @@ class _SyncStatusChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; 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( return Padding(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
@@ -293,16 +315,10 @@ class _SyncStatusChip extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(icon, color: iconColor, size: 18),
provider.isLoading
? Icons.cloud_sync_rounded
: Icons.cloud_done_outlined,
color: colorScheme.primary,
size: 18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
quota != null ? formatQuota(quota) : 'Sync', label,
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -348,6 +364,9 @@ class _SyncedStretchPanel extends StatelessWidget {
// before the 100px lock threshold. // before the 100px lock threshold.
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0); final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
final quota = provider.quota; final quota = provider.quota;
final status = provider.syncHeaderStatus;
final (_, statusLabel) = _syncHeaderDisplay(status);
final conflicts = provider.syncConflicts;
return Stack( return Stack(
children: [ children: [
@@ -365,7 +384,7 @@ class _SyncedStretchPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
provider.isLoading ? 'Syncing…' : 'Synced', statusLabel,
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, 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<void> _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'),
),
],
],
),
);
}
}