Compare commits

...

9 Commits

Author SHA1 Message Date
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
amir 21d8f0dca2 redesign: modern indigo UI, new app icon, edge-to-edge theme
App icon: deep indigo-to-violet gradient background with white sync arrows;
replaced flat #2196F3 with layered adaptive icon.

Theme: disabled dynamic color; rich indigo/teal/amber Material3 palette;
edge-to-edge with transparent status bar; tighter typography letterSpacing.

HomeScreen: colored left accent bar per status; URL-decoded SAF paths;
relative timestamps (Just now / N min ago / N hr ago); indigo status pills;
FilledTonalButton empty state.

PairDetailScreen: hero StatusBanner with large icon and relative time;
InfoCard as bordered grid with icon backgrounds; colored dot event timeline;
URL-decoded local path display.

SettingsScreen: section headers with primary left bar; AccountCard with
primaryContainer icon backgrounds; Security/About in bordered cards.

Bump version to 1.0.13 (code 14).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:31:44 +00:00
amir 3d7a8b5f3d fix: remote deletions not mirrored when file has no state record
When a file was uploaded before state-tracking worked (getFileMetadata was
broken), its SyncFileStateEntity was never saved. On next sync the engine
saw !local + remote + known=null and downloaded it back instead of deleting
it remotely, creating an infinite re-download loop.

Fix: syncDecide() now accepts hasPriorSyncState (derived from whether the
pair has any known states at all). On initial sync (no prior state) unknown
remote files are downloaded as before. Once the pair has been synced, unknown
remote-only files are treated as mirror-eligible deletions — same as if known
state existed — so locally-deleted files propagate to the remote correctly.

Verified live: 3 remote-only orphan files deleted from Nextcloud on sync.
Bump version to 1.0.12 (code 13).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:27 +00:00
amir 1d6a80e43d fix: SAF delete crash, getFileMetadata drop-first, MKCOL before upload
- LocalAccessor.Saf.delete() now uses docIdCache (same as openInputStream)
  and catches IllegalStateException from DocumentsContract.deleteDocument
  instead of propagating it through awaitAll() and crashing the whole sync
- WebDavProvider.getFileMetadata() passes dropFirst=false to parsePropfind
  since Depth:0 returns exactly 1 result (the file); drop(1) was discarding it
- SyncEngine.performSync() calls ensureRemoteDirs() before each upload so
  MKCOL is issued for any missing parent directories (405=exists is success)
- Bump version to 1.0.11 (code 12)

Verified against live Nextcloud: baseline ↑0 ↓0 ✗0, upload detection ↑1 ↓0 ✗0,
download detection ↑0 ↓1 ✗0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 01:07:54 +00:00
amir a9322d3214 fix: incremental sync + unit tests for decide() logic
Sync change detection (3rd attempt — now correct):
- After UPLOAD: save null remote metadata (server mtime unknown until
  next listing); decide() treats null remoteModifiedAt as "not changed"
- After DOWNLOAD: read actual local mtime via accessor.lastModifiedMs()
  so the stored value matches what walkFiles() sees on next scan
- SKIP reconciliation: if known state has null timestamps and both sides
  exist, fill them in — stabilises state within 2 syncs after first transfer
- Extract syncDecide() as internal top-level function for testability

Unit tests (14 cases covering all key scenarios):
- First sync decisions (upload/download/conflict)
- Second sync after upload with null remote metadata → SKIP
- Second sync after download with recorded local mtime → SKIP
- Epoch-millis precision: same ms = SKIP, +1ms = change detected
- Regression: epoch-second stored value would have differed → now correct
- Delete behaviour (MIRROR vs KEEP)
- Direction filters (UPLOAD_ONLY, DOWNLOAD_ONLY)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:13:00 +00:00
amir 5f45a344b7 fix: epoch-millis DB converter + biometric from onResume
Sync change detection:
- DbConverters was using epochSecond but comparisons used epochMilli —
  every file appeared modified on every scan, causing full re-sync each time
- DB migration 2→3 clears sync_file_states (all stored timestamps wrong)
- First sync after upgrade re-learns state; subsequent syncs skip unchanged files

Biometric:
- Move prompt trigger from LaunchedEffect to onResume() — guarantees
  the activity is in RESUMED state when authenticate() is called
- Add bestAuthenticators(): tries BIOMETRIC_STRONG|DEVICE_CREDENTIAL first,
  falls back to BIOMETRIC_WEAK|DEVICE_CREDENTIAL for side-sensor phones
- canAuthenticate() now accepts either strong or weak+credential
- onAuthenticationError always shows Unlock button (no infinite retry loop)
- isLocked/showRetry are Activity-level state, no need for Compose remember

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:59:20 +00:00
amir e237555222 fix: biometric retry + sync change detection race condition
Biometric:
- Handle onAuthenticationError with auto-retry (except user cancel)
- Show lock screen with proper UI and an Unlock button as fallback
- Add subtitle clarifying fingerprint/PIN options

Sync engine:
- Fix data race: async coroutines now return FileOutcome instead of
  mutating shared vars/list concurrently (was causing file states to
  not be saved, so every sync re-transferred all files)
- Fix remoteChanged: use || instead of && so either etag or
  modifiedAt change is enough to detect a remote modification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:51:24 +00:00
amir d6220b7bd7 fix: add edit button, bypass constraints on manual sync
- Add Edit icon to PairDetailScreen top bar
- Wire onEdit callback through NavGraph to AddPairScreen with pairId
- Manual "Sync now" (home card + detail screen) now ignores wifiOnly
  and chargingOnly constraints so it runs immediately on tap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:32:46 +00:00
