Offline tab as Files view, automatic background sync, sync notifications
Build APK / build (push) Successful in 5m27s
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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user