Compare commits
1
Commits
722b66f7b1
..
RC4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faaec5365f |
@@ -54,10 +54,10 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se
|
||||
- UI settings that persist across launches — split into **global** (theme
|
||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||
actions, login lock — see below) and **per-account** (grid/list view,
|
||||
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||
per-folder sort map, cache policy) — see `server.md` for exactly which is
|
||||
which and why
|
||||
actions, login lock, sync-on-cellular — see below) and **per-account**
|
||||
(grid/list view, favorites-only, show-hidden, storage scope, Photos sort,
|
||||
Files' per-folder sort map, cache policy, synced folders) — see
|
||||
`server.md` for exactly which is which and why
|
||||
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
|
||||
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
|
||||
`AppLockService` (a thin wrapper over `local_auth` — this app never
|
||||
|
||||
+151
-6
@@ -157,12 +157,12 @@ rather than needing a rewrite for multi-account support.
|
||||
(`views/move_copy_destination_picker.dart`) is covered in
|
||||
`architecture.md`, including why it can't reuse the Files tab's shared
|
||||
navigation state the way `ShareUploadView` does.
|
||||
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath,
|
||||
{onProgress})`) stream the local file via `Dio().put()` with an explicit
|
||||
`Content-Length` and `onSendProgress`, mirroring the download path. The
|
||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||
- **Uploads**: there's no more in-app-only upload path - `NextcloudService`/
|
||||
`ServerProvider.uploadFileFromPath` were removed once the Files tab's "+"
|
||||
→ "Upload File" was unified with the share-to-upload flow (below). Every
|
||||
upload, however it's triggered, now goes through `ShareUploadView`
|
||||
(caller-chosen destination via `navigateToAbsoluteFolder`, then
|
||||
`UploadService`/`ShareUploadService.kt`).
|
||||
- **Receiving a shared file from another app**: hand-rolled in
|
||||
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
|
||||
`android:launchMode` `singleTask` in the manifest so a second share while
|
||||
@@ -246,6 +246,151 @@ Google Drive/Instagram's "choose a file" flow), the reverse direction of
|
||||
backing out (system back while picking, or a picked-item mismatch) with
|
||||
`RESULT_CANCELED` instead.
|
||||
|
||||
## Device sync
|
||||
|
||||
Mirrors selected folders to app-private local storage
|
||||
(`getExternalFilesDir(null)/sync/<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
|
||||
|
||||
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||
|
||||
@@ -63,7 +63,17 @@ widgets. Key points:
|
||||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
||||
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
||||
the classic Material refresh spinner live.
|
||||
the classic Material refresh spinner live. Its persistent chip/panel
|
||||
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
|
||||
status (`ServerProvider.syncHeaderStatus`), not the WebDAV-refresh
|
||||
loading state the pull gesture itself triggers - that has its own,
|
||||
separate floating spinner bubble, so nothing was lost by handing the
|
||||
persistent text/icon over.
|
||||
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
|
||||
corner badge over a thumbnail showing per-item device-sync status
|
||||
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
|
||||
list and grid tiles today. Reuse this rather than a new ad hoc badge if
|
||||
another view starts showing sync status per item.
|
||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||
for the video player's seek bar, plus a perpetually-animated
|
||||
|
||||
@@ -84,6 +84,18 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Device sync's periodic/one-off background jobs (SyncWorker,
|
||||
// ConflictResolveWorker) - see their doc comments for why this is
|
||||
// plain WorkManager rather than a Dart-side background-task plugin.
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
// PROPFIND (WebDAV directory listing) - Android's HttpURLConnection
|
||||
// hard-rejects any method outside {OPTIONS,GET,HEAD,POST,PUT,DELETE,
|
||||
// TRACE,PATCH} (ProtocolException), unlike plain OpenJDK. OkHttp has
|
||||
// no such whitelist. See SyncEngine.kt.
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
Vendored
+18
@@ -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 { *; }
|
||||
@@ -76,6 +76,13 @@
|
||||
android:name=".DownloadService"
|
||||
android:exported="false"
|
||||
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
|
||||
above - only the app's own cache/picker/ subfolder is exposed,
|
||||
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.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
||||
// local_auth's Android implementation, which hosts its biometric/device
|
||||
@@ -44,6 +54,8 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
private val downloadServiceChannelName = "dev.ayushya.noo/download_service"
|
||||
private val pickIntentChannelName = "dev.ayushya.noo/pick_intent"
|
||||
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
|
||||
private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
|
||||
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
|
||||
private val notificationPermissionRequestCode = 4202
|
||||
// Lazy, not a field initializer - `packageName` reads through the
|
||||
// Activity's base Context, which isn't attached yet while this class's
|
||||
@@ -53,6 +65,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var newShareSink: EventChannel.EventSink? = null
|
||||
private var newPickSink: EventChannel.EventSink? = null
|
||||
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -114,6 +127,74 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
newPickSink = null
|
||||
}
|
||||
})
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, syncServiceChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"reschedule" -> rescheduleSyncWork(call, result)
|
||||
"cancel" -> cancelSyncWork(result)
|
||||
"syncNow" -> syncNow(call, result)
|
||||
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
|
||||
"resolveConflict" -> resolveConflict(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, syncStatusChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
val listener: (SyncStatusBus.Status) -> Unit = { status ->
|
||||
mainHandler.post { events.success(syncStatusMap(status)) }
|
||||
}
|
||||
syncStatusListener = listener
|
||||
SyncStatusBus.subscribe(listener)
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
syncStatusListener?.let { SyncStatusBus.unsubscribe(it) }
|
||||
syncStatusListener = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun syncStatusMap(status: SyncStatusBus.Status): Map<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) {
|
||||
@@ -262,6 +343,85 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Shared arg-parsing for `reschedule`/`syncNow` - both need the same
|
||||
/// account/credentials/folder-list shape, just enqueue differently.
|
||||
private fun syncWorkData(call: MethodCall): androidx.work.Data? {
|
||||
val accountId = call.argument<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
|
||||
/// Intent) as another app's GET_CONTENT picker - the mimeType filter and
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -14,11 +14,13 @@ import '../models/nextcloud_share.dart';
|
||||
import '../models/nextcloud_sharee.dart';
|
||||
import '../models/pick_request.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../models/sync_status.dart';
|
||||
import '../services/account_store.dart';
|
||||
import '../services/app_lock_service.dart';
|
||||
import '../services/login_flow_service.dart';
|
||||
import '../services/nextcloud_service.dart';
|
||||
import '../services/pick_intent_service.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
|
||||
@@ -97,6 +99,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const _prefLoginLockEnabled = 'ui_login_lock_enabled';
|
||||
static const _prefLockAccountSwitching = 'ui_lock_account_switching';
|
||||
static const _prefLockHiddenFiles = 'ui_lock_hidden_files';
|
||||
static const _prefSyncedFolders = 'ui_synced_folders';
|
||||
static const _prefSyncEverything = 'ui_sync_everything';
|
||||
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
|
||||
|
||||
final Future<SharedPreferences> _prefsFuture =
|
||||
SharedPreferences.getInstance();
|
||||
@@ -127,6 +132,23 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
double _bottomBarBlur = 28;
|
||||
bool _tapTabToScrollTop = true;
|
||||
|
||||
// Device sync - which remote paths (files or folders, per account) get
|
||||
// mirrored locally by the native SyncWorker, and whether its periodic
|
||||
// background runs are allowed on cellular (default Wi-Fi-only). See
|
||||
// SyncEngine.kt/server.md.
|
||||
List<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
|
||||
// storage - see AppLockService). Global, not per-account: it guards
|
||||
// access to the app/its accounts, not any one account's content.
|
||||
@@ -255,6 +277,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
ServerProvider() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_init();
|
||||
_subscribeToSyncStatus();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
@@ -265,6 +288,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
await Future.wait([_loadPreferences(), _restoreSession()]);
|
||||
}
|
||||
|
||||
/// Live device-sync status for the rest of the app's lifetime (not
|
||||
/// re-subscribed per account switch - each event carries its own
|
||||
/// `accountId`, so events for an account that isn't currently active are
|
||||
/// just ignored below). Seeded once via a one-shot snapshot right after
|
||||
/// every successful login/account-switch instead (see
|
||||
/// `_applyCredentialsForAccount`), since events only arrive on change.
|
||||
void _subscribeToSyncStatus() {
|
||||
_syncStatusSub = SyncService.statusStream.listen((snapshot) {
|
||||
_applySyncStatus(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
void _applySyncStatus(SyncStatusSnapshot snapshot) {
|
||||
if (snapshot.accountId != null && snapshot.accountId != _activeAccountId) {
|
||||
return;
|
||||
}
|
||||
_isSyncingNow = snapshot.syncing;
|
||||
_syncingFileIds = snapshot.syncingFileIds;
|
||||
_syncedFileIds = snapshot.syncedFileIds;
|
||||
_syncConflicts = snapshot.conflicts;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
||||
/// returns to the foreground - only relevant under [CachePolicy.interval].
|
||||
/// Also re-locks the app on backgrounding when login lock is set up - a
|
||||
@@ -323,6 +369,29 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool get lockHiddenFiles => _lockHiddenFiles;
|
||||
bool get needsUnlock => _loginLockEnabled && !_isUnlocked;
|
||||
|
||||
List<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;
|
||||
AppTab? get requestedTab => _requestedTab;
|
||||
|
||||
@@ -621,6 +690,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
prefs.getBool(_prefLockAccountSwitching) ?? _lockAccountSwitching;
|
||||
_lockHiddenFiles =
|
||||
prefs.getBool(_prefLockHiddenFiles) ?? _lockHiddenFiles;
|
||||
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
|
||||
_applyAccountPrefs(prefs, _activeAccountId);
|
||||
|
||||
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||
@@ -709,6 +779,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_photosSortAscending = true;
|
||||
_cachePolicy = CachePolicy.never;
|
||||
_cacheIntervalMinutes = 5;
|
||||
_syncedPaths = [];
|
||||
_syncEverything = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -767,6 +839,19 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
orElse: () => CachePolicy.never,
|
||||
);
|
||||
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
|
||||
|
||||
final syncedFoldersJson = prefs.getString(k(_prefSyncedFolders));
|
||||
if (syncedFoldersJson != null) {
|
||||
try {
|
||||
_syncedPaths = (jsonDecode(syncedFoldersJson) as List).cast<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
|
||||
@@ -806,6 +891,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
if (gen != _sessionGeneration) return false;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
unawaited(SyncService.reschedule(this));
|
||||
unawaited(SyncService.getStatus().then(_applySyncStatus));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1131,7 +1218,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final prefs = await _prefsFuture;
|
||||
await _accountStore.saveActiveAccountId(prefs, null);
|
||||
_applyAccountPrefs(prefs, null);
|
||||
_isSyncingNow = false;
|
||||
_syncingFileIds = {};
|
||||
_syncedFileIds = {};
|
||||
_syncConflicts = [];
|
||||
notifyListeners();
|
||||
unawaited(SyncService.cancel());
|
||||
}
|
||||
|
||||
/// Ends the active session but keeps this account saved - unlike
|
||||
@@ -1473,6 +1565,93 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_persistFolderSort();
|
||||
}
|
||||
|
||||
void _persistSyncedPaths() {
|
||||
_persistAccountPref(
|
||||
_prefSyncedFolders,
|
||||
(p, key) => p.setString(key, jsonEncode(_syncedPaths)),
|
||||
);
|
||||
}
|
||||
|
||||
bool isPathSynced(String path) => _syncedPaths.contains(path);
|
||||
|
||||
void addSyncedPath(String path) {
|
||||
if (_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = [..._syncedPaths, path];
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(this));
|
||||
}
|
||||
|
||||
void removeSyncedPath(String path) {
|
||||
if (!_syncedPaths.contains(path)) return;
|
||||
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
|
||||
notifyListeners();
|
||||
_persistSyncedPaths();
|
||||
unawaited(SyncService.reschedule(this));
|
||||
}
|
||||
|
||||
void setSyncEverything(bool value) {
|
||||
if (_syncEverything == value) return;
|
||||
_syncEverything = value;
|
||||
notifyListeners();
|
||||
_persistAccountPref(_prefSyncEverything, (p, key) => p.setBool(key, value));
|
||||
unawaited(SyncService.reschedule(this));
|
||||
}
|
||||
|
||||
void setSyncOnCellular(bool value) {
|
||||
if (_syncOnCellular == value) return;
|
||||
_syncOnCellular = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefSyncOnCellular, value));
|
||||
unawaited(SyncService.reschedule(this));
|
||||
}
|
||||
|
||||
/// In-app conflict resolution (the sync header's "Keep local"/"Use
|
||||
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
|
||||
/// for why this shares the exact same native path the notification
|
||||
/// actions use.
|
||||
Future<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) {
|
||||
_tabOrder = order;
|
||||
notifyListeners();
|
||||
@@ -2154,6 +2333,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimeoutTimer?.cancel();
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_syncStatusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ class AccountStore {
|
||||
'ui_files_type_filter',
|
||||
'ui_cache_policy',
|
||||
'ui_cache_interval_minutes',
|
||||
'ui_synced_folders',
|
||||
'ui_sync_everything',
|
||||
];
|
||||
|
||||
String accountPrefKey(String accountId, String baseKey) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../models/app_tab.dart';
|
||||
import '../models/saved_account.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/frosted_glass_container.dart';
|
||||
import '../widgets/seek_bar_painter.dart';
|
||||
@@ -237,6 +238,17 @@ class AccountView extends StatelessWidget {
|
||||
const _SecurityCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Device Sync Section
|
||||
Text(
|
||||
'Device Sync',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const _DeviceSyncCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Material You Design Settings
|
||||
Text(
|
||||
'Material You Aesthetics',
|
||||
@@ -722,6 +734,120 @@ class _SecurityCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceSyncCard extends StatefulWidget {
|
||||
const _DeviceSyncCard();
|
||||
|
||||
@override
|
||||
State<_DeviceSyncCard> createState() => _DeviceSyncCardState();
|
||||
}
|
||||
|
||||
class _DeviceSyncCardState extends State<_DeviceSyncCard> {
|
||||
bool _syncingNow = false;
|
||||
|
||||
Future<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 {
|
||||
const _BottomBarAppearanceCard();
|
||||
|
||||
|
||||
+93
-17
@@ -1,8 +1,10 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
@@ -21,6 +23,7 @@ import '../widgets/share_sheet.dart';
|
||||
import '../widgets/sort_menu_button.dart';
|
||||
import '../widgets/sticky_header_delegate.dart';
|
||||
import '../widgets/swipeable_item.dart';
|
||||
import '../widgets/sync_status_badge.dart';
|
||||
import '../widgets/synced_header_scaffold.dart';
|
||||
import 'file_viewer_screen.dart';
|
||||
import 'move_copy_destination_picker.dart';
|
||||
@@ -284,6 +287,18 @@ class _FilesViewState extends State<FilesView>
|
||||
label: 'Rename',
|
||||
onTap: () => _renameItem(provider, selected.single),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: provider.isPathSynced(selected.single.path)
|
||||
? Icons.sync_rounded
|
||||
: Icons.sync_outlined,
|
||||
label: provider.isPathSynced(selected.single.path)
|
||||
? 'Stop syncing to device'
|
||||
: 'Sync to device',
|
||||
onTap: () => provider.isPathSynced(selected.single.path)
|
||||
? provider.removeSyncedPath(selected.single.path)
|
||||
: provider.addSyncedPath(selected.single.path),
|
||||
),
|
||||
if (selected.length == 1)
|
||||
SelectionAction(
|
||||
icon: Icons.info_outline_rounded,
|
||||
@@ -803,6 +818,46 @@ class _FilesViewState extends State<FilesView>
|
||||
if (files.isEmpty) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
// Already mirrored locally by device sync (every selected file, not
|
||||
// just some)? Save straight from the local copy instead of a fresh
|
||||
// network fetch through DownloadService - see
|
||||
// ServerProvider.localSyncedFilePath.
|
||||
final localPaths = await Future.wait(
|
||||
files.map(provider.localSyncedFilePath),
|
||||
);
|
||||
if (localPaths.every((path) => path != null)) {
|
||||
try {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
final ext = p.extension(files[i].name).replaceFirst('.', '');
|
||||
final baseName = p.basenameWithoutExtension(files[i].name);
|
||||
await FileSaver.instance.saveFile(
|
||||
name: baseName,
|
||||
filePath: localPaths[i]!,
|
||||
ext: ext,
|
||||
);
|
||||
}
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
files.length == 1
|
||||
? 'Downloaded ${files.first.name}'
|
||||
: 'Downloaded ${files.length} files',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not save file(s): $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DownloadService.startDownload(provider, files);
|
||||
messenger.showSnackBar(
|
||||
@@ -930,17 +985,29 @@ class _FilesViewState extends State<FilesView>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
SelectableThumbnail(
|
||||
isSelected: isSelected,
|
||||
size: 44,
|
||||
checkmarkSize: 24,
|
||||
child: ItemThumbnail(
|
||||
item: item,
|
||||
provider: provider,
|
||||
size: 44,
|
||||
borderRadius: 12,
|
||||
iconSize: 22,
|
||||
),
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SelectableThumbnail(
|
||||
isSelected: isSelected,
|
||||
size: 44,
|
||||
checkmarkSize: 24,
|
||||
child: ItemThumbnail(
|
||||
item: item,
|
||||
provider: provider,
|
||||
size: 44,
|
||||
borderRadius: 12,
|
||||
iconSize: 22,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: SyncStatusBadge(
|
||||
status: provider.syncStatusFor(item),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -1036,12 +1103,21 @@ class _FilesViewState extends State<FilesView>
|
||||
onLongPress: provider.isPicking && !provider.pickRequest!.allowMultiple
|
||||
? null
|
||||
: () => _toggleSelection(item),
|
||||
child: SelectableThumbnail(
|
||||
isSelected: isSelected,
|
||||
checkmarkSize: 32,
|
||||
child: isMedia
|
||||
? _buildMediaGridContent(context, item, provider)
|
||||
: _buildPlainGridContent(context, item),
|
||||
child: Stack(
|
||||
children: [
|
||||
SelectableThumbnail(
|
||||
isSelected: isSelected,
|
||||
checkmarkSize: 32,
|
||||
child: isMedia
|
||||
? _buildMediaGridContent(context, item, provider)
|
||||
: _buildPlainGridContent(context, item),
|
||||
),
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: SyncStatusBadge(status: provider.syncStatusFor(item)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -236,9 +236,17 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
setState(() => _isSharingFile = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempPath = p.join(tempDir.path, widget.item.name);
|
||||
await provider.service!.downloadToFile(widget.item.path, tempPath);
|
||||
// Already mirrored locally by device sync? Share that copy straight
|
||||
// away instead of a fresh WebDAV fetch - see
|
||||
// ServerProvider.localSyncedFilePath.
|
||||
final tempPath =
|
||||
await provider.localSyncedFilePath(widget.item) ??
|
||||
await () async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final path = p.join(tempDir.path, widget.item.name);
|
||||
await provider.service!.downloadToFile(widget.item.path, path);
|
||||
return path;
|
||||
}();
|
||||
if (!mounted) return;
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../models/sync_status.dart';
|
||||
import '../providers/server_provider.dart';
|
||||
|
||||
/// Icon/label for the persistent header chip/panel - reflects device-sync
|
||||
/// status (see `ServerProvider.syncHeaderStatus`), not the WebDAV
|
||||
/// directory-listing refresh the pull gesture itself triggers (that has
|
||||
/// its own, separate floating spinner bubble - see
|
||||
/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`).
|
||||
(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status) {
|
||||
return switch (status) {
|
||||
SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'),
|
||||
SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'),
|
||||
SyncHeaderStatus.done => (Icons.cloud_done_rounded, 'Synced'),
|
||||
SyncHeaderStatus.alert => (Symbols.cloud_alert_rounded, 'Sync issue'),
|
||||
};
|
||||
}
|
||||
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
@@ -188,7 +204,9 @@ class _SyncedHeaderScaffoldState extends State<SyncedHeaderScaffold> {
|
||||
stretch: widget.selectionBar == null,
|
||||
expandedHeight: widget.selectionBar != null
|
||||
? kToolbarHeight
|
||||
: (_headerLocked ? 190 : kToolbarHeight),
|
||||
: (_headerLocked
|
||||
? 190.0 + provider.syncConflicts.length * 52.0
|
||||
: kToolbarHeight),
|
||||
collapsedHeight: kToolbarHeight,
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
surfaceTintColor: colorScheme.surfaceContainer,
|
||||
@@ -278,7 +296,11 @@ class _SyncStatusChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final quota = provider.quota;
|
||||
final status = provider.syncHeaderStatus;
|
||||
final (icon, label) = _syncHeaderDisplay(status);
|
||||
final iconColor = status == SyncHeaderStatus.alert
|
||||
? colorScheme.error
|
||||
: colorScheme.primary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
@@ -293,16 +315,10 @@ class _SyncStatusChip extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
provider.isLoading
|
||||
? Icons.cloud_sync_rounded
|
||||
: Icons.cloud_done_outlined,
|
||||
color: colorScheme.primary,
|
||||
size: 18,
|
||||
),
|
||||
Icon(icon, color: iconColor, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
quota != null ? formatQuota(quota) : 'Sync',
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@@ -348,6 +364,9 @@ class _SyncedStretchPanel extends StatelessWidget {
|
||||
// before the 100px lock threshold.
|
||||
final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
|
||||
final quota = provider.quota;
|
||||
final status = provider.syncHeaderStatus;
|
||||
final (_, statusLabel) = _syncHeaderDisplay(status);
|
||||
final conflicts = provider.syncConflicts;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -365,7 +384,7 @@ class _SyncedStretchPanel extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
provider.isLoading ? 'Syncing…' : 'Synced',
|
||||
statusLabel,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@@ -414,6 +433,17 @@ class _SyncedStretchPanel extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (conflicts.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
for (final conflict in conflicts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _SyncConflictRow(
|
||||
conflict: conflict,
|
||||
provider: provider,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -424,3 +454,93 @@ class _SyncedStretchPanel extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in the locked-open panel's conflicts section - a file that
|
||||
/// changed both locally and on the server since the last sync, with the
|
||||
/// same two resolutions the sync-conflict notification offers (see
|
||||
/// `SyncWorker.notifyConflicts`/`ConflictResolveWorker` on the native
|
||||
/// side), just triggered in-app instead.
|
||||
class _SyncConflictRow extends StatefulWidget {
|
||||
final SyncConflictInfo conflict;
|
||||
final ServerProvider provider;
|
||||
|
||||
const _SyncConflictRow({required this.conflict, required this.provider});
|
||||
|
||||
@override
|
||||
State<_SyncConflictRow> createState() => _SyncConflictRowState();
|
||||
}
|
||||
|
||||
class _SyncConflictRowState extends State<_SyncConflictRow> {
|
||||
bool _resolving = false;
|
||||
|
||||
Future<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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user