v1.0.25: multi-select files, unified notification, dark theme, icon redesign

- FilesScreen: long-press enters selection mode, bulk share/delete toolbar, BackHandler
- FilesViewModel: ShareMultiple action, isSelectionMode/selectedCount state, download-to-cache for open/share
- FileWatchService: recursive FileObserver per subdirectory; unified notification updated with sync result via WorkManager flow observation
- SyncWorker: silent flag suppresses notifications when triggered by watcher; emits KEY_RESULT_SUMMARY output data
- Passbolt-inspired dark theme (Red700/Red500 primary, near-black surface)
- App icon: circular AutoSync-style sync arrows (cyan gradient, deep navy background)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:22:43 +00:00
parent 146b8baf9a
commit 8fdd22bc98
9 changed files with 392 additions and 215 deletions
@@ -13,7 +13,9 @@ import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
@@ -214,9 +216,34 @@ class FileWatchService : Service() {
val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
// Update notification while sync is in progress
updateNotificationDynamic("Syncing: ${pair.name}")
// Wait for completion and show result in the persistent notification
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY)
val watchCount = fileObservers.keys.size + contentObservers.size
val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}"
if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) {
updateNotificationDynamic("${pair.name}: $summary$watching")
} else {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null) // revert to default watching text
} catch (_: Exception) {
updateNotificationDynamic(null)
}
}
}
}
@@ -241,7 +268,7 @@ class FileWatchService : Service() {
}
}
private fun buildNotification(count: Int): Notification {
private fun buildNotification(count: Int, overrideText: String? = null): Notification {
val tapIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
@@ -250,7 +277,7 @@ class FileWatchService : Service() {
return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow")
.setContentText(
if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
else "Starting file watcher…"
)
.setSmallIcon(R.drawable.ic_sync)
@@ -264,4 +291,10 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count))
}
private fun updateNotificationDynamic(overrideText: String?) {
val count = fileObservers.keys.size + contentObservers.size
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText))
}
}
@@ -39,6 +39,8 @@ class SyncWorker @AssistedInject constructor(
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
val silent = inputData.getBoolean(KEY_SILENT, false)
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
@@ -47,7 +49,15 @@ class SyncWorker @AssistedInject constructor(
val provider = providerFactory.create(account)
val result = syncEngine.sync(domainPair, provider)
if (result.error != null && pair.notifyOnError) {
val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
if (result.downloaded > 0) add("${result.downloaded}")
if (result.deleted > 0) add("🗑${result.deleted}")
if (result.conflicts > 0) add("${result.conflicts}")
}
val resultSummary = if (lines.isEmpty()) "Up to date" else lines.joinToString(" ")
if (!silent && result.error != null && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -55,28 +65,28 @@ class SyncWorker @AssistedInject constructor(
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val lines = buildList {
} else if (!silent && pair.notifyOnComplete && result.error == null) {
val fullLines = buildList {
if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n")
val summary = if (fullLines.isEmpty()) "Up to date — nothing to sync" else fullLines.joinToString("\n")
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name}Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
title = "${pair.name}Synced",
text = if (fullLines.isEmpty()) summary else fullLines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success()
Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary))
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
if (!silent && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -146,19 +156,21 @@ class SyncWorker @AssistedInject constructor(
companion object {
const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
private const val CHANNEL_COMPLETE = "sync_complete"
private const val CHANNEL_ALERTS = "sync_alerts"
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean, silent: Boolean = false): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")