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