Compare commits

...

4 Commits

Author SHA1 Message Date
amir b15637132c v1.0.16: spinning sync icon, colorful icon, ON_CHANGE fix, notification fix
- Sync icon now rotates (CSS-style spin) in StatusPill, StatusBanner,
  and card sync button whenever status is SYNCING
- Launcher icon redesigned: indigo→violet→cyan gradient background,
  upload arrow fades white→sky-blue, download arrow fades white→violet,
  soft glow ring behind arrows
- Fix ON_CHANGE not triggering: FileWatchService.start() now called
  from AddPairViewModel.save() so pairs created with ON_CHANGE
  immediately begin watching without needing a toggle or reboot
- Fix FileWatch notification hidden: IMPORTANCE_MIN → IMPORTANCE_LOW
  so the "Watching N folders" notification shows in the shade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:42:30 +00:00
amir bcfecbb867 releases/latest: add v1.0.15 APK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:55:48 +00:00
amir f751b26a9e 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 <noreply@anthropic.com>
2026-05-24 02:55:19 +00:00
amir e22db9bced feat: rich sync notifications (progress + result + error)
Three notification channels:
- sync_progress (LOW): foreground notification while syncing, shows pair name
- sync_complete (LOW): result after success — "↑X ↓X" or "Up to date"
- sync_alerts (DEFAULT): error notification with message on failure

Notifications respect per-pair notifyOnComplete / notifyOnError settings.
All notifications tap-through to MainActivity. Foreground info now names the
pair being synced instead of the generic "Syncing…" text.

