Compare commits

...

2 Commits

Author SHA1 Message Date
amir 08dc4f5bd4 v1.0.23: functional Files tab, background service persistence, startup indexer, curved icon
- FilesScreen: per-file context menu (Open, Share, Rename, Delete), rename dialog,
  delete confirmation, FileProvider-based open/share intents, Snackbar error feedback
- FilesViewModel: FileAction sealed class + SharedFlow; openFile, shareFile,
  deleteFile, renameFile with DB cleanup; resolveFile handles SAF primary: URIs
- FileWatchService: stopWithTask=false keeps watcher alive after app swipe-away;
  catchupScan on startup detects changes missed while service was not running;
  SyncFileStateDao injected; FileObserver used for real-path SAF URIs
- BootReceiver: handles MY_PACKAGE_REPLACED to restart service after app update
- file_paths.xml: added external-path so FileProvider can serve /storage/emulated/0 files
- ic_launcher_foreground: three curved stroke-based arrows (quadratic bezier, round caps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:25:58 +00:00
amir 422e8f0f0f feat: fix sync counters, polished activity rows, Files tab, new icon
- Fix SYNC_COMPLETED showing ↑0 ↓0 ✗0 when only deletions occurred: add ✕N
  for deleted files to the summary message (↑N ↓N ✕N ✗N format)
- Fix PairDetail Activity section showing raw "SYNC_STARTED" enum names and
  "remote" as a plain subtitle: replace dot-based EventRow with the same
  polished icon-bubble rows as the global Log tab
- Extract shared SyncEventRow composable + iconAndTint/label helpers to
  ui/shared/SyncEventRow.kt; both LogScreen and PairDetailScreen now use it
- Add Files tab (4th tab between Log and Accounts): folder browser showing
  all synced files per pair, grouped by subdirectory, with file-type icons,
  size, last-synced date, and a summary header (N files, total size)
- Add SyncFileStateDao.observeForPair() reactive Flow query for Files tab
- Completely redesign app icon: near-black radial gradient background with
  three bold directional arrows in an S-pattern (coral → silver → teal),
  each with gradient fills and tip-glow dots — entirely different from the
  typical circular sync-arrow style
- Bump version to 1.0.22 (build 23)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:05:28 +00:00
15 changed files with 760 additions and 140 deletions
+3
View File
@@ -66,13 +66,16 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- File watcher for ON_CHANGE sync pairs --> <!-- File watcher for ON_CHANGE sync pairs -->
<!-- stopWithTask=false keeps the service alive when the user swipes the app away -->
<service <service
android:name=".worker.FileWatchService" android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync|shortService" android:foregroundServiceType="dataSync|shortService"
android:stopWithTask="false"
android:exported="false" /> 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 -->
@@ -2,9 +2,13 @@ package com.syncflow.data.db
import androidx.room.* import androidx.room.*
import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SyncFileStateDao { interface SyncFileStateDao {
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId ORDER BY relativePath ASC")
fun observeForPair(pairId: Long): Flow<List<SyncFileStateEntity>>
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId") @Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId")
suspend fun getForPair(pairId: Long): List<SyncFileStateEntity> suspend fun getForPair(pairId: Long): List<SyncFileStateEntity>
@@ -46,7 +46,7 @@ class SyncEngine @Inject constructor(
else -> SyncStatus.SUCCESS else -> SyncStatus.SUCCESS
} }
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts) syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.failedFiles}", result.bytesTransferred) logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.deleted}${result.failedFiles}", result.bytesTransferred)
result result
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Sync failed for pair ${pair.id}") Timber.e(e, "Sync failed for pair ${pair.id}")
@@ -0,0 +1,396 @@
package com.syncflow.ui.files
import android.content.Intent
import android.webkit.MimeTypeMap
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilesScreen(
modifier: Modifier = Modifier,
vm: FilesViewModel = hiltViewModel(),
) {
val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
vm.fileAction.collect { action ->
when (action) {
is FileAction.Open -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Open with").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
}
}
is FileAction.Share -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share via").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share file: ${e.message}")
}
}
is FileAction.Error -> scope.launch {
snackbarHostState.showSnackbar(action.message)
}
}
}
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
if (pairs.size > 1) {
ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
divider = {},
) {
pairs.forEach { pair ->
Tab(
selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) },
text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
HorizontalDivider()
}
when {
pairs.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No sync pairs yet",
subtitle = "Create a sync pair to browse its files",
)
files.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No synced files yet",
subtitle = "Run a sync to populate this view",
)
else -> {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
val grouped = files.groupBy { f ->
val idx = f.relativePath.indexOf('/')
if (idx < 0) "" else f.relativePath.substring(0, idx)
}
grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) {
item(key = "dir_$dir") {
Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Folder, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(6.dp))
Text(
dir,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
icon, contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) {
val name = file.relativePath.substringAfterLast('/')
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
var menuExpanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
if (showRenameDialog) {
RenameDialog(
currentName = name,
onConfirm = { newName ->
vm.renameFile(file, newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false },
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete file?") },
text = { Text("\"$name\" will be removed from this device.") },
confirmButton = {
TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
},
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = "File options",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Open") },
leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) },
onClick = { menuExpanded = false; vm.openFile(file) },
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
onClick = { menuExpanded = false; vm.shareFile(file) },
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menuExpanded = false; showRenameDialog = true },
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(
Icons.Default.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
)
}
}
}
}
@Composable
private fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
title = { Text("Rename file") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed)
else onDismiss()
},
enabled = newName.isNotBlank(),
) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
private fun String.mimeType(): String {
val ext = substringAfterLast('.', "").lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
}
private fun fileIcon(name: String) = when {
name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf
name.endsWith(".jpg", ignoreCase = true) ||
name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) ||
name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) ||
name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) ||
name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) ||
name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) ||
name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile
}
private fun Long.toDisplaySize(): String = when {
this < 1_024 -> "$this B"
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
}
@@ -0,0 +1,117 @@
package com.syncflow.ui.files
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.db.entities.SyncPairEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
sealed class FileAction {
data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction()
data class Error(val message: String) : FileAction()
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FilesViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
@ApplicationContext private val context: Context,
) : ViewModel() {
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
list.firstOrNull { it.id == id } ?: list.firstOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val files: StateFlow<List<SyncFileStateEntity>> = _selectedPairId
.flatMapLatest { id ->
if (id == null) pairs.map { it.firstOrNull()?.id }.filterNotNull()
.flatMapLatest { fileStateDao.observeForPair(it) }
else fileStateDao.observeForPair(id)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction
fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
}
fun shareFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file) ?: return
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
}
fun deleteFile(file: SyncFileStateEntity) {
viewModelScope.launch {
try {
val resolved = resolveFile(file)
resolved?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Delete failed: ${file.relativePath}")
_fileAction.emit(FileAction.Error("Delete failed: ${e.message}"))
}
}
}
fun renameFile(file: SyncFileStateEntity, newName: String) {
viewModelScope.launch {
try {
val resolved = resolveFile(file) ?: return@launch
val parent = resolved.parentFile ?: return@launch
val dest = File(parent, newName)
if (!resolved.renameTo(dest)) {
_fileAction.emit(FileAction.Error("Rename failed"))
return@launch
}
// Update DB: delete old state; the next sync will re-detect as a new upload
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}")
_fileAction.emit(FileAction.Error("Rename failed: ${e.message}"))
}
}
}
private fun resolveFile(file: SyncFileStateEntity): File? {
val pair = selectedPair.value ?: return null
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath
val f = File(root, file.relativePath)
if (!f.exists()) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) }
return null
}
return f
}
private fun safTreeUriToRealPath(uriString: String): String? {
if (!uriString.startsWith("content://")) return uriString
return try {
val treeUri = android.net.Uri.parse(uriString)
val docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri)
if (docId.startsWith("primary:")) "/storage/emulated/0/${docId.removePrefix("primary:")}"
else null
} catch (e: Exception) { null }
}
}
@@ -6,16 +6,15 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* 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.vector.ImageVector
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.domain.model.SyncEventType import com.syncflow.domain.model.SyncEventType
import com.syncflow.ui.shared.iconAndTint
import com.syncflow.ui.shared.label
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -143,31 +142,6 @@ private fun LogEntryRow(entry: LogEntry) {
} }
} }
@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 { private fun java.time.LocalDate.toRelativeLabel(): String {
val today = java.time.LocalDate.now() val today = java.time.LocalDate.now()
return when { return when {
@@ -19,9 +19,11 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.ManageAccounts import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.NotificationsNone import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
@@ -33,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.syncflow.R import com.syncflow.R
import com.syncflow.ui.files.FilesScreen
import com.syncflow.ui.home.HomeScreen import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.log.LogScreen import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen import com.syncflow.ui.settings.SettingsScreen
@@ -45,7 +48,7 @@ fun MainShell(
onPairClick: (Long) -> Unit, onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit, onAddAccount: () -> Unit,
) { ) {
val pagerState = rememberPagerState(pageCount = { 3 }) val pagerState = rememberPagerState(pageCount = { 4 })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
@@ -102,7 +105,18 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(2) } }, onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
icon = { icon = {
Icon( Icon(
if (currentPage == 2) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts, if (currentPage == 2) Icons.Filled.Folder else Icons.Outlined.FolderOpen,
contentDescription = null,
)
},
label = { Text("Files") },
)
NavigationBarItem(
selected = currentPage == 3,
onClick = { scope.launch { pagerState.animateScrollToPage(3) } },
icon = {
Icon(
if (currentPage == 3) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null, contentDescription = null,
) )
}, },
@@ -135,7 +149,8 @@ fun MainShell(
when (page) { when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick) 0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> LogScreen() 1 -> LogScreen()
2 -> SettingsScreen(onAddAccount = onAddAccount) 2 -> FilesScreen()
3 -> SettingsScreen(onAddAccount = onAddAccount)
} }
} }
} }
@@ -17,14 +17,12 @@ 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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.SyncPairEntity import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -132,7 +130,7 @@ fun PairDetailScreen(
} }
} else { } else {
items(events, key = { it.id }) { event -> items(events, key = { it.id }) { event ->
EventRow(event) SyncEventRow(event, showDivider = event != events.last())
} }
} }
} }
@@ -231,56 +229,6 @@ private fun InfoRow(
} }
} }
@Composable
private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
val dotColor = eventColor(event.eventType)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Colored dot indicator
Surface(
shape = RoundedCornerShape(50),
color = dotColor,
modifier = Modifier.size(8.dp),
) {}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
event.filePath ?: event.message ?: event.eventType.name,
style = MaterialTheme.typography.bodySmall,
)
event.message?.takeIf { event.filePath != null }?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
fmt.format(event.timestamp.atZone(zone)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun eventColor(type: SyncEventType): Color = when (type) {
SyncEventType.SYNC_STARTED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_COMPLETED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> MaterialTheme.colorScheme.primary
}
private fun String.toDisplayPath(): String { private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8") val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded } return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -0,0 +1,102 @@
package com.syncflow.ui.shared
import androidx.compose.foundation.layout.*
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.Composable
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 com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.domain.model.SyncEventType
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun SyncEventRow(event: SyncEventEntity, showDivider: Boolean = true) {
val (icon, tint) = event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = timeFmt.format(event.timestamp.atZone(ZoneId.systemDefault()))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
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(
event.eventType.label(),
style = MaterialTheme.typography.labelMedium,
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val detail = event.filePath ?: event.message
if (detail != null) {
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
if (showDivider) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 48.dp),
)
}
}
@Composable
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
}
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"
}
@@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return val validActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
if (intent.action !in validActions) return
val wm = WorkManager.getInstance(context) val wm = WorkManager.getInstance(context)
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -16,6 +16,7 @@ import androidx.work.ExistingWorkPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import com.syncflow.MainActivity import com.syncflow.MainActivity
import com.syncflow.R import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -28,6 +29,7 @@ import javax.inject.Inject
class FileWatchService : Service() { class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao @Inject lateinit var syncPairDao: SyncPairDao
@Inject lateinit var fileStateDao: SyncFileStateDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
@@ -151,6 +153,36 @@ class FileWatchService : Service() {
observer.startWatching() observer.startWatching()
fileObservers[pairId] = observer fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path") Timber.d("FileWatchService: watching filesystem path for pair $pairId: $path")
// Check if anything changed while the service was not running
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) {
val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath }
if (known.isEmpty()) return // Never synced — first sync will be triggered manually
val current = mutableMapOf<String, Long>()
dir.walk().filter { it.isFile }.forEach { f ->
current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified()
}
val hasNew = current.any { (rel, _) -> rel !in known }
val hasModified = current.any { (rel, mtime) ->
val s = known[rel]; s != null && s.localModifiedAt != null &&
s.localModifiedAt.toEpochMilli() != mtime
}
val hasDeleted = known.keys.any { rel -> rel !in current }
if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork(
"catchup_$pairId",
ExistingWorkPolicy.KEEP,
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly),
)
}
} }
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) { private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient <gradient
android:type="linear" android:type="radial"
android:angle="135" android:gradientRadius="80%"
android:startColor="#2E1065" android:centerX="0.35"
android:centerColor="#6D28D9" android:centerY="0.3"
android:endColor="#1E40AF"/> android:startColor="#1C1124"
android:centerColor="#0E0A18"
android:endColor="#060408"/>
</shape> </shape>
@@ -6,65 +6,90 @@
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Outer soft glow ring --> <!-- Subtle inner glow -->
<path <path
android:pathData="M54,54m-44,0a44,44 0 1,0 88,0a44,44 0 1,0 -88,0" android:pathData="M54,54m-30,0a30,30 0 1,0 60,0a30,30 0 1,0 -60,0"
android:fillColor="#12FFFFFF"/> android:fillColor="#0AFFFFFF"/>
<!-- Mid glow ring --> <!-- ═══ Arrow 1: curving RIGHT (top) — coral gradient ═══ -->
<!-- Curved shaft via quadratic bezier: left→ dips up → right -->
<path <path
android:pathData="M54,54m-33,0a33,33 0 1,0 66,0a33,33 0 1,0 -66,0" android:pathData="M28,40 Q54,22 80,36"
android:fillColor="#18FFFFFF"/> android:fillColor="#00000000"
android:strokeWidth="7"
<!-- Inner glow ring --> android:strokeLineCap="round"
<path android:strokeLineJoin="round">
android:pathData="M54,54m-21,0a21,21 0 1,0 42,0a21,21 0 1,0 -42,0" <aapt:attr name="android:strokeColor">
android:fillColor="#10FFFFFF"/> <gradient android:type="linear"
android:startX="28" android:startY="36"
<!-- Upload arrow (top-right) — neon cyan → sky blue --> android:endX="80" android:endY="36"
<path android:pathData="M54,18V4.5L36,22.5l18,18V27c14.895,0 27,12.105 27,27 0,4.545-1.125,8.865-3.15,12.6l6.57,6.57C87.93,67.635 90,61.065 90,54c0-19.89-16.11-36-36-36z"> android:startColor="#FF6B6B"
android:endColor="#FFB347"/>
</aapt:attr>
</path>
<!-- Arrowhead at (80,36) pointing right/slightly-down -->
<path android:pathData="M76,28 L72,44 L88,38 Z">
<aapt:attr name="android:fillColor"> <aapt:attr name="android:fillColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="36" android:startY="4" android:startX="72" android:startY="36"
android:endX="90" android:endY="70" android:endX="88" android:endY="36"
android:startColor="#67E8F9" android:startColor="#FFB347"
android:endColor="#38BDF8"/> android:endColor="#FF8C42"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Download arrow (bottom-left) — hot pink → coral --> <!-- ═══ Arrow 2: curving LEFT (middle) — silver/white ═══ -->
<path android:pathData="M54,81c-14.895,0-27,-12.105-27,-27 0,-4.545 1.125,-8.865 3.15,-12.6L23.58,34.83C20.07,40.365 18,46.935 18,54c0,19.89 16.11,36 36,36v13.5l18,-18-18,-18v13.5z"> <!-- Curved shaft: right→ dips down → left -->
<path
android:pathData="M80,56 Q54,74 28,60"
android:fillColor="#00000000"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="80" android:startY="60"
android:endX="28" android:endY="60"
android:startColor="#D0D8EE"
android:endColor="#8892AA"/>
</aapt:attr>
</path>
<!-- Arrowhead at (28,60) pointing left/slightly-down -->
<path android:pathData="M32,52 L36,68 L20,62 Z">
<aapt:attr name="android:fillColor"> <aapt:attr name="android:fillColor">
<gradient android:type="linear" <gradient android:type="linear"
android:startX="18" android:startY="35" android:startX="36" android:startY="60"
android:endX="72" android:endY="103" android:endX="20" android:endY="60"
android:startColor="#F472B6" android:startColor="#D0D8EE"
android:endColor="#FB923C"/> android:endColor="#8892AA"/>
</aapt:attr> </aapt:attr>
</path> </path>
<!-- Center glowing orb --> <!-- ═══ Arrow 3: curving RIGHT (bottom) — teal gradient ═══ -->
<!-- Curved shaft: left→ dips up → right -->
<path <path
android:pathData="M54,54m-7,0a7,7 0 1,0 14,0a7,7 0 1,0 -14,0" android:pathData="M28,74 Q54,58 80,72"
android:fillColor="#60FFFFFF"/> android:fillColor="#00000000"
<path android:strokeWidth="7"
android:pathData="M54,54m-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0" android:strokeLineCap="round"
android:fillColor="#FFFFFF"/> android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<!-- Cardinal accent sparks --> <gradient android:type="linear"
<!-- Top — cyan --> android:startX="28" android:startY="72"
<path android:pathData="M54,13m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#22D3EE"/> android:endX="80" android:endY="72"
<!-- Right — indigo --> android:startColor="#4DD0E1"
<path android:pathData="M95,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#818CF8"/> android:endColor="#00BCD4"/>
<!-- Bottom — pink --> </aapt:attr>
<path android:pathData="M54,95m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#F9A8D4"/> </path>
<!-- Left — emerald --> <!-- Arrowhead at (80,72) pointing right/slightly-up -->
<path android:pathData="M13,54m-3,0a3,3 0 1,0 6,0a3,3 0 1,0 -6,0" android:fillColor="#6EE7B7"/> <path android:pathData="M76,64 L72,80 L88,74 Z">
<aapt:attr name="android:fillColor">
<!-- Diagonal mini sparks (45°) --> <gradient android:type="linear"
<path android:pathData="M85,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#A5F3FC"/> android:startX="72" android:startY="72"
<path android:pathData="M85,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#FDBA74"/> android:endX="88" android:endY="72"
<path android:pathData="M23,85m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#C084FC"/> android:startColor="#00BCD4"
<path android:pathData="M23,23m-2,0a2,2 0 1,0 4,0a2,2 0 1,0 -4,0" android:fillColor="#86EFAC"/> android:endColor="#00ACC1"/>
</aapt:attr>
</path>
</vector> </vector>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." /> <external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." /> <files-path name="internal_files" path="." />
</paths> </paths>
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.21 VERSION_NAME=1.0.23
VERSION_CODE=22 VERSION_CODE=24