feat: fix notifications on Android 13+/16, add Log tab, fix ON_CHANGE detection

- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix
  for notifications never appearing on Android 13+ phones including Android 16)
- Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead
  of lazily inside workers
- Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type
  for Android 16 foreground service compatibility
- Add global activity Log tab (new tab 2 in main nav) showing all sync events
  across all pairs, grouped by date with pair name, event icon, and file detail
- Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only
  fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/*
  tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection
- Bump version to 1.0.21 (build 22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:34:48 +00:00
parent 739e6ece46
commit a7c5ed713a
9 changed files with 332 additions and 33 deletions
@@ -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<ImageVector, Color> = 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)
}
}