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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user