From f751b26a9ed0a123204ac38339f3da0e05e221bb Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sun, 24 May 2026 02:55:19 +0000 Subject: [PATCH] v1.0.15: ON_CHANGE file watching, browser fix, rich notifications - Add FileWatchService for real-time ON_CHANGE sync (FileObserver for direct paths, ContentObserver for SAF content:// URIs), 5s debounce - Fix remote browser stuck spinner: cancel in-flight jobs on navigation, reset entries immediately, add Retry button on error - Fix browser reuse bug: LaunchedEffect key now includes initialPath - Fix WebDavProvider: rethrow XML parse errors (no more silent Empty folder) and URL-decode file names from href - Notifications now use BigTextStyle showing per-file-type counts (Uploaded/Downloaded/Deleted) matching Autosync notification style - Wire FileWatchService into BootReceiver and HomeViewModel toggle - Register FileWatchService in AndroidManifest Co-Authored-By: Claude Sonnet 4.6 --- ...otlin-compiler-1894979669169952593.salive} | 0 app/src/main/AndroidManifest.xml | 6 + .../data/providers/webdav/WebDavProvider.kt | 63 +++--- .../ui/browser/RemoteBrowserDialog.kt | 3 +- .../ui/browser/RemoteBrowserViewModel.kt | 61 +++--- .../com/syncflow/ui/home/HomeViewModel.kt | 38 ++-- .../com/syncflow/worker/BootReceiver.kt | 28 ++- .../com/syncflow/worker/FileWatchService.kt | 182 ++++++++++++++++++ .../kotlin/com/syncflow/worker/SyncWorker.kt | 27 +-- version.properties | 4 +- 10 files changed, 318 insertions(+), 94 deletions(-) rename .kotlin/sessions/{kotlin-compiler-15389394296919477029.salive => kotlin-compiler-1894979669169952593.salive} (100%) create mode 100644 app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt diff --git a/.kotlin/sessions/kotlin-compiler-15389394296919477029.salive b/.kotlin/sessions/kotlin-compiler-1894979669169952593.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-15389394296919477029.salive rename to .kotlin/sessions/kotlin-compiler-1894979669169952593.salive diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c6eaa2d..2adbda6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,12 @@ + + + { val results = mutableListOf() - try { - val factory = XmlPullParserFactory.newInstance() - factory.isNamespaceAware = true - val parser = factory.newPullParser() - parser.setInput(xml.reader()) + val factory = XmlPullParserFactory.newInstance() + factory.isNamespaceAware = true + val parser = factory.newPullParser() + parser.setInput(xml.reader()) - var href = ""; var isCollection = false; var contentLength = 0L - var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null - var inResponse = false; var inProp = false + var href = ""; var isCollection = false; var contentLength = 0L + var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null + var inResponse = false; var inProp = false - var eventType = parser.eventType - while (eventType != XmlPullParser.END_DOCUMENT) { - val tag = parser.name?.substringAfterLast(':')?.lowercase() - when (eventType) { - XmlPullParser.START_TAG -> when (tag) { - "response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null } - "prop" -> inProp = true - "href" -> if (!inProp) href = parser.nextText().trim() - "collection" -> if (inProp) isCollection = true - "getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L - "getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim()) - "getetag" -> if (inProp) etag = parser.nextText().trim().trim('"') - "getcontenttype" -> if (inProp) contentType = parser.nextText().trim() - } - XmlPullParser.END_TAG -> when (tag) { - "prop" -> inProp = false - "response" -> if (inResponse && href.isNotBlank()) { - val name = href.trimEnd('/').substringAfterLast('/') - val relPath = "$parentPath/$name".replace("//", "/") - results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) - inResponse = false - } + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + val tag = parser.name?.substringAfterLast(':')?.lowercase() + when (eventType) { + XmlPullParser.START_TAG -> when (tag) { + "response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null } + "prop" -> inProp = true + "href" -> if (!inProp) href = parser.nextText().trim() + "collection" -> if (inProp) isCollection = true + "getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L + "getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim()) + "getetag" -> if (inProp) etag = parser.nextText().trim().trim('"') + "getcontenttype" -> if (inProp) contentType = parser.nextText().trim() + } + XmlPullParser.END_TAG -> when (tag) { + "prop" -> inProp = false + "response" -> if (inResponse && href.isNotBlank()) { + val rawName = href.trimEnd('/').substringAfterLast('/') + val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName } + val relPath = "$parentPath/$name".replace("//", "/") + results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) + inResponse = false } } - eventType = parser.next() } - } catch (_: Exception) {} + eventType = parser.next() + } return if (dropFirst) results.drop(1) else results } diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt index d62deb6..ff7ff74 100644 --- a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt @@ -27,7 +27,7 @@ fun RemoteBrowserDialog( onDismiss: () -> Unit, vm: RemoteBrowserViewModel = hiltViewModel(), ) { - LaunchedEffect(accountId) { vm.init(accountId, initialPath) } + LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) } val state by vm.state.collectAsState() @@ -81,6 +81,7 @@ fun RemoteBrowserDialog( Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error) Text(state.error!!, color = MaterialTheme.colorScheme.error) + FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") } } } state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt index 1104e0d..3e7f6d5 100644 --- a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt @@ -6,6 +6,7 @@ import com.syncflow.data.providers.ProviderFactory import com.syncflow.data.repository.AccountRepository import com.syncflow.domain.model.RemoteFile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -30,15 +31,19 @@ class RemoteBrowserViewModel @Inject constructor( private val _state = MutableStateFlow(BrowserState()) val state = _state.asStateFlow() + private var loadJob: Job? = null + fun init(accountId: Long, startPath: String = "/") { - _state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) } - loadPath(accountId, startPath) + loadJob?.cancel() + _state.value = BrowserState(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath), isLoading = true) + loadJob = loadPath(accountId, startPath) } fun navigateTo(path: String) { val accountId = _state.value.accountId - _state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) } - loadPath(accountId, path) + loadJob?.cancel() + _state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path, isLoading = true, entries = emptyList(), error = null) } + loadJob = loadPath(accountId, path) } fun navigateUp(): Boolean { @@ -46,30 +51,36 @@ class RemoteBrowserViewModel @Inject constructor( if (stack.size <= 1) return false val newStack = stack.dropLast(1) val parent = newStack.last() - _state.update { it.copy(currentPath = parent, pathStack = newStack) } - loadPath(_state.value.accountId, parent) + loadJob?.cancel() + _state.update { it.copy(currentPath = parent, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) } + loadJob = loadPath(_state.value.accountId, parent) return true } - private fun loadPath(accountId: Long, path: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - val account = accountRepository.getAccount(accountId) - if (account == null) { - _state.update { it.copy(isLoading = false, error = "Account not found") } - return@launch - } - val provider = runCatching { providerFactory.create(account) }.getOrElse { e -> - _state.update { it.copy(isLoading = false, error = e.message) } - return@launch - } - provider.listFiles(path) - .onSuccess { files -> - _state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) } - } - .onFailure { e -> - _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") } - } + fun retry() { + val s = _state.value + if (s.accountId == -1L) return + loadJob?.cancel() + _state.update { it.copy(isLoading = true, error = null) } + loadJob = loadPath(s.accountId, s.currentPath) + } + + private fun loadPath(accountId: Long, path: String): Job = viewModelScope.launch { + val account = accountRepository.getAccount(accountId) + if (account == null) { + _state.update { it.copy(isLoading = false, error = "Account not found") } + return@launch } + val provider = runCatching { providerFactory.create(account) }.getOrElse { e -> + _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to create provider") } + return@launch + } + provider.listFiles(path) + .onSuccess { files -> + _state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) } + } + .onFailure { e -> + _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") } + } } } diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt index 8d37e4a..cf2776c 100644 --- a/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt @@ -1,13 +1,17 @@ package com.syncflow.ui.home +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.WorkManager import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.domain.model.ScheduleType +import com.syncflow.worker.FileWatchService import com.syncflow.worker.SyncWorker import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -17,6 +21,7 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( private val syncPairDao: SyncPairDao, private val workManager: WorkManager, + @ApplicationContext private val context: Context, ) : ViewModel() { val syncPairs = syncPairDao.observeAll() @@ -29,21 +34,28 @@ class HomeViewModel @Inject constructor( fun toggleEnabled(pair: SyncPairEntity) { viewModelScope.launch { - syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled)) - if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) { - val req = SyncWorker.buildPeriodicRequest( - pair.id, - pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), - pair.wifiOnly, - pair.chargingOnly, - ) - workManager.enqueueUniquePeriodicWork( - "periodic_${pair.id}", - androidx.work.ExistingPeriodicWorkPolicy.UPDATE, - req, - ) + val nowEnabled = !pair.isEnabled + syncPairDao.update(pair.copy(isEnabled = nowEnabled)) + if (nowEnabled) { + when (pair.scheduleType) { + ScheduleType.ON_CHANGE -> FileWatchService.start(context) + ScheduleType.MANUAL -> { /* nothing */ } + else -> { + val req = SyncWorker.buildPeriodicRequest( + pair.id, + pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), + pair.wifiOnly, + pair.chargingOnly, + ) + workManager.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req) + } + } } else { workManager.cancelAllWorkByTag("sync_${pair.id}") + // Refresh watcher (it will stop itself if no ON_CHANGE pairs remain) + if (pair.scheduleType == ScheduleType.ON_CHANGE) { + FileWatchService.start(context) + } } } } diff --git a/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt index 8575882..0d715eb 100644 --- a/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt +++ b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt @@ -3,6 +3,7 @@ package com.syncflow.worker import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.WorkManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -22,17 +23,24 @@ class BootReceiver : BroadcastReceiver() { val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - syncPairDao.getEnabled() - .filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE } - .forEach { pair -> - val req = SyncWorker.buildPeriodicRequest( - pair.id, - pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), - pair.wifiOnly, - pair.chargingOnly, - ) - wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req) + val pairs = syncPairDao.getEnabled() + var hasOnChange = false + pairs.forEach { pair -> + when (pair.scheduleType) { + ScheduleType.ON_CHANGE -> hasOnChange = true + ScheduleType.MANUAL -> { /* nothing */ } + else -> { + val req = SyncWorker.buildPeriodicRequest( + pair.id, + pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), + pair.wifiOnly, + pair.chargingOnly, + ) + wm.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req) + } } + } + if (hasOnChange) FileWatchService.start(context) } finally { pending.finish() } diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt new file mode 100644 index 0000000..78e0b2a --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt @@ -0,0 +1,182 @@ +package com.syncflow.worker + +import android.app.* +import android.content.Context +import android.content.Intent +import android.database.ContentObserver +import android.net.Uri +import android.os.Build +import android.os.FileObserver +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import androidx.core.app.NotificationCompat +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import com.syncflow.MainActivity +import com.syncflow.R +import com.syncflow.data.db.SyncPairDao +import com.syncflow.domain.model.ScheduleType +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.* +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +@AndroidEntryPoint +class FileWatchService : Service() { + + @Inject lateinit var syncPairDao: SyncPairDao + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val mainHandler = Handler(Looper.getMainLooper()) + + private val fileObservers = mutableMapOf() + private val contentObservers = mutableMapOf() + private val debounceJobs = mutableMapOf() + + companion object { + const val CHANNEL_WATCH = "sync_watching" + private const val NOTIFICATION_ID = 1002 + + fun start(context: Context) { + val intent = Intent(context, FileWatchService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, FileWatchService::class.java)) + } + } + + override fun onCreate() { + super.onCreate() + ensureChannel() + startForeground(NOTIFICATION_ID, buildNotification(0)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + scope.launch { refresh() } + return START_STICKY + } + + override fun onDestroy() { + clearWatchers() + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private suspend fun refresh() { + clearWatchers() + val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE } + + pairs.forEach { pair -> + val pairId = pair.id + val localPath = pair.localPath + + if (localPath.startsWith("content://")) { + val treeUri = Uri.parse(localPath) + val observer = object : ContentObserver(mainHandler) { + override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) + override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) + } + contentResolver.registerContentObserver(treeUri, true, observer) + contentObservers[pairId] = observer + Timber.d("FileWatchService: watching SAF URI for pair $pairId") + } else { + val dir = File(localPath) + if (!dir.exists()) { + Timber.w("FileWatchService: path does not exist for pair $pairId: $localPath") + return@forEach + } + val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or + FileObserver.MOVED_FROM or FileObserver.MOVED_TO + val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + object : FileObserver(dir, mask) { + override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) + } + } else { + @Suppress("DEPRECATION") + object : FileObserver(localPath, mask) { + override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly) + } + } + observer.startWatching() + fileObservers[pairId] = observer + Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath") + } + } + + val count = fileObservers.size + contentObservers.size + updateNotification(count) + + if (count == 0) { + Timber.d("FileWatchService: no ON_CHANGE pairs, stopping") + stopSelf() + } + } + + private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { + debounceJobs[pairId]?.cancel() + debounceJobs[pairId] = scope.launch { + delay(5_000) + 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) + WorkManager.getInstance(applicationContext) + .enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req) + } + } + + private fun clearWatchers() { + fileObservers.values.forEach { it.stopWatching() } + fileObservers.clear() + contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) } + contentObservers.clear() + debounceJobs.values.forEach { it.cancel() } + debounceJobs.clear() + } + + private fun ensureChannel() { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL_WATCH) == null) { + nm.createNotificationChannel( + NotificationChannel(CHANNEL_WATCH, "File watching", NotificationManager.IMPORTANCE_MIN).apply { + description = "Background service watching folders for changes" + setShowBadge(false) + } + ) + } + } + + private fun buildNotification(count: Int): Notification { + val tapIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_WATCH) + .setContentTitle("SyncFlow") + .setContentText( + if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes" + else "Starting file watcher…" + ) + .setSmallIcon(R.drawable.ic_sync) + .setContentIntent(tapIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build() + } + + private fun updateNotification(count: Int) { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(count)) + } +} diff --git a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt index 5362772..da7a5b7 100644 --- a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt +++ b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt @@ -56,17 +56,19 @@ class SyncWorker @AssistedInject constructor( priority = NotificationCompat.PRIORITY_DEFAULT, ) } else if (pair.notifyOnComplete && result.error == null) { - val summary = buildString { - if (result.uploaded > 0) append("↑${result.uploaded} ") - if (result.downloaded > 0) append("↓${result.downloaded} ") - if (result.deleted > 0) append("πŸ—‘${result.deleted} ") - if (result.conflicts > 0) append("⚠${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}") - }.trim().ifEmpty { "Up to date" } + val lines = 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") notify( id = pairId.toInt() + RESULT_ID_OFFSET, channelId = CHANNEL_COMPLETE, - title = "${pair.name} β€” Synced", - text = summary, + title = "${pair.name} β€” Changes synced", + text = if (lines.isEmpty()) summary else lines.first(), + bigText = summary, priority = NotificationCompat.PRIORITY_LOW, ) } @@ -122,21 +124,24 @@ class SyncWorker @AssistedInject constructor( ForegroundInfo(NOTIFICATION_ID, notification) } - private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int) { + private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int, bigText: String? = null) { val tapIntent = PendingIntent.getActivity( applicationContext, id, Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - nm.notify(id, NotificationCompat.Builder(applicationContext, channelId) + val builder = NotificationCompat.Builder(applicationContext, channelId) .setContentTitle(title) .setContentText(text) .setSmallIcon(R.drawable.ic_sync) .setPriority(priority) .setContentIntent(tapIntent) .setAutoCancel(true) - .build()) + if (bigText != null) { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText)) + } + nm.notify(id, builder.build()) } companion object { diff --git a/version.properties b/version.properties index a2c04c5..e7fab87 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.14 -VERSION_CODE=15 +VERSION_NAME=1.0.15 +VERSION_CODE=16