amir c8e50ac17e fix: take persistable SAF URI permission on folder selection
Without calling takePersistableUriPermission, the content:// URI
permission granted by ACTION_OPEN_DOCUMENT_TREE is revoked on
app reinstall, causing Permission Denial errors during sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 23:27:16 +00:00
33 changed files with 1124 additions and 330 deletions
@@ -7,17 +7,28 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
@@ -36,6 +47,7 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var appPreferences: AppPreferences
private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
@@ -47,66 +59,106 @@ class MainActivity : AppCompatActivity() {
SyncFlowNavGraph(rememberNavController())
}
if (isLocked) {
LockOverlay()
LaunchedEffect(Unit) {
showBiometricPrompt(onSuccess = { isLocked = false })
}
LockOverlay(
showRetry = showRetry,
onRetry = { triggerBiometric() },
)
}
}
}
}
override fun onResume() {
super.onResume()
if (isLocked) triggerBiometric()
}
override fun onStop() {
super.onStop()
if (isChangingConfigurations) return
lifecycleScope.launch {
if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) {
isLocked = true
showRetry = false
}
}
}
private fun canAuthenticate(): Boolean {
val authenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
else
BIOMETRIC_STRONG
return BiometricManager.from(this).canAuthenticate(authenticators) ==
BiometricManager.BIOMETRIC_SUCCESS
}
private fun showBiometricPrompt(onSuccess: () -> Unit) {
private fun triggerBiometric() {
showRetry = false
val authenticators = bestAuthenticators()
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
isLocked = false
showRetry = false
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// Show the Unlock button so the user can tap to retry manually
showRetry = true
}
override fun onAuthenticationFailed() {
// Wrong biometric — BiometricPrompt retries automatically
}
})
val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.setSubtitle("Use fingerprint or PIN")
.setAllowedAuthenticators(authenticators)
.build()
} else {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setSubtitle("Use fingerprint")
.setNegativeButtonText("Cancel")
.build()
}
prompt.authenticate(promptInfo)
}
private fun bestAuthenticators(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return BIOMETRIC_STRONG
val bm = BiometricManager.from(this)
// Prefer strong+credential; fall back to weak+credential so side-sensor phones work
return if (bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS)
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
else
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
}
private fun canAuthenticate(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
val bm = BiometricManager.from(this)
return bm.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS ||
bm.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
}
}
@Composable
private fun LockOverlay() {
private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Default.Lock, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.primary)
Text("SyncFlow is locked", style = MaterialTheme.typography.titleMedium)
if (showRetry) {
Button(onClick = onRetry) { Text("Unlock") }
} else {
Text(
"Use fingerprint or PIN to unlock",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -4,6 +4,6 @@ import androidx.room.TypeConverter
import java.time.Instant
class DbConverters {
@TypeConverter fun fromInstant(v: Instant?): Long? = v?.epochSecond
@TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochSecond(it) }
@TypeConverter fun fromInstant(v: Instant?): Long? = v?.toEpochMilli()
@TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochMilli(it) }
}
@@ -3,6 +3,8 @@ package com.syncflow.data.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.syncflow.data.db.entities.*
@Database(
@@ -13,11 +15,21 @@ import com.syncflow.data.db.entities.*
SyncConflictEntity::class,
SyncEventEntity::class,
],
version = 2,
version = 3,
exportSchema = true,
)
@TypeConverters(DbConverters::class)
abstract class SyncDatabase : RoomDatabase() {
companion object {
// Wipe file states: timestamps were stored as epoch-seconds, now epoch-millis.
// All previously saved states are wrong so we drop and re-learn on next sync.
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DELETE FROM sync_file_states")
}
}
}
abstract fun cloudAccountDao(): CloudAccountDao
abstract fun syncPairDao(): SyncPairDao
abstract fun syncFileStateDao(): SyncFileStateDao
@@ -132,7 +132,10 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp ->
if (resp.code != 207) throw Exception("HTTP ${resp.code}")
parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'))
// Depth:0 returns exactly the requested resource as the single response entry.
// parsePropfind normally drops the first entry (the parent dir) for Depth:1
// directory listings, so pass dropFirst=false here.
parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'), dropFirst = false)
.firstOrNull() ?: throw Exception("File not found")
}
}
@@ -153,7 +156,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
private fun parsePropfind(xml: String, parentPath: String): List<RemoteFile> {
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>()
try {
val factory = XmlPullParserFactory.newInstance()
@@ -192,7 +195,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
eventType = parser.next()
}
} catch (_: Exception) {}
return results.drop(1) // drop the parent folder itself
return if (dropFirst) results.drop(1) else results
}
private fun parseHttpDate(value: String): Instant = try {
@@ -21,9 +21,8 @@ object AppModule {
@Provides @Singleton
fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase =
Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db")
// Only fall back to destructive migration for very old dev builds (v1).
// All future version bumps must include a proper Migration object.
.fallbackToDestructiveMigrationFrom(1)
.addMigrations(SyncDatabase.MIGRATION_2_3)
.build()
@Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao()
@@ -63,7 +63,13 @@ sealed class LocalAccessor {
class Saf(private val treeUri: Uri, private val resolver: ContentResolver) : LocalAccessor() {
// Populated by walkFiles so openInputStream can skip the re-query for files that
// already exist locally (uploads). Root-level files are the common failure case
// when findDocUri re-queries: the cache sidesteps the issue entirely.
private val docIdCache = mutableMapOf<String, String>()
override fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo> {
docIdCache.clear()
val rootDocId = DocumentsContract.getTreeDocumentId(treeUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, rootDocId)
return cursorWalk(childrenUri, "", pair)
@@ -110,6 +116,7 @@ sealed class LocalAccessor {
if (ext in excludeExts) continue
if (size !in minBytes..maxBytes) continue
result[rel] = LocalFileInfo(rel, size, modified)
docIdCache[rel] = docId
}
}
}
@@ -117,7 +124,10 @@ sealed class LocalAccessor {
}
override fun openInputStream(relativePath: String): InputStream? {
val docUri = findDocUri(relativePath) ?: return null
val docUri = docIdCache[relativePath]
?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) }
?: findDocUri(relativePath)
?: return null
return resolver.openInputStream(docUri)
}
@@ -155,8 +165,15 @@ sealed class LocalAccessor {
}
override fun delete(relativePath: String): Boolean {
val docUri = findDocUri(relativePath) ?: return false
return DocumentsContract.deleteDocument(resolver, docUri)
val docUri = docIdCache[relativePath]
?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) }
?: findDocUri(relativePath)
?: return false
return try {
DocumentsContract.deleteDocument(resolver, docUri)
} catch (e: Exception) {
false
}
}
override fun lastModifiedMs(relativePath: String): Long {
@@ -8,6 +8,7 @@ import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
@@ -68,39 +69,44 @@ class SyncEngine @Inject constructor(
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
var uploaded = 0; var downloaded = 0; var deleted = 0; var skipped = 0; var failed = 0; var conflicts = 0
var bytesTransferred = 0L
val newStates = mutableListOf<com.syncflow.data.db.entities.SyncFileStateEntity>()
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
coroutineScope {
// Each async block returns its outcome; no shared mutable state across coroutines.
data class FileOutcome(
val uploaded: Int = 0, val downloaded: Int = 0, val deleted: Int = 0,
val skipped: Int = 0, val failed: Int = 0, val conflicts: Int = 0,
val bytesTransferred: Long = 0L,
val newState: SyncFileStateEntity? = null,
)
val outcomes: List<FileOutcome> = coroutineScope {
allPaths.map { rel ->
async {
semaphore.withPermit {
val local = localFiles[rel]
val remote = remoteFiles[rel]
val known = knownStates[rel]
val decision = decide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known)
val decision = syncDecide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known, hasPriorSyncState)
when (decision) {
SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream ->
provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }
uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
}
local!!.sizeBytes
}.getOrElse { e ->
Timber.e(e, "Upload failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit
return@withPermit FileOutcome(failed = 1)
}
uploaded++
bytesTransferred += bytes
newStates += buildState(pair.id, rel, local!!, remote)
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
}
SyncDecision.DOWNLOAD -> {
val bytes = runCatching {
@@ -110,29 +116,31 @@ class SyncEngine @Inject constructor(
remote!!.sizeBytes
}.getOrElse { e ->
Timber.e(e, "Download failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit
return@withPermit FileOutcome(failed = 1)
}
downloaded++
bytesTransferred += bytes
newStates += buildState(pair.id, rel, null, remote)
// Read the actual local mtime written by the OS/SAF after download.
val localMtime = runCatching { accessor.lastModifiedMs(rel) }
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
?: System.currentTimeMillis()
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime), remoteAfterTransfer = remote))
}
SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel)
fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1)
}
SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel")
fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1)
}
SyncDecision.CONFLICT -> {
conflicts++
conflictDao.insert(SyncConflictEntity(
syncPairId = pair.id,
relativePath = rel,
@@ -144,81 +152,49 @@ class SyncEngine @Inject constructor(
detectedAt = Instant.now(),
))
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
FileOutcome(conflicts = 1)
}
SyncDecision.SKIP -> {
// Save state whenever both sides are present and state is absent or
// incomplete (post-upload null metadata). Without a baseline record,
// a subsequent local deletion would look like an unseen remote file
// and be re-downloaded instead of triggering DELETE_REMOTE.
val saveState = local != null && remote != null && (
known == null ||
known.remoteModifiedAt == null || known.localModifiedAt == null
)
if (saveState) {
FileOutcome(skipped = 1, newState = buildState(pair.id, rel, local, remoteAfterTransfer = remote))
} else {
FileOutcome(skipped = 1)
}
}
SyncDecision.SKIP -> skipped++
}
}
}
}.awaitAll()
}
fileStateDao.upsertAll(newStates)
return SyncResult(uploaded, downloaded, deleted, skipped, failed, conflicts, bytesTransferred)
fileStateDao.upsertAll(outcomes.mapNotNull { it.newState })
return SyncResult(
uploaded = outcomes.sumOf { it.uploaded },
downloaded = outcomes.sumOf { it.downloaded },
deleted = outcomes.sumOf { it.deleted },
skipped = outcomes.sumOf { it.skipped },
failedFiles = outcomes.sumOf { it.failed },
conflicts = outcomes.sumOf { it.conflicts },
bytesTransferred = outcomes.sumOf { it.bytesTransferred },
)
}
private fun decide(
direction: SyncDirection,
conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior,
local: LocalFileInfo?,
remote: RemoteFile?,
known: com.syncflow.data.db.entities.SyncFileStateEntity?,
): SyncDecision {
val localExists = local != null
val remoteExists = remote != null
val localChanged = known == null || (localExists && local!!.lastModifiedMs != known.localModifiedAt?.toEpochMilli())
val remoteChanged = known == null || (remoteExists && remote!!.etag != known.remoteEtag && remote.modifiedAt != known.remoteModifiedAt)
return when {
!localExists && !remoteExists -> SyncDecision.SKIP
localExists && !remoteExists -> when {
known == null -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL
else -> SyncDecision.SKIP
}
private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
val parts = rel.replace('\\', '/').split('/')
var currentPath = remotePairPath
for (part in parts.dropLast(1)) {
currentPath = "$currentPath/$part"
provider.createDirectory(currentPath).onFailure { e ->
Timber.w("MKCOL $currentPath: ${e.message}")
}
!localExists && remoteExists -> when {
known == null -> when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
localChanged && remoteChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD
SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD
SyncDirection.TWO_WAY -> when (conflictStrategy) {
ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD
ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModifiedMs ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_LARGEST -> if ((local?.sizeBytes ?: 0L) >= (remote?.sizeBytes ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_BOTH -> SyncDecision.CONFLICT
ConflictStrategy.ASK -> SyncDecision.CONFLICT
}
}
localChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
remoteChanged -> when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
else -> SyncDecision.SKIP
}
}
@@ -226,16 +202,16 @@ class SyncEngine @Inject constructor(
pairId: Long,
rel: String,
local: LocalFileInfo?,
remote: RemoteFile?,
) = com.syncflow.data.db.entities.SyncFileStateEntity(
remoteAfterTransfer: RemoteFile?,
) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null,
remoteModifiedAt = remote?.modifiedAt,
remoteSizeBytes = remote?.sizeBytes ?: 0L,
remoteEtag = remote?.etag,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
remoteSizeBytes = remoteAfterTransfer?.sizeBytes ?: 0L,
remoteEtag = remoteAfterTransfer?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
@@ -245,6 +221,92 @@ class SyncEngine @Inject constructor(
}
}
// Top-level so unit tests can call it directly without instantiating SyncEngine.
internal fun syncDecide(
direction: SyncDirection,
conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior,
local: LocalFileInfo?,
remote: RemoteFile?,
known: SyncFileStateEntity?,
hasPriorSyncState: Boolean = false,
): SyncDecision {
val localExists = local != null
val remoteExists = remote != null
// Treat null known timestamps as "not yet recorded" — don't treat as changed.
// The SKIP reconciliation pass will fill them in on the next sync.
val localChanged = known == null ||
(localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli())
val remoteChanged = known == null ||
(remoteExists && known.remoteModifiedAt != null &&
(remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt))
return when {
!localExists && !remoteExists -> SyncDecision.SKIP
localExists && !remoteExists -> when {
known == null -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL
else -> SyncDecision.SKIP
}
}
!localExists && remoteExists -> when {
known == null -> if (!hasPriorSyncState) {
// Initial sync: no history at all — remote files are new, download them.
when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
} else {
// Pair has been synced before but this file has no state record
// (e.g. uploaded before state-tracking was fixed). Treat the same
// as a known remote-deletion: apply mirror/keep behavior.
when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
localChanged && remoteChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD
SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD
SyncDirection.TWO_WAY -> when (conflictStrategy) {
ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD
ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModifiedMs ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_LARGEST -> if ((local?.sizeBytes ?: 0L) >= (remote?.sizeBytes ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_BOTH -> SyncDecision.CONFLICT
ConflictStrategy.ASK -> SyncDecision.CONFLICT
}
}
localChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
remoteChanged -> when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
else -> SyncDecision.SKIP
}
}
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
data class SyncResult(
@@ -1,5 +1,6 @@
package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -14,6 +15,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -29,10 +31,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
uri?.let { vm.update { copy(localPath = it.toString()) } }
uri?.let {
context.contentResolver.takePersistableUriPermission(
it,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = it.toString()) }
}
}
if (showRemoteBrowser && s.selectedAccountId != -1L) {
@@ -1,9 +1,11 @@
package com.syncflow.ui.home
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
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.*
@@ -11,11 +13,12 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -58,49 +61,107 @@ private fun SyncPairCard(
onSync: () -> Unit,
onToggle: () -> Unit,
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
val accentColor = pair.lastSyncResult.accentColor
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(pair.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(2.dp))
Text(
pair.localPath.takeLast(40),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = pair.isEnabled, onCheckedChange = { onToggle() })
}
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
Row(modifier = Modifier.fillMaxWidth()) {
// Colored left accent bar
Box(
modifier = Modifier
.width(3.dp)
.height(IntrinsicSize.Min)
.defaultMinSize(minHeight = 80.dp),
) {
StatusChip(pair.lastSyncResult)
if (pair.pendingConflicts > 0) {
AssistChip(
onClick = {},
label = { Text("${pair.pendingConflicts} conflicts") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = accentColor,
) {}
}
}
Column(modifier = Modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
pair.name,
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(2.dp))
val localShortName = pair.localPath.toDisplayPath()
Text(
localShortName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Text(
pair.remotePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = pair.isEnabled,
onCheckedChange = { onToggle() },
)
}
Spacer(Modifier.weight(1f))
pair.lastSyncAt?.let { at ->
Text(
at.formatRelative(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp))
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusPill(pair.lastSyncResult)
if (pair.pendingConflicts > 0) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(Icons.Default.Warning, null, Modifier.size(12.dp), tint = MaterialTheme.colorScheme.error)
Text(
"${pair.pendingConflicts} conflicts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
Spacer(Modifier.weight(1f))
pair.lastSyncAt?.let { at ->
Text(
at.toRelativeString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp))
}
}
}
}
@@ -108,21 +169,33 @@ private fun SyncPairCard(
}
@Composable
private fun StatusChip(status: SyncStatus) {
val (icon, label, color) = when (status) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber, "Partial", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
private fun StatusPill(status: SyncStatus) {
val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
SyncStatus.IDLE -> Pair(Icons.Outlined.Circle, "Idle")
}
val containerColor = status.accentColor
val contentColor = when (status) {
SyncStatus.IDLE -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.surface
}
Surface(
shape = RoundedCornerShape(50),
color = containerColor.copy(alpha = 0.15f),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(icon, null, Modifier.size(12.dp), tint = containerColor)
Text(label, style = MaterialTheme.typography.labelSmall, color = containerColor)
}
}
AssistChip(
onClick = {},
label = { Text(label) },
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(containerColor = color),
)
}
@Composable
@@ -132,15 +205,49 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Outlined.CloudSync, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary)
Icon(
Icons.Outlined.CloudSync,
null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
)
Spacer(Modifier.height(16.dp))
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium)
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text("Tap + to create your first sync", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"Tap + to create your first sync",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAdd) { Text("Add Sync Pair") }
FilledTonalButton(onClick = onAdd) { Text("Add Sync Pair") }
}
}
private val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
private fun Instant.formatRelative(): String = fmt.format(atZone(ZoneId.systemDefault()))
private val SyncStatus.accentColor: Color
@Composable get() = when (this) {
SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary
SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary
SyncStatus.FAILED -> MaterialTheme.colorScheme.error
SyncStatus.CONFLICT,
SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
}
private fun String.toDisplayPath(): String {
// For SAF content:// URIs, decode the last path segment (e.g. primary%3ASyncFlowTest → SyncFlowTest)
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
}
private fun Instant.toRelativeString(): String {
val diff = Duration.between(this, Instant.now()).abs()
return when {
diff.toMinutes() < 1 -> "Just now"
diff.toMinutes() < 60 -> "${diff.toMinutes()} min ago"
diff.toHours() < 24 -> "${diff.toHours()} hr ago"
diff.toDays() == 1L -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.format(atZone(ZoneId.systemDefault()))
}
}
@@ -23,7 +23,7 @@ class HomeViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun triggerSync(pair: SyncPairEntity) {
val req = SyncWorker.buildOneTimeRequest(pair.id, pair.wifiOnly, pair.chargingOnly)
val req = SyncWorker.buildOneTimeRequest(pair.id, wifiOnly = false, chargingOnly = false)
workManager.enqueue(req)
}
@@ -6,8 +6,15 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -19,8 +26,11 @@ import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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.settings.SettingsScreen
import kotlinx.coroutines.launch
@@ -38,11 +48,26 @@ fun MainShell(
Scaffold(
topBar = {
TopAppBar(
title = { Text("SyncFlow", fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
CenterAlignedTopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.ic_sync),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
"SyncFlow",
style = MaterialTheme.typography.titleLarge,
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars),
)
},
bottomBar = {
@@ -81,6 +106,8 @@ fun MainShell(
text = { Text("Add Sync") },
icon = { Icon(Icons.Default.Add, null) },
onClick = onAddPair,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
}
},
@@ -48,6 +48,7 @@ fun SyncFlowNavGraph(navController: NavHostController) {
) {
PairDetailScreen(
onBack = { navController.popBackStack() },
onEdit = { id -> navController.navigate(Screen.AddPair.route(id)) },
onConflicts = { id -> navController.navigate(Screen.Conflicts.route(id)) },
)
}
@@ -3,16 +3,24 @@ package com.syncflow.ui.pairdetail
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -21,6 +29,7 @@ import java.time.format.FormatStyle
@Composable
fun PairDetailScreen(
onBack: () -> Unit,
onEdit: (Long) -> Unit,
onConflicts: (Long) -> Unit,
vm: PairDetailViewModel = hiltViewModel(),
) {
@@ -35,7 +44,10 @@ fun PairDetailScreen(
title = { Text("Delete sync pair?") },
text = { Text("This removes the pair and all sync history. Files are NOT deleted.") },
confirmButton = {
TextButton(onClick = { vm.delete(); onBack() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
TextButton(
onClick = { vm.delete(); onBack() },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("Delete")
}
},
@@ -49,6 +61,7 @@ fun PairDetailScreen(
title = { Text(pair?.name ?: "") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
},
@@ -58,8 +71,12 @@ fun PairDetailScreen(
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
pair?.let { p -> StatusBanner(p) }
}
item {
pair?.let { p ->
InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name)
@@ -83,8 +100,22 @@ fun PairDetailScreen(
}
item {
Text("Activity", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp),
) {
Box(
modifier = Modifier
.width(3.dp)
.height(16.dp)
.clip(RoundedCornerShape(2.dp)),
) {
Surface(color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxSize()) {}
}
Spacer(Modifier.width(8.dp))
Text("Activity", style = MaterialTheme.typography.titleSmall)
}
HorizontalDivider(modifier = Modifier.padding(top = 4.dp))
}
if (events.isEmpty()) {
@@ -103,24 +134,84 @@ fun PairDetailScreen(
}
@Composable
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath)
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
InfoRow(Icons.Default.SwapHoriz, "Direction", direction)
InfoRow(Icons.Default.Schedule, "Schedule", schedule)
private fun StatusBanner(pair: SyncPairEntity) {
val (icon, label, containerColor) = when (pair.lastSyncResult) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
}
Surface(
color = containerColor,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.titleMedium)
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath.toDisplayPath())
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
Row {
InfoRow(Icons.Default.SwapHoriz, "Direction", direction, modifier = Modifier.weight(1f))
InfoRow(Icons.Default.Schedule, "Schedule", schedule, modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun InfoRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(28.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
icon,
null,
Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.width(8.dp))
Text("$label: ", style = MaterialTheme.typography.labelMedium)
Text(value, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f))
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall)
}
}
}
@@ -128,38 +219,65 @@ private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label
private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
val (icon, tint) = eventIcon(event.eventType)
val dotColor = eventColor(event.eventType)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, Modifier.size(16.dp), tint = tint)
Spacer(Modifier.width(8.dp))
// 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)
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(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(fmt.format(event.timestamp.atZone(zone)), 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 eventIcon(type: SyncEventType): Pair<androidx.compose.ui.graphics.vector.ImageVector, androidx.compose.ui.graphics.Color> {
val green = MaterialTheme.colorScheme.primary
val red = MaterialTheme.colorScheme.error
val orange = MaterialTheme.colorScheme.tertiary
val grey = MaterialTheme.colorScheme.onSurfaceVariant
return when (type) {
SyncEventType.SYNC_STARTED -> Pair(Icons.Default.PlayArrow, green)
SyncEventType.SYNC_COMPLETED -> Pair(Icons.Default.CheckCircle, green)
SyncEventType.SYNC_FAILED -> Pair(Icons.Default.Error, red)
SyncEventType.FILE_UPLOADED -> Pair(Icons.Default.Upload, green)
SyncEventType.FILE_DOWNLOADED -> Pair(Icons.Default.Download, green)
SyncEventType.FILE_DELETED -> Pair(Icons.Default.Delete, orange)
SyncEventType.FILE_SKIPPED -> Pair(Icons.Default.SkipNext, grey)
SyncEventType.CONFLICT_DETECTED -> Pair(Icons.Default.Warning, orange)
SyncEventType.CONFLICT_RESOLVED -> Pair(Icons.Default.CheckCircle, green)
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 {
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
}
private fun Instant.toRelativeString(): String {
val diff = Duration.between(this, Instant.now()).abs()
return when {
diff.toMinutes() < 1 -> "Just now"
diff.toMinutes() < 60 -> "${diff.toMinutes()} min ago"
diff.toHours() < 24 -> "${diff.toHours()} hr ago"
diff.toDays() == 1L -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.format(atZone(ZoneId.systemDefault()))
}
}
@@ -36,7 +36,7 @@ class PairDetailViewModel @Inject constructor(
fun syncNow() {
val p = pair.value ?: return
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, p.wifiOnly, p.chargingOnly))
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false))
}
fun delete() {
@@ -1,14 +1,17 @@
package com.syncflow.ui.settings
import androidx.compose.foundation.BorderStroke
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.draw.clip
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.CloudAccountEntity
@@ -45,15 +48,18 @@ fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Cloud Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
SectionHeader(title = "Cloud Accounts")
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
FilledTonalButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add Account")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
if (accounts.isEmpty()) {
@@ -63,9 +69,22 @@ fun SettingsScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.CloudOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("No accounts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Add a cloud account to start syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(
Icons.Default.CloudOff,
null,
Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Text(
"No accounts yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Add a cloud account to start syncing",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
@@ -80,40 +99,102 @@ fun SettingsScreen(
}
item {
Spacer(Modifier.height(16.dp))
Text("Security", style = MaterialTheme.typography.titleMedium)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
Spacer(Modifier.height(8.dp))
SectionHeader(title = "Security")
Spacer(Modifier.height(4.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.weight(1f)) {
Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium)
Text(
"Require biometrics when returning to app",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium)
Text(
"Require biometrics when returning to app",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) })
}
Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) })
}
}
item {
Spacer(Modifier.height(16.dp))
Text("About", style = MaterialTheme.typography.titleMedium)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text("SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.", style = MaterialTheme.typography.bodySmall)
Text("Open source. No ads. No tracking.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
SectionHeader(title = "About")
Spacer(Modifier.height(4.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.",
style = MaterialTheme.typography.bodySmall,
)
Text(
"Open source. No ads. No tracking.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.width(3.dp)
.height(16.dp)
.clip(RoundedCornerShape(2.dp)),
) {
Surface(color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxSize()) {}
}
Spacer(Modifier.width(8.dp))
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
@Composable
private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(providerIcon(acct.providerType), null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
// Icon with primaryContainer background
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
providerIcon(acct.providerType),
null,
Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(acct.displayName, style = MaterialTheme.typography.bodyMedium)
@@ -138,22 +219,22 @@ private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) {
private fun providerIcon(type: ProviderType) = when (type) {
ProviderType.GOOGLE_DRIVE -> Icons.Default.Cloud
ProviderType.DROPBOX -> Icons.Default.CloudQueue
ProviderType.ONEDRIVE -> Icons.Default.CloudDone
ProviderType.WEBDAV -> Icons.Default.Storage
ProviderType.SFTP -> Icons.Default.Terminal
ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle
ProviderType.OWNCLOUD -> Icons.Default.CloudCircle
ProviderType.SFTPGO -> Icons.Default.Storage
ProviderType.DROPBOX -> Icons.Default.CloudQueue
ProviderType.ONEDRIVE -> Icons.Default.CloudDone
ProviderType.WEBDAV -> Icons.Default.Storage
ProviderType.SFTP -> Icons.Default.Terminal
ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle
ProviderType.OWNCLOUD -> Icons.Default.CloudCircle
ProviderType.SFTPGO -> Icons.Default.Storage
}
private fun friendlyProviderName(type: ProviderType) = when (type) {
ProviderType.GOOGLE_DRIVE -> "Google Drive"
ProviderType.DROPBOX -> "Dropbox"
ProviderType.ONEDRIVE -> "OneDrive"
ProviderType.WEBDAV -> "WebDAV"
ProviderType.SFTP -> "SFTP"
ProviderType.NEXTCLOUD -> "Nextcloud"
ProviderType.OWNCLOUD -> "ownCloud"
ProviderType.SFTPGO -> "SFTPGo"
ProviderType.DROPBOX -> "Dropbox"
ProviderType.ONEDRIVE -> "OneDrive"
ProviderType.WEBDAV -> "WebDAV"
ProviderType.SFTP -> "SFTP"
ProviderType.NEXTCLOUD -> "Nextcloud"
ProviderType.OWNCLOUD -> "ownCloud"
ProviderType.SFTPGO -> "SFTPGo"
}
@@ -2,8 +2,27 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color
val SyncBlue = Color(0xFF2196F3)
val SyncGreen = Color(0xFF4CAF50)
val SyncOrange = Color(0xFFFF9800)
val SyncRed = Color(0xFFF44336)
val SyncPurple = Color(0xFF9C27B0)
// Primary — indigo
val Indigo600 = Color(0xFF4F46E5)
val Indigo900 = Color(0xFF312E81)
val Indigo100 = Color(0xFFE0E7FF)
val Indigo50 = Color(0xFFEEF2FF)
// Secondary — teal
val Teal600 = Color(0xFF0D9488)
val Teal100 = Color(0xFFCCFBF1)
// Tertiary — amber
val Amber500 = Color(0xFFF59E0B)
val Amber100 = Color(0xFFFEF3C7)
// Neutrals
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate600 = Color(0xFF475569)
val Slate900 = Color(0xFF0F172A)
// Semantic
val GreenSuccess = Color(0xFF16A34A)
val RedError = Color(0xFFDC2626)
@@ -1,50 +1,77 @@
package com.syncflow.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme(
primary = SyncBlue,
onPrimary = androidx.compose.ui.graphics.Color.White,
secondary = SyncGreen,
tertiary = SyncPurple,
primary = Indigo600,
onPrimary = Color.White,
primaryContainer = Indigo100,
onPrimaryContainer = Indigo900,
secondary = Teal600,
onSecondary = Color.White,
secondaryContainer = Teal100,
tertiary = Amber500,
tertiaryContainer = Amber100,
background = Slate50,
surface = Color.White,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
error = RedError,
errorContainer = Color(0xFFFEE2E2),
outline = Slate200,
)
private val DarkColors = darkColorScheme(
primary = SyncBlue,
secondary = SyncGreen,
tertiary = SyncPurple,
primary = Color(0xFF818CF8),
onPrimary = Indigo900,
primaryContainer = Color(0xFF3730A3),
onPrimaryContainer = Indigo100,
secondary = Color(0xFF2DD4BF),
onSecondary = Color(0xFF003731),
secondaryContainer = Color(0xFF00504A),
tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E),
background = Color(0xFF0F0F1A),
surface = Color(0xFF1A1A2E),
surfaceVariant = Color(0xFF252538),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
errorContainer = Color(0xFF7F1D1D),
outline = Color(0xFF334155),
)
private val AppTypography = Typography(
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp, letterSpacing = (-0.5).sp),
titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp, letterSpacing = (-0.25).sp),
titleSmall = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp, letterSpacing = 0.sp),
labelMedium = TextStyle(fontWeight = FontWeight.Medium, fontSize = 12.sp, letterSpacing = 0.1.sp),
labelSmall = TextStyle(fontWeight = FontWeight.Medium, fontSize = 11.sp, letterSpacing = 0.1.sp),
)
@Composable
fun SyncFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> DarkColors
else -> LightColors
}
val colorScheme = if (darkTheme) DarkColors else LightColors
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = android.graphics.Color.TRANSPARENT
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content)
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
@@ -2,12 +2,15 @@ package com.syncflow.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.*
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
@@ -33,44 +36,116 @@ class SyncWorker @AssistedInject constructor(
val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
return try {
val domainPair = pair.toDomain()
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 summary = buildString {
if (result.uploaded > 0) append("${result.uploaded} ")
if (result.downloaded > 0) append("${result.downloaded} ")
if (result.deleted > 0) append("🗑${result.deleted} ")
if (result.conflicts > 0) append("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}.trim().ifEmpty { "Up to date" }
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name} — Synced",
text = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success()
} catch (e: Exception) {
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()
}
}
private fun buildForegroundInfo(progress: String): ForegroundInfo {
val channelId = "sync_channel"
private fun ensureChannels() {
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW))
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("SyncFlow")
.setContentText(progress)
if (nm.getNotificationChannel(CHANNEL_PROGRESS) == null)
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"
})
}
private fun buildForegroundInfo(pairName: String, status: String): ForegroundInfo {
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)
.setContentIntent(tapIntent)
.setOngoing(true)
.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)
} else {
else
ForegroundInfo(NOTIFICATION_ID, notification)
}
}
private fun notify(id: Int, channelId: String, title: String, text: String, priority: Int) {
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
nm.notify(id, NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle(title)
.setContentText(text)
.setSmallIcon(R.drawable.ic_sync)
.setPriority(priority)
.setContentIntent(tapIntent)
.setAutoCancel(true)
.build())
}
companion object {
const val KEY_PAIR_ID = "pair_id"
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 {
val constraints = Constraints.Builder()
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="linear"
android:angle="135"
android:startColor="#312E81"
android:endColor="#6366F1"/>
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24">
<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"/>
<path
android:fillColor="#FFFFFF"
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"/>
</vector>
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+2 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,164 @@
package com.syncflow.domain.sync
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.domain.model.*
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
class SyncDecideTest {
private val MS = 1_716_000_000_000L
private val MS2 = MS + 5_000L
private fun local(ms: Long = MS, size: Long = 100L) = LocalFileInfo("test.txt", size, ms)
private fun remote(ms: Long = MS, etag: String? = "abc", size: Long = 100L) =
RemoteFile(
path = "path/test.txt", name = "test.txt", isDirectory = false,
sizeBytes = size, modifiedAt = Instant.ofEpochMilli(ms),
etag = etag, mimeType = null,
)
private fun state(localMs: Long? = MS, remoteMs: Long? = MS, etag: String? = "abc") =
SyncFileStateEntity(
syncPairId = 1L, relativePath = "test.txt",
localModifiedAt = localMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = 100L, localHash = null,
remoteModifiedAt = remoteMs?.let { Instant.ofEpochMilli(it) },
remoteSizeBytes = 100L, remoteEtag = etag,
lastSyncedAt = Instant.now(), syncedHash = null,
)
private fun decide(
local: LocalFileInfo?, remote: RemoteFile?, known: SyncFileStateEntity? = null,
dir: SyncDirection = SyncDirection.TWO_WAY,
conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
delete: DeleteBehavior = DeleteBehavior.MIRROR,
hasPriorState: Boolean = known != null,
) = syncDecide(dir, conflict, delete, local, remote, known, hasPriorState)
// ── first sync (no known state) ───────────────────────────────────────────
@Test fun `first sync both exist local newer uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS)))
@Test fun `first sync both exist remote newer downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2)))
@Test fun `first sync local only TWO_WAY uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(), null))
@Test fun `first sync remote only TWO_WAY downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(null, remote()))
// ── after upload: remote metadata null in state ───────────────────────────
@Test fun `second sync after upload remote metadata null skips`() {
// State saved after upload: local mtime known, remote unknown (null).
val known = state(localMs = MS, remoteMs = null, etag = null)
// Remote listing shows a new mtime (server assigned), but we treat null as "no change".
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS2), known))
}
@Test fun `after upload local changed again re-uploads`() {
val known = state(localMs = MS, remoteMs = null, etag = null)
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS2), known))
}
// ── after download: local mtime recorded ─────────────────────────────────
@Test fun `second sync fully recorded skips`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS, etag = "abc"), known))
}
@Test fun `remote changed after download downloads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2, etag = "xyz"), known))
}
@Test fun `local changed after download uploads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS, etag = "abc"), known))
}
// ── epoch-millis precision ────────────────────────────────────────────────
@Test fun `same millisecond timestamp treated as unchanged`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.SKIP,
decide(local(ts), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `1ms difference detected as local change`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.UPLOAD,
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `epoch-second stored value differs from millis comparison`() {
// If we stored 1716393136 (seconds) and compare to 1716393136000 (millis) they differ →
// This was the original bug — now we store millis so they should match.
val ms = 1_716_393_136_000L // exact second boundary, no sub-second component
assertEquals(SyncDecision.SKIP,
decide(local(ms), remote(ms, etag = "e"), state(localMs = ms, remoteMs = ms, etag = "e")))
}
// ── delete behaviour ──────────────────────────────────────────────────────
@Test fun `local exists remote deleted TWO_WAY MIRROR deletes local`() =
assertEquals(SyncDecision.DELETE_LOCAL, decide(local(), null, state(), delete = DeleteBehavior.MIRROR))
@Test fun `local exists remote deleted KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(local(), null, state(), delete = DeleteBehavior.KEEP))
@Test fun `remote deleted UPLOAD_ONLY skips local deletion`() =
assertEquals(SyncDecision.SKIP,
decide(local(), null, state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `local deleted TWO_WAY MIRROR deletes remote`() =
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), state(), delete = DeleteBehavior.MIRROR))
@Test fun `local deleted TWO_WAY KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(null, remote(), state(), delete = DeleteBehavior.KEEP))
@Test fun `local deleted DOWNLOAD_ONLY skips remote deletion`() =
assertEquals(SyncDecision.SKIP,
decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY))
// ── local deleted, no state record (uploaded in broken version) ──────────
@Test fun `local deleted no known state but pair has prior history deletes remote`() =
// hasPriorState=true means the pair has been synced before; file has no state
// because it was uploaded when getFileMetadata was broken. Should still mirror deletion.
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(), known = null, delete = DeleteBehavior.MIRROR, hasPriorState = true))
@Test fun `initial sync remote only no prior state downloads`() =
assertEquals(SyncDecision.DOWNLOAD,
decide(null, remote(), known = null, hasPriorState = false))
// ── first-seen SKIP saves baseline so later deletions are detected ────────
@Test fun `first sync both exist same mtime uploads local wins tie`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS), remote(MS, etag = "abc")))
@Test fun `after first-seen skip local deleted deletes remote`() {
// Simulate: first sync saw both sides identical → SKIP (state saved by engine).
// Then local file deleted → known is now present → DELETE_REMOTE.
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(MS, etag = "abc"), known, delete = DeleteBehavior.MIRROR))
}
// ── directions ────────────────────────────────────────────────────────────
@Test fun `UPLOAD_ONLY ignores remote changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS), remote(MS2, etag = "new"), state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `DOWNLOAD_ONLY ignores local changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS2), remote(MS, etag = "abc"), state(), dir = SyncDirection.DOWNLOAD_ONLY))
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.1
VERSION_CODE=2
VERSION_NAME=1.0.14
VERSION_CODE=15