Bump to 1.0.14 (code 15).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:35:45 +00:00
16 changed files with 481 additions and 112 deletions
+6
View File
@@ -68,6 +68,12 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- File watcher for ON_CHANGE sync pairs -->
<service
android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service --> <!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -158,7 +158,6 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> { private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>() val results = mutableListOf<RemoteFile>()
try {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true factory.isNamespaceAware = true
val parser = factory.newPullParser() val parser = factory.newPullParser()
@@ -185,7 +184,8 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
XmlPullParser.END_TAG -> when (tag) { XmlPullParser.END_TAG -> when (tag) {
"prop" -> inProp = false "prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) { "response" -> if (inResponse && href.isNotBlank()) {
val name = href.trimEnd('/').substringAfterLast('/') val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/") val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType)) results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false inResponse = false
@@ -194,7 +194,6 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
} }
eventType = parser.next() eventType = parser.next()
} }
} catch (_: Exception) {}
return if (dropFirst) results.drop(1) else results return if (dropFirst) results.drop(1) else results
} }
@@ -1,5 +1,6 @@
package com.syncflow.ui.addpair package com.syncflow.ui.addpair
import android.content.Context
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -7,9 +8,10 @@ import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.* import com.syncflow.domain.model.*
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -31,7 +33,7 @@ data class AddPairUiState(
val scheduleType: ScheduleType = ScheduleType.INTERVAL, val scheduleType: ScheduleType = ScheduleType.INTERVAL,
val intervalMinutes: Int = 30, val intervalMinutes: Int = 30,
val dailyTime: String = "02:00", val dailyTime: String = "02:00",
val weekdays: Int = 0b1111111, // all 7 days by default val weekdays: Int = 0b1111111,
// ── Constraints ────────────────────────────────────────────────────────── // ── Constraints ──────────────────────────────────────────────────────────
val wifiOnly: Boolean = true, val wifiOnly: Boolean = true,
val wifiSsid: String = "", val wifiSsid: String = "",
@@ -57,6 +59,7 @@ data class AddPairUiState(
class AddPairViewModel @Inject constructor( class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val accountDao: CloudAccountDao, private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle, savedState: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -147,7 +150,10 @@ class AddPairViewModel @Inject constructor(
) )
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity) if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity)
} }
.onSuccess { _state.update { it.copy(done = true) } } .onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } } .onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
} }
} }
@@ -27,7 +27,7 @@ fun RemoteBrowserDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
vm: RemoteBrowserViewModel = hiltViewModel(), vm: RemoteBrowserViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(accountId) { vm.init(accountId, initialPath) } LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
@@ -81,6 +81,7 @@ fun RemoteBrowserDialog(
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error) Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = 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) { state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -6,6 +6,7 @@ import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.RemoteFile
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -30,15 +31,19 @@ class RemoteBrowserViewModel @Inject constructor(
private val _state = MutableStateFlow(BrowserState()) private val _state = MutableStateFlow(BrowserState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var loadJob: Job? = null
fun init(accountId: Long, startPath: String = "/") { fun init(accountId: Long, startPath: String = "/") {
_state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) } loadJob?.cancel()
loadPath(accountId, startPath) _state.value = BrowserState(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath), isLoading = true)
loadJob = loadPath(accountId, startPath)
} }
fun navigateTo(path: String) { fun navigateTo(path: String) {
val accountId = _state.value.accountId val accountId = _state.value.accountId
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) } loadJob?.cancel()
loadPath(accountId, path) _state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(accountId, path)
} }
fun navigateUp(): Boolean { fun navigateUp(): Boolean {
@@ -46,21 +51,28 @@ class RemoteBrowserViewModel @Inject constructor(
if (stack.size <= 1) return false if (stack.size <= 1) return false
val newStack = stack.dropLast(1) val newStack = stack.dropLast(1)
val parent = newStack.last() val parent = newStack.last()
_state.update { it.copy(currentPath = parent, pathStack = newStack) } loadJob?.cancel()
loadPath(_state.value.accountId, parent) _state.update { it.copy(currentPath = parent, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, parent)
return true return true
} }
private fun loadPath(accountId: Long, path: String) { fun retry() {
viewModelScope.launch { val s = _state.value
if (s.accountId == -1L) return
loadJob?.cancel()
_state.update { it.copy(isLoading = true, error = null) } _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) val account = accountRepository.getAccount(accountId)
if (account == null) { if (account == null) {
_state.update { it.copy(isLoading = false, error = "Account not found") } _state.update { it.copy(isLoading = false, error = "Account not found") }
return@launch return@launch
} }
val provider = runCatching { providerFactory.create(account) }.getOrElse { e -> val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _state.update { it.copy(isLoading = false, error = e.message ?: "Failed to create provider") }
return@launch return@launch
} }
provider.listFiles(path) provider.listFiles(path)
@@ -72,4 +84,3 @@ class RemoteBrowserViewModel @Inject constructor(
} }
} }
} }
}
@@ -1,5 +1,10 @@
package com.syncflow.ui.home package com.syncflow.ui.home
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -14,6 +19,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
@@ -159,8 +165,18 @@ private fun SyncPairCard(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
val syncRotation by rememberInfiniteTransition(label = "cardSyncSpin").animateFloat(
initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation",
)
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) { FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp)) Icon(
Icons.Default.Sync, "Sync now",
modifier = Modifier.size(18.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
},
)
} }
} }
} }
@@ -179,10 +195,11 @@ private fun StatusPill(status: SyncStatus) {
SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle") SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle")
} }
val containerColor = status.accentColor val containerColor = status.accentColor
val contentColor = when (status) { val rotation by rememberInfiniteTransition(label = "syncSpin").animateFloat(
SyncStatus.IDLE -> MaterialTheme.colorScheme.onSurfaceVariant initialValue = 0f, targetValue = 360f,
else -> MaterialTheme.colorScheme.surface animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
} label = "rotation",
)
Surface( Surface(
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
color = containerColor.copy(alpha = 0.15f), color = containerColor.copy(alpha = 0.15f),
@@ -192,7 +209,11 @@ private fun StatusPill(status: SyncStatus) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Icon(icon, null, Modifier.size(12.dp), tint = containerColor) Icon(
icon, null,
Modifier.size(12.dp).graphicsLayer { if (status == SyncStatus.SYNCING) rotationZ = rotation },
tint = containerColor,
)
Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor) Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor)
} }
} }
@@ -1,13 +1,17 @@
package com.syncflow.ui.home package com.syncflow.ui.home
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -17,6 +21,7 @@ import javax.inject.Inject
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val syncPairDao: SyncPairDao, private val syncPairDao: SyncPairDao,
private val workManager: WorkManager, private val workManager: WorkManager,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val syncPairs = syncPairDao.observeAll() val syncPairs = syncPairDao.observeAll()
@@ -29,21 +34,28 @@ class HomeViewModel @Inject constructor(
fun toggleEnabled(pair: SyncPairEntity) { fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch { viewModelScope.launch {
syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled)) val nowEnabled = !pair.isEnabled
if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) { 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( val req = SyncWorker.buildPeriodicRequest(
pair.id, pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly, pair.wifiOnly,
pair.chargingOnly, pair.chargingOnly,
) )
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
"periodic_${pair.id}", }
androidx.work.ExistingPeriodicWorkPolicy.UPDATE, }
req,
)
} else { } else {
workManager.cancelAllWorkByTag("sync_${pair.id}") 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)
}
} }
} }
} }
@@ -1,5 +1,10 @@
package com.syncflow.ui.pairdetail package com.syncflow.ui.pairdetail
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -13,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity import com.syncflow.data.db.entities.SyncEventEntity
@@ -143,6 +149,11 @@ private fun StatusBanner(pair: SyncPairEntity) {
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer) SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant) SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
} }
val rotation by rememberInfiniteTransition(label = "bannerSpin").animateFloat(
initialValue = 0f, targetValue = 360f,
animationSpec = infiniteRepeatable(tween(1000, easing = LinearEasing)),
label = "bannerRotation",
)
Surface( Surface(
color = containerColor, color = containerColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@@ -152,7 +163,12 @@ private fun StatusBanner(pair: SyncPairEntity) {
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(icon, null, modifier = Modifier.size(40.dp)) Icon(
icon, null,
modifier = Modifier.size(40.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = rotation
},
)
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Column { Column {
Text(label, style = MaterialTheme.typography.titleMedium) Text(label, style = MaterialTheme.typography.titleMedium)
@@ -3,6 +3,7 @@ package com.syncflow.worker
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -22,17 +23,24 @@ class BootReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
syncPairDao.getEnabled() val pairs = syncPairDao.getEnabled()
.filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE } var hasOnChange = false
.forEach { pair -> pairs.forEach { pair ->
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> hasOnChange = true
ScheduleType.MANUAL -> { /* nothing */ }
else -> {
val req = SyncWorker.buildPeriodicRequest( val req = SyncWorker.buildPeriodicRequest(
pair.id, pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15), pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly, pair.wifiOnly,
pair.chargingOnly, pair.chargingOnly,
) )
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req) wm.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
} }
}
}
if (hasOnChange) FileWatchService.start(context)
} finally { } finally {
pending.finish() pending.finish()
} }
@@ -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<Long, FileObserver>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
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_LOW).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))
}
}
@@ -2,12 +2,15 @@ package com.syncflow.worker
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.* import androidx.work.*
import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain import com.syncflow.data.db.entities.toDomain
@@ -33,44 +36,121 @@ class SyncWorker @AssistedInject constructor(
val pairId = inputData.getLong(KEY_PAIR_ID, -1L) val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure() if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure() val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure() val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
return try { return try {
val domainPair = pair.toDomain() val domainPair = pair.toDomain()
val provider = providerFactory.create(account) val provider = providerFactory.create(account)
syncEngine.sync(domainPair, provider) val result = syncEngine.sync(domainPair, provider)
if (result.error != null && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
title = "${pair.name} — Sync failed",
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
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} — Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId") Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
title = "${pair.name} — Sync failed",
text = e.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
}
if (runAttemptCount < 3) Result.retry() else Result.failure() if (runAttemptCount < 3) Result.retry() else Result.failure()
} }
} }
private fun buildForegroundInfo(progress: String): ForegroundInfo { private fun ensureChannels() {
val channelId = "sync_channel"
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) { if (nm.getNotificationChannel(CHANNEL_PROGRESS) == null)
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW)) nm.createNotificationChannel(NotificationChannel(CHANNEL_PROGRESS, "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while a sync is running"
})
if (nm.getNotificationChannel(CHANNEL_COMPLETE) == null)
nm.createNotificationChannel(NotificationChannel(CHANNEL_COMPLETE, "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
description = "Summary after each successful sync"
})
if (nm.getNotificationChannel(CHANNEL_ALERTS) == null)
nm.createNotificationChannel(NotificationChannel(CHANNEL_ALERTS, "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Alerts for sync failures and conflicts"
})
} }
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("SyncFlow") private fun buildForegroundInfo(pairName: String, status: String): ForegroundInfo {
.setContentText(progress) val tapIntent = PendingIntent.getActivity(
applicationContext, 0,
Intent(applicationContext, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_PROGRESS)
.setContentTitle(pairName)
.setContentText(status)
.setSmallIcon(R.drawable.ic_sync) .setSmallIcon(R.drawable.ic_sync)
.setContentIntent(tapIntent)
.setOngoing(true) .setOngoing(true)
.build() .build()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else { else
ForegroundInfo(NOTIFICATION_ID, notification) ForegroundInfo(NOTIFICATION_ID, notification)
} }
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
val builder = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_sync)
.setPriority(priority)
.setContentIntent(tapIntent)
.setAutoCancel(true)
if (bigText != null) {
builder.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
}
nm.notify(id, builder.build())
} }
companion object { companion object {
const val KEY_PAIR_ID = "pair_id" const val KEY_PAIR_ID = "pair_id"
private const val NOTIFICATION_ID = 1001 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): OneTimeWorkRequest {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()
@@ -3,6 +3,7 @@
<gradient <gradient
android:type="linear" android:type="linear"
android:angle="135" android:angle="135"
android:startColor="#312E81" android:startColor="#4338CA"
android:endColor="#6366F1"/> android:centerColor="#7C3AED"
android:endColor="#0891B2"/>
</shape> </shape>
@@ -1,13 +1,39 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<!-- Soft white glow ring behind arrows -->
<path <path
android:fillColor="#FFFFFF" android:pathData="M12,12m-7.5,0a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0"
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6 0,1.01-0.25,1.97-0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0-4.42-3.58-8-8-8z"/> android:fillColor="#22FFFFFF"/>
<!-- Upload arrow — white → sky blue -->
<path <path
android:fillColor="#FFFFFF" android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6 0,1.01-0.25,1.97-0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0-4.42-3.58-8-8-8z">
android:pathData="M12,18c-3.31,0-6,-2.69-6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4-4,-4v3z"/> <aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="8" android:startY="1"
android:endX="20" android:endY="15"
android:startColor="#FFFFFF"
android:endColor="#7DD3FC"/>
</aapt:attr>
</path>
<!-- Download arrow — white → violet -->
<path
android:pathData="M12,18c-3.31,0-6,-2.69-6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4-4,-4v3z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="4" android:startY="8"
android:endX="16" android:endY="23"
android:startColor="#FFFFFF"
android:endColor="#C4B5FD"/>
</aapt:attr>
</path>
</vector> </vector>
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.13 VERSION_NAME=1.0.16
VERSION_CODE=14 VERSION_CODE=17