diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2adbda6..e503d70 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
@@ -71,13 +72,13 @@
diff --git a/app/src/main/kotlin/com/syncflow/MainActivity.kt b/app/src/main/kotlin/com/syncflow/MainActivity.kt
index 974f69b..dc3700d 100644
--- a/app/src/main/kotlin/com/syncflow/MainActivity.kt
+++ b/app/src/main/kotlin/com/syncflow/MainActivity.kt
@@ -1,9 +1,12 @@
package com.syncflow
+import android.Manifest
+import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
@@ -49,10 +52,14 @@ class MainActivity : AppCompatActivity() {
private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false)
+ private val requestNotificationPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* proceed regardless */ }
+
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ requestNotificationPermissionIfNeeded()
setContent {
SyncFlowTheme {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -127,6 +134,16 @@ class MainActivity : AppCompatActivity() {
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
}
+ private fun requestNotificationPermissionIfNeeded() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+
private fun canAuthenticate(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
diff --git a/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt b/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt
index ca6aca2..479142c 100644
--- a/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt
+++ b/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt
@@ -1,6 +1,9 @@
package com.syncflow
import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao
@@ -22,6 +25,7 @@ class SyncFlowApp : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
+ createNotificationChannels()
// Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -29,6 +33,27 @@ class SyncFlowApp : Application(), Configuration.Provider {
}
}
+ private fun createNotificationChannels() {
+ val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ listOf(
+ NotificationChannel("sync_progress", "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Shown while a sync is running"
+ },
+ NotificationChannel("sync_complete", "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Summary after each successful sync"
+ },
+ NotificationChannel("sync_alerts", "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
+ description = "Alerts for sync failures and conflicts"
+ },
+ NotificationChannel("sync_watching", "File watching", NotificationManager.IMPORTANCE_MIN).apply {
+ description = "Background service watching folders for changes"
+ setShowBadge(false)
+ },
+ ).forEach { channel ->
+ if (nm.getNotificationChannel(channel.id) == null) nm.createNotificationChannel(channel)
+ }
+ }
+
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt
index 7b97ac2..4f4e1c5 100644
--- a/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt
+++ b/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt
@@ -9,6 +9,9 @@ interface SyncEventDao {
@Query("SELECT * FROM sync_events WHERE syncPairId = :pairId ORDER BY timestamp DESC LIMIT :limit")
fun observeRecent(pairId: Long, limit: Int = 200): Flow>
+ @Query("SELECT * FROM sync_events ORDER BY timestamp DESC LIMIT :limit")
+ fun observeAll(limit: Int = 500): Flow>
+
@Insert
suspend fun insert(entity: SyncEventEntity): Long
diff --git a/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt b/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt
new file mode 100644
index 0000000..ea31ec1
--- /dev/null
+++ b/app/src/main/kotlin/com/syncflow/ui/log/LogScreen.kt
@@ -0,0 +1,178 @@
+package com.syncflow.ui.log
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.syncflow.domain.model.SyncEventType
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+@Composable
+fun LogScreen(
+ modifier: Modifier = Modifier,
+ vm: LogViewModel = hiltViewModel(),
+) {
+ val entries by vm.entries.collectAsState()
+
+ if (entries.isEmpty()) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.Notifications,
+ contentDescription = null,
+ modifier = Modifier.size(72.dp),
+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
+ )
+ Text("No activity yet", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Sync events will appear here",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ } else {
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ // Group entries by calendar date
+ val grouped = entries.groupBy { entry ->
+ entry.event.timestamp.atZone(ZoneId.systemDefault()).toLocalDate()
+ }
+ grouped.forEach { (date, dayEntries) ->
+ item(key = date.toString()) {
+ Text(
+ text = date.toRelativeLabel(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
+ )
+ }
+ items(dayEntries, key = { it.event.id }) { entry ->
+ LogEntryRow(entry)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
+ modifier = Modifier.padding(start = 52.dp),
+ )
+ }
+ }
+ item { Spacer(Modifier.height(80.dp)) }
+ }
+ }
+}
+
+@Composable
+private fun LogEntryRow(entry: LogEntry) {
+ val (icon, tint) = entry.event.eventType.iconAndTint()
+ val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+ val timeStr = entry.event.timestamp.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ // Icon bubble
+ Surface(
+ shape = RoundedCornerShape(10.dp),
+ color = tint.copy(alpha = 0.12f),
+ modifier = Modifier.size(36.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
+ }
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ entry.pairName,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f, fill = false),
+ )
+ Text(
+ timeStr,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.event.eventType.label(),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ val detail = entry.event.filePath ?: entry.event.message
+ if (detail != null) {
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SyncEventType.iconAndTint(): Pair = when (this) {
+ SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary
+ SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
+ SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
+ SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary
+ SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary
+ SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary
+ SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant
+ SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary
+ SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
+}
+
+private fun SyncEventType.label(): String = when (this) {
+ SyncEventType.SYNC_STARTED -> "Sync started"
+ SyncEventType.SYNC_COMPLETED -> "Sync completed"
+ SyncEventType.SYNC_FAILED -> "Sync failed"
+ SyncEventType.FILE_UPLOADED -> "File uploaded"
+ SyncEventType.FILE_DOWNLOADED -> "File downloaded"
+ SyncEventType.FILE_DELETED -> "File deleted"
+ SyncEventType.FILE_SKIPPED -> "File skipped"
+ SyncEventType.CONFLICT_DETECTED -> "Conflict detected"
+ SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved"
+}
+
+private fun java.time.LocalDate.toRelativeLabel(): String {
+ val today = java.time.LocalDate.now()
+ return when {
+ this == today -> "Today"
+ this == today.minusDays(1) -> "Yesterday"
+ else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(this)
+ }
+}
diff --git a/app/src/main/kotlin/com/syncflow/ui/log/LogViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/log/LogViewModel.kt
new file mode 100644
index 0000000..26b5d38
--- /dev/null
+++ b/app/src/main/kotlin/com/syncflow/ui/log/LogViewModel.kt
@@ -0,0 +1,29 @@
+package com.syncflow.ui.log
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.syncflow.data.db.SyncEventDao
+import com.syncflow.data.db.SyncPairDao
+import com.syncflow.data.db.entities.SyncEventEntity
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+data class LogEntry(val event: SyncEventEntity, val pairName: String)
+
+@HiltViewModel
+class LogViewModel @Inject constructor(
+ syncEventDao: SyncEventDao,
+ syncPairDao: SyncPairDao,
+) : ViewModel() {
+
+ val entries = combine(
+ syncEventDao.observeAll(500),
+ syncPairDao.observeAll(),
+ ) { events, pairs ->
+ val pairNames = pairs.associateBy({ it.id }, { it.name })
+ events.map { LogEntry(it, pairNames[it.syncPairId] ?: "Unknown") }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+}
diff --git a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt
index 662d53d..f9954e6 100644
--- a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt
+++ b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt
@@ -20,8 +20,10 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ManageAccounts
+import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.ManageAccounts
+import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -32,6 +34,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.syncflow.R
import com.syncflow.ui.home.HomeScreen
+import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
@@ -42,7 +45,7 @@ fun MainShell(
onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit,
) {
- val pagerState = rememberPagerState(pageCount = { 2 })
+ val pagerState = rememberPagerState(pageCount = { 3 })
val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage
@@ -88,7 +91,18 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
icon = {
Icon(
- if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
+ if (currentPage == 1) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone,
+ contentDescription = null,
+ )
+ },
+ label = { Text("Log") },
+ )
+ NavigationBarItem(
+ selected = currentPage == 2,
+ onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
+ icon = {
+ Icon(
+ if (currentPage == 2) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null,
)
},
@@ -120,7 +134,8 @@ fun MainShell(
) { page ->
when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
- 1 -> SettingsScreen(onAddAccount = onAddAccount)
+ 1 -> LogScreen()
+ 2 -> SettingsScreen(onAddAccount = onAddAccount)
}
}
}
diff --git a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt
index cb43caf..6d36d3c 100644
--- a/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt
+++ b/app/src/main/kotlin/com/syncflow/worker/FileWatchService.kt
@@ -10,6 +10,7 @@ import android.os.FileObserver
import android.os.Handler
import android.os.IBinder
import android.os.Looper
+import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager
@@ -81,35 +82,25 @@ class FileWatchService : Service() {
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)
- }
+ // Try to resolve the SAF tree URI to a real filesystem path so we can use
+ // FileObserver. ContentObserver on a DocumentsProvider tree URI only fires
+ // when changes come through the SAF API, not for raw filesystem writes.
+ val realPath = safTreeUriToRealPath(localPath)
+ if (realPath != null) {
+ watchPath(realPath, 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)
+ // Fallback: register a ContentObserver for SAF paths that can't be resolved
+ 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 (ContentObserver fallback) for pair $pairId")
}
- observer.startWatching()
- fileObservers[pairId] = observer
- Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
+ } else {
+ watchPath(localPath, pairId, pair.wifiOnly, pair.chargingOnly)
}
}
@@ -122,6 +113,46 @@ class FileWatchService : Service() {
}
}
+ private fun safTreeUriToRealPath(uriString: String): String? {
+ return try {
+ val treeUri = Uri.parse(uriString)
+ val docId = DocumentsContract.getTreeDocumentId(treeUri)
+ // docId format is "primary:RelativePath" for primary internal storage
+ if (docId.startsWith("primary:")) {
+ val relative = docId.removePrefix("primary:")
+ "/storage/emulated/0/$relative"
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ Timber.w("FileWatchService: could not resolve SAF URI to real path: $e")
+ null
+ }
+ }
+
+ private fun watchPath(path: String, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
+ val dir = File(path)
+ if (!dir.exists()) {
+ Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
+ return
+ }
+ 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, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly)
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ object : FileObserver(path, mask) {
+ override fun onEvent(event: Int, p: String?) = onChangeDetected(pairId, wifiOnly, chargingOnly)
+ }
+ }
+ observer.startWatching()
+ fileObservers[pairId] = observer
+ Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path")
+ }
+
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
diff --git a/version.properties b/version.properties
index 4d904ae..70d9618 100644
--- a/version.properties
+++ b/version.properties
@@ -1,2 +1,2 @@
-VERSION_NAME=1.0.19
-VERSION_CODE=20
+VERSION_NAME=1.0.21
+VERSION_CODE=22