Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s

- Offline tab is now FilesView(offline: true) over a FolderBrowser interface
  (FilesController / OfflineController) sharing controls, tiles, thumbnails
- Synced files follow the Files Cache rule (default: refresh every 15 min):
  per-account WorkManager jobs, in-app timer, resume and pull triggers;
  root-etag shortcut skips full walks when nothing changed
- Single sync at a time (shared lock); logging out stops that account's sync
- Sync safety: PROPFIND failures skip the path instead of deleting local files
- Mirror empty folders and remove deleted ones; drop synced paths deleted on
  the server; missing local files are re-downloaded
- Notifications: silent per-account sync notifications, "Background sync
  notifications" setting, audible upload/download completion
- Files refresh in place (no spinner flash); retry after network returns
- Fix sync badges not updating (shared native status stream), "Sync off" on
  launch (eager providers), Files Cache section moved under Device Sync

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:06:55 -04:00
co-authored by Claude Sonnet 5
parent 49ae712f5e
commit f2bba2914e
38 changed files with 3133 additions and 741 deletions
+4
View File
@@ -2,6 +2,10 @@
<uses-permission android:name="android.permission.INTERNET"/>
<!-- For local_auth's biometric app-lock (Settings > Security). -->
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<!-- For connectivity_plus (ConnectivityController), which gates the
Offline tab restriction and skips network requests entirely while
offline - see session_controller.dart/main.dart. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- For ShareUploadService/DownloadService, which keep a "Share to Noo"
upload/download going (and cancellable from its notification) even
if the app is fully closed - see each service's own doc comment. -->
@@ -7,6 +7,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
@@ -62,6 +63,11 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
// Same lock as SyncWorker: both load/mutate/save the whole state map.
SyncEngine.syncLock.withLock { performResolve() }
}
private suspend fun performResolve(): 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()
@@ -84,7 +90,9 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
// 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 fresh = runCatching {
SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
}.getOrNull()
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
state[fileId] = SyncEngine.FileState(
relPath = relPath,
@@ -42,7 +42,7 @@ class DownloadService : Service() {
const val EXTRA_USERNAME = "username"
const val EXTRA_AUTH_HEADER = "authHeader"
private const val CHANNEL_ID = "file_downloads"
private const val CHANNEL_ID = "file_downloads_v2"
private const val NOTIFICATION_ID = 4301
}
@@ -86,8 +86,11 @@ class DownloadService : Service() {
this,
CHANNEL_ID,
"File downloads",
NotificationManager.IMPORTANCE_LOW,
"Progress for files downloaded from Noo",
// Default (not low) importance so a finished download actually
// alerts; the progress updates themselves stay silent below.
NotificationManager.IMPORTANCE_DEFAULT,
"Progress and completion of files downloaded from Noo",
legacyIds = listOf("file_downloads"),
)
ServiceCompat.startForeground(
this,
@@ -250,6 +253,7 @@ class DownloadService : Service() {
.setContentTitle("Downloading from Noo")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
when {
@@ -132,9 +132,11 @@ class MainActivity : FlutterFragmentActivity() {
.setMethodCallHandler { call, result ->
when (call.method) {
"reschedule" -> rescheduleSyncWork(call, result)
"cancel" -> cancelSyncWork(result)
"cancel" -> cancelSyncWork(call, result)
"syncNow" -> syncNow(call, result)
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
"getSyncStatus" -> result.success(
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
)
"resolveConflict" -> resolveConflict(call, result)
"removeLocalSync" -> removeLocalSync(call, result)
else -> result.notImplemented()
@@ -157,16 +159,31 @@ class MainActivity : FlutterFragmentActivity() {
})
}
private fun syncStatusMap(status: SyncStatusBus.Status): Map<String, Any?> {
val syncedFileIds = status.accountId?.let {
/// [accountIdOverride] is the account Dart is asking about - needed
/// because [SyncStatusBus] only learns an account once a sync pass has
/// run in this process, so on a fresh app start its `accountId` is null
/// and the durable per-file synced state (which is what actually says
/// "this file is mirrored") would otherwise be reported as empty.
private fun syncStatusMap(
status: SyncStatusBus.Status,
accountIdOverride: String? = null,
): Map<String, Any?> {
val accountId = accountIdOverride ?: status.accountId
// Live syncing/conflict state on the bus belongs to whichever
// account last synced - don't attribute it to a different one.
val busMatches = status.accountId == null || status.accountId == accountId
val syncedFileIds = accountId?.let {
SyncEngine.loadState(applicationContext, it).keys.toList()
} ?: emptyList()
return mapOf(
"accountId" to status.accountId,
"syncing" to status.syncing,
"syncingFileIds" to status.syncingFileIds.toList(),
"accountId" to accountId,
"syncing" to (busMatches && status.syncing),
"syncingFileIds" to if (busMatches) status.syncingFileIds.toList() else emptyList(),
"syncedFileIds" to syncedFileIds,
"conflicts" to status.conflicts.map {
"missingRoots" to (accountId?.let {
SyncEngine.loadMissingRoots(applicationContext, it).toList()
} ?: emptyList()),
"conflicts" to (if (busMatches) status.conflicts else emptyList()).map {
mapOf(
"accountId" to it.accountId,
"fileId" to it.fileId,
@@ -346,7 +363,7 @@ class MainActivity : FlutterFragmentActivity() {
/// 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? {
private fun syncWorkData(call: MethodCall, force: Boolean = false): androidx.work.Data? {
val accountId = call.argument<String>("accountId")
val serverUrl = call.argument<String>("serverUrl")
val username = call.argument<String>("username")
@@ -363,6 +380,8 @@ class MainActivity : FlutterFragmentActivity() {
SyncWorker.KEY_USERNAME to username,
SyncWorker.KEY_AUTH_HEADER to authHeader,
SyncWorker.KEY_FOLDERS to foldersJson,
SyncWorker.KEY_FORCE to force,
SyncWorker.KEY_NOTIFY to (call.argument<Boolean>("notify") ?: true),
)
}
@@ -372,13 +391,26 @@ class MainActivity : FlutterFragmentActivity() {
/// 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 accountId = call.argument<String>("accountId")
if (accountId == null) {
result.error("bad_args", "Missing accountId", null)
return
}
val data = syncWorkData(call)
val foldersJson = call.argument<String>("folders")
val wifiOnly = call.argument<Boolean>("wifiOnly") ?: true
// Null = periodic background sync is off (Files Cache set to
// "never"/"manual") - only foreground triggers and pull-to-refresh
// sync then. See SyncStatusController's doc comment.
val intervalMinutes = call.argument<Int>("intervalMinutes")
val workManager = WorkManager.getInstance(this)
// The single shared job from before per-account scheduling.
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0) {
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0 ||
intervalMinutes == null
) {
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
result.success(null)
return
}
@@ -386,23 +418,33 @@ class MainActivity : FlutterFragmentActivity() {
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)
// Follows the Files Cache "refresh periodically" interval. Android
// won't run periodic work more often than every 15 minutes, so
// shorter intervals are clamped up (the app's own in-foreground
// timer covers the shorter cadence while it's open).
val request = PeriodicWorkRequestBuilder<SyncWorker>(
maxOf(intervalMinutes, 15).toLong(),
TimeUnit.MINUTES,
)
.setInputData(data)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
SyncWorker.UNIQUE_PERIODIC_NAME,
SyncWorker.periodicNameFor(accountId),
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
result.success(null)
}
private fun cancelSyncWork(result: MethodChannel.Result) {
WorkManager.getInstance(this).cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
/// Cancels one account's periodic job (e.g. when it's removed).
private fun cancelSyncWork(call: MethodCall, result: MethodChannel.Result) {
val accountId = call.argument<String>("accountId")
val workManager = WorkManager.getInstance(this)
if (accountId != null) {
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
}
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
result.success(null)
}
@@ -427,16 +469,34 @@ class MainActivity : FlutterFragmentActivity() {
/// One-off immediate run (Settings' "Sync now"), independent of the
/// periodic schedule.
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
val data = syncWorkData(call)
// `force` = an explicit "Sync now" / newly-added path: full walk,
// visible progress, and it supersedes any in-flight run. Otherwise
// (pull-to-refresh, foreground timer, app resume) it's a quiet
// check that never interrupts a run already in progress; those may
// additionally respect the Wi-Fi-only setting (`wifiOnly` present).
val force = call.argument<Boolean>("force") ?: true
val data = syncWorkData(call, force)
if (data == null) {
result.error("bad_args", "Missing required sync arguments", null)
return
}
val request = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data).build()
val wifiOnly = call.argument<Boolean>("wifiOnly")
val builder = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data)
if (wifiOnly != null) {
builder.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED,
)
.build(),
)
}
WorkManager.getInstance(this).enqueueUniqueWork(
SyncWorker.UNIQUE_ONE_OFF_NAME,
ExistingWorkPolicy.REPLACE,
request,
// Per account so switching accounts doesn't let one account's
// run replace/keep-out another's.
SyncWorker.oneOffNameFor(call.argument<String>("accountId")!!),
if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
builder.build(),
)
result.success(null)
}
@@ -18,11 +18,21 @@ object NooNotificationChannels {
name: String,
importance: Int,
description: String,
// Old channel ids this one replaces. A channel's importance (and so
// whether it makes sound) can't be changed once it's been created -
// Android ignores later changes - so changing it means moving to a
// new id and removing the old channel.
legacyIds: List<String> = emptyList(),
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
legacyIds.forEach { manager.deleteNotificationChannel(it) }
val channel = NotificationChannel(id, name, importance).apply {
this.description = description
if (importance <= NotificationManager.IMPORTANCE_LOW) {
setSound(null, null)
enableVibration(false)
}
}
manager.createNotificationChannel(channel)
}
@@ -47,7 +47,7 @@ class ShareUploadService : Service() {
const val EXTRA_AUTH_HEADER = "authHeader"
const val EXTRA_REMOTE_FOLDER = "remoteFolder"
private const val CHANNEL_ID = "share_upload"
private const val CHANNEL_ID = "share_upload_v2"
private const val NOTIFICATION_ID = 4201
}
@@ -90,7 +90,9 @@ class ShareUploadService : Service() {
this,
CHANNEL_ID,
"File uploads",
NotificationManager.IMPORTANCE_LOW,
// Default (not low) importance so a finished upload actually
// alerts; the progress updates themselves stay silent below.
NotificationManager.IMPORTANCE_DEFAULT,
"Progress for files shared to Noo",
)
ServiceCompat.startForeground(
@@ -239,6 +241,7 @@ class ShareUploadService : Service() {
.setContentTitle("Uploading to Noo")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
when {
@@ -7,6 +7,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlinx.coroutines.sync.Mutex
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -48,6 +49,27 @@ object SyncEngine {
val localMTime: Long,
)
/**
* What we last saw for a configured sync root when a walk of it fully
* completed cleanly: its own etag (Nextcloud propagates a change to any
* descendant up through every ancestor folder's etag, so an unchanged
* root etag means nothing beneath it changed on the server) and when
* that full walk happened. Lets a background pass answer "did anything
* change?" with one tiny Depth-0 PROPFIND per root instead of
* re-walking the whole tree every time.
*/
data class RootMarker(val etag: String, val fullWalkAt: Long)
/**
* The server couldn't be reached or answered with an error (anything
* but a clean 404). Distinct from "the path is genuinely gone/empty":
* treating a network blip as an empty listing made every previously
* synced file look deleted server-side, and the diff then deleted the
* local copies. Callers skip the affected path for this run instead.
*/
class RemoteUnavailableException(message: String, cause: Throwable? = null) :
Exception(message, cause)
sealed class SyncAction {
data class Download(val entry: RemoteEntry) : SyncAction()
data class Upload(val relPath: String, val fileId: String) : SyncAction()
@@ -55,6 +77,18 @@ object SyncEngine {
data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction()
}
/**
* Held for the whole of any run that reads-then-rewrites a sync state
* map ([SyncWorker], [ConflictResolveWorker]). The periodic job and the
* one-off "sync now" jobs have different WorkManager names, so
* WorkManager itself happily runs them at once - and both load the
* state, mutate it, then save the *whole* map back, so whichever
* finished last silently discarded the other's updates. One process-wide
* lock serializes them (also keeps [SyncStatusBus], which only tracks
* one account at a time, coherent when several accounts are due).
*/
val syncLock = Mutex()
private const val TAG = "NooSync"
private const val STATE_PREFS = "noo_sync_state"
private val rfc1123 =
@@ -73,6 +107,108 @@ object SyncEngine {
}
private fun stateKey(accountId: String) = "state_$accountId"
private fun rootsKey(accountId: String) = "roots_$accountId"
private fun rootsVersionKey(accountId: String) = "roots_version_$accountId"
private fun missingKey(accountId: String) = "missing_$accountId"
// Bumped whenever a walk starts doing something new (e.g. mirroring
// empty folders) so markers recorded by an older version - which would
// make the next run skip the walk - are ignored once.
private const val ROOTS_VERSION = 2
fun loadRootMarkers(context: Context, accountId: String): MutableMap<String, RootMarker> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
if (prefs.getInt(rootsVersionKey(accountId), 0) != ROOTS_VERSION) return mutableMapOf()
val json = prefs.getString(rootsKey(accountId), null) ?: return mutableMapOf()
val obj = JSONObject(json)
val result = mutableMapOf<String, RootMarker>()
for (path in obj.keys()) {
val entry = obj.getJSONObject(path)
result[path] = RootMarker(entry.getString("etag"), entry.getLong("fullWalkAt"))
}
return result
}
fun saveRootMarkers(context: Context, accountId: String, markers: Map<String, RootMarker>) {
val obj = JSONObject()
for ((path, m) in markers) {
obj.put(path, JSONObject().apply {
put("etag", m.etag)
put("fullWalkAt", m.fullWalkAt)
})
}
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
prefs.edit()
.putString(rootsKey(accountId), obj.toString())
.putInt(rootsVersionKey(accountId), ROOTS_VERSION)
.apply()
}
/**
* Configured sync roots the server reported as gone (a clean 404) - e.g.
* a synced file or folder that was deleted on the web. The app removes
* these from its synced list (there's nothing left to sync) and then
* clears them via [removeLocalSync].
*/
fun loadMissingRoots(context: Context, accountId: String): Set<String> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
return prefs.getStringSet(missingKey(accountId), emptySet())?.toSet() ?: emptySet()
}
fun setRootMissing(context: Context, accountId: String, path: String, missing: Boolean) {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
val current = loadMissingRoots(context, accountId).toMutableSet()
val changed = if (missing) current.add(path) else current.remove(path)
if (changed) prefs.edit().putStringSet(missingKey(accountId), current).apply()
}
/** Creates a local directory for every remote folder, so empty folders exist on device too. */
fun mirrorFolders(entries: List<RemoteEntry>, syncRoot: File, rootRel: String) {
if (rootRel.isNotEmpty()) File(syncRoot, rootRel).mkdirs()
for (e in entries) {
if (e.isFolder) File(syncRoot, e.path.trim('/')).mkdirs()
}
}
/**
* Removes local directories under [rootRel] that no longer exist on the
* server ([remoteFolders], relative paths) - the counterpart to
* [mirrorFolders]. Only *empty* directories are removed: by the time this
* runs the files that were inside a deleted server folder have already
* been deleted by the diff, while a directory that still holds something
* (a file this device created that hasn't synced) is left alone. Never
* removes the sync root itself.
*/
fun pruneRemovedFolders(syncRoot: File, rootRel: String, remoteFolders: Set<String>) {
val top = if (rootRel.isEmpty()) syncRoot else File(syncRoot, rootRel)
if (!top.isDirectory) return
fun prune(dir: File, rel: String) {
dir.listFiles()?.filter { it.isDirectory }?.forEach { child ->
prune(child, if (rel.isEmpty()) child.name else "$rel/${child.name}")
}
if (rel.isNotEmpty() && rel !in remoteFolders && dir.list().isNullOrEmpty()) {
dir.delete()
}
}
prune(top, rootRel)
}
/**
* True if every file in [fileIds] is still on disk exactly as the sync
* state last recorded it (same size and mtime) - i.e. there's nothing
* local to upload and nothing missing to re-download. Pure local stat
* calls, no network.
*/
fun localMatchesState(
state: Map<String, FileState>,
fileIds: Collection<String>,
syncRoot: File,
): Boolean = fileIds.all { id ->
val s = state[id] ?: return@all false
val f = File(syncRoot, s.relPath)
f.exists() && f.length() == s.size && f.lastModified() == s.localMTime
}
fun loadState(context: Context, accountId: String): MutableMap<String, FileState> {
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
@@ -161,9 +297,10 @@ object SyncEngine {
return try {
httpClient.newCall(request).execute().use { response ->
Log.d(TAG, "PROPFIND $url -> ${response.code}")
if (response.code == 404) return emptyList() // genuinely gone
if (!response.isSuccessful) {
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
return emptyList()
throw RemoteUnavailableException("PROPFIND $url -> ${response.code}")
}
val doc = DocumentBuilderFactory.newInstance()
@@ -173,9 +310,11 @@ object SyncEngine {
parsePropfindResponse(doc, username, cleanPath, skipSelf)
}
} catch (e: RemoteUnavailableException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "PROPFIND $url threw", e)
emptyList()
throw RemoteUnavailableException("PROPFIND $url threw", e)
}
}
@@ -323,11 +462,16 @@ object SyncEngine {
(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))
!localExists -> {
// State says this file was mirrored but the local copy
// is gone (stale state left over from an earlier sync of
// this path, or the file was removed outside the app).
// It's inside a path the user asked to sync, so pull it
// back down. This used to be treated as "the user
// deleted it, respect that" and emitted a Delete - which
// dropped the state, skipped the download, and reported
// phantom "N removed" for files that were never on disk.
actions.add(SyncAction.Download(entry))
}
serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath))
serverChanged -> actions.add(SyncAction.Download(entry))
@@ -380,6 +524,24 @@ object SyncEngine {
for (fileId in toRemove) state.remove(fileId)
saveState(context, accountId, state)
setRootMissing(context, accountId, "/" + cleanPath, false)
setRootMissing(context, accountId, cleanPath, false)
// A root marker for this path (or an ancestor/descendant that also
// covers some of what was just deleted) would make the next
// background pass think nothing changed and skip re-downloading.
val removedPath = "/" + cleanPath
val markers = loadRootMarkers(context, accountId)
val staleKeys = markers.keys.filter { key ->
val k = key.trimEnd('/')
k.isEmpty() || removedPath == "/" || k == removedPath ||
k.startsWith("$removedPath/") || removedPath.startsWith("$k/")
}
if (staleKeys.isNotEmpty()) {
staleKeys.forEach { markers.remove(it) }
saveRootMarkers(context, accountId, markers)
}
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
if (target.exists()) target.deleteRecursively()
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
@@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.io.File
@@ -32,27 +33,94 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
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"
// True for a user-initiated "Sync now": always does a full walk of
// every root (never trusts the root-etag shortcut) and shows the
// "Checking for changes..." notification immediately. False for
// periodic/automatic runs, which stay silent unless something
// actually transfers.
const val KEY_FORCE = "force"
private const val CHANNEL_ID = "device_sync"
private const val SUMMARY_NOTIFICATION_ID = 4401
private const val CONFLICT_NOTIFICATION_ID_BASE = 4500
// Whether automatic (non-force) runs may post progress/summary
// notifications - the "Background sync notifications" setting.
// Conflicts are always notified (they need a decision), and a
// user-initiated run (force) always shows progress.
const val KEY_NOTIFY = "notify"
// Even when a root's etag hasn't changed, walk it in full at least
// this often: etag propagation isn't reliable everywhere (external
// storage mounts in particular), so this bounds how stale a mirror
// can get if the cheap check is ever wrong.
private const val FULL_WALK_MAX_AGE_MS = 6L * 60 * 60 * 1000
// Pre-multi-account name of the single shared periodic job - only
// kept so it can be cancelled when migrating to per-account jobs.
const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic"
// Each account with sync enabled gets its own periodic job (with
// that account's own credentials/paths/interval baked in), so
// background sync works for every enabled account, not just
// whichever one was active last.
fun periodicNameFor(accountId: String) = "${UNIQUE_PERIODIC_NAME}_$accountId"
fun oneOffNameFor(accountId: String) = "noo_sync_now_$accountId"
// Low importance = no sound/vibration. Importance can't be changed
// on an existing channel, hence the new id (the old "device_sync"
// channel, which made noise, is deleted when this one is created).
private const val CHANNEL_ID = "device_sync_v2"
private const val LEGACY_CHANNEL_ID = "device_sync"
// Notification ids are per account so two accounts syncing never
// overwrite each other's progress/summary/conflict notifications.
// Progress and the final summary share one id per account - the
// summary's notify() call naturally replaces the ongoing progress
// notification in place (no flicker of two separate notifications),
// and if there was nothing to actually sync, doWork explicitly
// cancels it since no summary gets posted to replace it.
private const val SUMMARY_NOTIFICATION_ID_BASE = 100_000
private const val CONFLICT_NOTIFICATION_ID_BASE = 200_000
fun summaryNotificationId(accountId: String) =
SUMMARY_NOTIFICATION_ID_BASE + (accountId.hashCode() and 0xFFFF)
fun conflictNotificationId(accountId: String, relPath: String) =
CONFLICT_NOTIFICATION_ID_BASE + ((accountId + "|" + relPath).hashCode() and 0xFFFF)
private const val TAG = "NooSync"
}
// totalTransfers is only known once at least one path's been diffed -
// grows across the run as each configured path is diffed in turn.
private var totalTransfers = 0
private var completedTransfers = 0
private var progressShown = false
// Set at the top of performSync - the notify* helpers below need the
// account (for its notification ids / title) and whether this run may
// show progress at all.
private var notifyAccountId = ""
private var notifyUsername = ""
private var showProgressNotifications = true
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
// One sync at a time, app-wide - see SyncEngine.syncLock. A run that
// has to wait just starts once the current one finishes.
SyncEngine.syncLock.withLock { performSync() }
}
private suspend fun performSync(): 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 force = inputData.getBoolean(KEY_FORCE, false)
notifyAccountId = accountId
notifyUsername = username
showProgressNotifications = force || inputData.getBoolean(KEY_NOTIFY, true)
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()
val markers = SyncEngine.loadRootMarkers(applicationContext, accountId)
var downloaded = 0
var uploaded = 0
@@ -60,81 +128,180 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
SyncStatusBus.setSyncing(accountId, true)
// For a user-initiated run, shown immediately rather than lazily on
// the first transfer - the PROPFIND walk/diff below (per configured
// path, potentially recursive) can itself take real time, and the
// user just asked for a sync so they expect to see it happening.
// Automatic runs stay silent until something actually transfers.
if (force) notifyProgress("Checking for changes…")
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
try {
// 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 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)
// Cheap "did anything change?" check: an unchanged root etag
// means nothing beneath it changed on the server, and if
// every local file is also still exactly as recorded there's
// nothing to upload or re-download either - so skip the walk
// (one Depth-0 PROPFIND instead of one per subfolder).
val marker = markers[path]
if (!force && self != null && self.isFolder && marker != null &&
marker.etag == self.etag &&
System.currentTimeMillis() - marker.fullWalkAt < FULL_WALK_MAX_AGE_MS &&
priorFileIdsForPath.isNotEmpty() &&
SyncEngine.localMatchesState(state, priorFileIdsForPath, syncRoot)
) {
Log.d(TAG, "skip $path: server etag unchanged, local intact")
continue
}
// The server says this synced path no longer exists (a
// clean 404, not a network error): flag it so the app drops
// it from the synced list instead of leaving a dead entry.
SyncEngine.setRootMissing(applicationContext, accountId, path, self == null)
val entries = when {
self == null -> emptyList()
self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path)
else -> listOf(self)
}
var pathClean = true
val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot)
totalTransfers += actions.count {
it is SyncEngine.SyncAction.Download || it is SyncEngine.SyncAction.Upload
}
for (action in actions) {
when (action) {
is SyncEngine.SyncAction.Download -> {
val relPath = action.entry.path.removePrefix("/")
notifyProgress(relPath.substringAfterLast('/'))
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true)
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++
} else {
pathClean = false
}
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false)
completedTransfers++
}
is SyncEngine.SyncAction.Upload -> {
notifyProgress(action.relPath.substringAfterLast('/'))
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++
} else {
pathClean = false
}
SyncStatusBus.markFileSyncing(accountId, action.fileId, false)
completedTransfers++
}
is SyncEngine.SyncAction.Delete -> {
// Only counts toward the "removed" summary if a
// local copy actually existed and was deleted -
// dropping stale state for a file that was never
// on disk isn't a removal the user should hear
// about.
val localFile = File(syncRoot, action.relPath)
if (localFile.exists() && localFile.delete()) deleted++
state.remove(action.fileId)
}
is SyncEngine.SyncAction.Conflict -> {
conflicts.add(action.entry to action.relPath)
pathClean = false
}
}
}
// Mirror the folder structure itself: empty folders
// created on the server appear on device, and folders
// deleted on the server (whose files the diff above has
// already removed) don't linger as empty directories.
if (self == null || self.isFolder) {
val rootRel = path.trim('/')
if (self != null) SyncEngine.mirrorFolders(entries, syncRoot, rootRel)
val remoteFolders = entries.filter { it.isFolder }
.map { it.path.trim('/') }
.toMutableSet()
if (self != null) remoteFolders.add(rootRel)
SyncEngine.pruneRemovedFolders(syncRoot, rootRel, remoteFolders)
}
// Only remember this root's etag once the whole path synced
// without a hitch - anything left over (a failed transfer, an
// unresolved conflict) has to be retried by a real walk next
// time, not skipped because the etag "hasn't changed".
if (self != null && self.isFolder && pathClean) {
markers[path] = SyncEngine.RootMarker(self.etag, System.currentTimeMillis())
} else {
markers.remove(path)
}
} catch (e: SyncEngine.RemoteUnavailableException) {
// Couldn't reach the server for this path - leave its state
// and marker untouched and try again next run. Crucially,
// never diff against an empty listing here: that would look
// like the whole folder was deleted server-side.
Log.w(TAG, "skipping $path this run: ${e.message}")
}
}
} finally {
markers.keys.retainAll(folders.toSet())
SyncEngine.saveRootMarkers(applicationContext, accountId, markers)
// State must be saved *before* announcing that syncing has
// stopped - `syncStatusMap` (MainActivity.kt) recomputes
// `syncedFileIds` fresh from `SyncEngine.loadState` every time
// this bus publishes, so publishing first would hand Dart a
// snapshot that's still missing every file this run just
// downloaded, and nothing would ever correct it afterward
// (no further bus event fires post-save) - newly-synced files
// would never show their "synced" badge until the next app
// restart forced a fresh `getStatus()` read.
SyncEngine.saveState(applicationContext, accountId, state)
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) {
if ((downloaded > 0 || uploaded > 0 || deleted > 0) && showProgressNotifications) {
// Replaces the ongoing progress notification in place (same
// ID) with a final, dismissible summary.
notifySummary(downloaded, uploaded, deleted)
} else if (progressShown) {
// Nothing actually transferred (e.g. every diffed action was a
// Delete/Conflict, or the progress notification was shown for a
// run that ended up empty) - nothing to replace it with, so
// just clear it rather than leaving a stale "Syncing…" behind.
manager().cancel(summaryNotificationId(accountId))
}
if (conflicts.isNotEmpty()) {
SyncStatusBus.addConflicts(
@@ -155,6 +322,39 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
Result.success()
}
/// Called once immediately at the start of every run that actually has
/// folders configured (covers the PROPFIND walk/diff phase, which can
/// itself take real time), then again per file as transfers happen.
/// `totalTransfers` grows as each configured path is diffed (paths are
/// diffed one at a time), so early on this shows an indeterminate bar;
/// it becomes determinate once the true total for this run is known. A
/// run that turns out to have nothing to transfer cancels this
/// notification at the end instead of leaving it stuck (see the
/// `progressShown` check in `doWork`).
private fun notifyProgress(fileName: String) {
if (!showProgressNotifications) return
progressShown = true
createChannel()
val text = if (totalTransfers > 1) {
"$fileName (${completedTransfers + 1}/$totalTransfers)"
} else {
fileName
}
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Syncing to device · $notifyUsername")
.setContentText(text)
.setOnlyAlertOnce(true)
.setSilent(true)
.setOngoing(true)
if (totalTransfers > 0) {
builder.setProgress(totalTransfers, completedTransfers, false)
} else {
builder.setProgress(0, 0, true)
}
manager().notify(summaryNotificationId(notifyAccountId), builder.build())
}
private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) {
createChannel()
val parts = mutableListOf<String>()
@@ -165,11 +365,12 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Noo sync")
.setContentTitle("Noo sync · $notifyUsername")
.setContentText(text)
.setAutoCancel(true)
.setSilent(true)
.build()
manager().notify(SUMMARY_NOTIFICATION_ID, notification)
manager().notify(summaryNotificationId(notifyAccountId), notification)
}
private fun notifyConflicts(
@@ -183,7 +384,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
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 notificationId = conflictNotificationId(accountId, relPath)
val useLocalIntent = conflictActionIntent(
accountId,
@@ -211,8 +412,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
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.")
.setContentText("$username - changed both on this device and on the server.")
.setAutoCancel(true)
.setSilent(true)
.addAction(0, "Keep local", useLocalIntent)
.addAction(0, "Use server", useServerIntent)
.build()
@@ -245,7 +447,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
}
// Request code must be unique per (file, resolution) pair, else the
// two actions' PendingIntents collide and only one survives.
val requestCode = (relPath + resolution).hashCode()
val requestCode = (accountId + relPath + resolution).hashCode()
return PendingIntent.getBroadcast(
applicationContext,
requestCode,
@@ -262,8 +464,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
applicationContext,
CHANNEL_ID,
"Device sync",
NotificationManager.IMPORTANCE_DEFAULT,
NotificationManager.IMPORTANCE_LOW,
"Updates and conflicts for folders synced to this device",
legacyIds = listOf(LEGACY_CHANNEL_ID),
)
}
}