Compare commits

..

9 Commits

Author SHA1 Message Date
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
amir d647e86e88 v1.0.1 — Fix SAF content URI access and foreground service type
- SyncEngine now handles content:// URIs via ContentResolver/DocumentsContract
  alongside regular file paths; fixes ENOENT on all SAF-backed sync pairs
- ForegroundInfo now passes FOREGROUND_SERVICE_TYPE_DATA_SYNC on API 29+
- Declare foregroundServiceType=dataSync on WorkManager service in manifest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:14:51 +00:00
amir c54730d3fb Fix R8 dontwarn rules, WorkManager init, and release signing config
- Add dontwarn for errorprone annotations (Tink) and sun.security.x509
- Remove WorkManagerInitializer from manifest (app uses on-demand init)
- Wire signingConfigs.release from local.properties

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:34:30 +00:00
19 changed files with 710 additions and 166 deletions
+15
View File
@@ -13,6 +13,11 @@ val versionProps = Properties().apply {
load(rootProject.file("version.properties").inputStream()) load(rootProject.file("version.properties").inputStream())
} }
val localProps = Properties().apply {
val f = rootProject.file("local.properties")
if (f.exists()) load(f.inputStream())
}
android { android {
namespace = "com.syncflow" namespace = "com.syncflow"
compileSdk = 34 compileSdk = 34
@@ -31,11 +36,21 @@ android {
manifestPlaceholders["MSAL_REDIRECT_URI"] = "msauth://com.syncflow/YOUR_BASE64_SIGNATURE" manifestPlaceholders["MSAL_REDIRECT_URI"] = "msauth://com.syncflow/YOUR_BASE64_SIGNATURE"
} }
signingConfigs {
create("release") {
storeFile = localProps["KEYSTORE_PATH"]?.toString()?.let { file(it) }
storePassword = localProps["KEYSTORE_PASSWORD"]?.toString()
keyAlias = localProps["KEY_ALIAS"]?.toString()
keyPassword = localProps["KEY_PASSWORD"]?.toString()
}
}
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
} }
} }
+2
View File
@@ -6,3 +6,5 @@
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-dontwarn org.conscrypt.** -dontwarn org.conscrypt.**
-dontwarn org.openjsse.** -dontwarn org.openjsse.**
-dontwarn com.google.errorprone.annotations.**
-dontwarn sun.security.x509.X509Key
+18
View File
@@ -68,5 +68,23 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -7,17 +7,28 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -36,6 +47,7 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var appPreferences: AppPreferences @Inject lateinit var appPreferences: AppPreferences
private var isLocked by mutableStateOf(false) private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
@@ -47,13 +59,18 @@ class MainActivity : AppCompatActivity() {
SyncFlowNavGraph(rememberNavController()) SyncFlowNavGraph(rememberNavController())
} }
if (isLocked) { if (isLocked) {
LockOverlay() LockOverlay(
LaunchedEffect(Unit) { showRetry = showRetry,
showBiometricPrompt(onSuccess = { isLocked = false }) onRetry = { triggerBiometric() },
)
} }
} }
} }
} }
override fun onResume() {
super.onResume()
if (isLocked) triggerBiometric()
} }
override fun onStop() { override fun onStop() {
@@ -62,51 +79,86 @@ class MainActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) { if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) {
isLocked = true isLocked = true
showRetry = false
} }
} }
} }
private fun canAuthenticate(): Boolean { private fun triggerBiometric() {
val authenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) showRetry = false
BIOMETRIC_STRONG or DEVICE_CREDENTIAL val authenticators = bestAuthenticators()
else
BIOMETRIC_STRONG
return BiometricManager.from(this).canAuthenticate(authenticators) ==
BiometricManager.BIOMETRIC_SUCCESS
}
private fun showBiometricPrompt(onSuccess: () -> Unit) {
val executor = ContextCompat.getMainExecutor(this) val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() { val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { 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) { val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow") .setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue") .setSubtitle("Use fingerprint or PIN")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) .setAllowedAuthenticators(authenticators)
.build() .build()
} else { } else {
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow") .setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue") .setSubtitle("Use fingerprint")
.setNegativeButtonText("Cancel") .setNegativeButtonText("Cancel")
.build() .build()
} }
prompt.authenticate(promptInfo) 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 @Composable
private fun LockOverlay() { private fun LockOverlay(showRetry: Boolean, onRetry: () -> Unit) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center, 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 import java.time.Instant
class DbConverters { class DbConverters {
@TypeConverter fun fromInstant(v: Instant?): Long? = v?.epochSecond @TypeConverter fun fromInstant(v: Instant?): Long? = v?.toEpochMilli()
@TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochSecond(it) } @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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.syncflow.data.db.entities.* import com.syncflow.data.db.entities.*
@Database( @Database(
@@ -13,11 +15,21 @@ import com.syncflow.data.db.entities.*
SyncConflictEntity::class, SyncConflictEntity::class,
SyncEventEntity::class, SyncEventEntity::class,
], ],
version = 2, version = 3,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DbConverters::class) @TypeConverters(DbConverters::class)
abstract class SyncDatabase : RoomDatabase() { 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 cloudAccountDao(): CloudAccountDao
abstract fun syncPairDao(): SyncPairDao abstract fun syncPairDao(): SyncPairDao
abstract fun syncFileStateDao(): SyncFileStateDao 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() val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (resp.code != 207) throw Exception("HTTP ${resp.code}") 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") .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('/')}" 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>() val results = mutableListOf<RemoteFile>()
try { try {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance()
@@ -192,7 +195,7 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
eventType = parser.next() eventType = parser.next()
} }
} catch (_: Exception) {} } 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 { private fun parseHttpDate(value: String): Instant = try {
@@ -21,9 +21,8 @@ object AppModule {
@Provides @Singleton @Provides @Singleton
fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase = fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase =
Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db") 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) .fallbackToDestructiveMigrationFrom(1)
.addMigrations(SyncDatabase.MIGRATION_2_3)
.build() .build()
@Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao() @Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao()
@@ -0,0 +1,218 @@
package com.syncflow.domain.sync
import android.content.ContentResolver
import android.net.Uri
import android.provider.DocumentsContract
import com.syncflow.domain.model.SyncPair
import java.io.File
import java.io.InputStream
import java.io.OutputStream
data class LocalFileInfo(val relativePath: String, val sizeBytes: Long, val lastModifiedMs: Long)
sealed class LocalAccessor {
abstract fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo>
abstract fun openInputStream(relativePath: String): InputStream?
abstract fun createOutputStream(relativePath: String): OutputStream?
abstract fun delete(relativePath: String): Boolean
abstract fun lastModifiedMs(relativePath: String): Long
// ── java.io.File backend (regular /storage/... paths) ────────────────────
class JavaFile(private val root: File) : LocalAccessor() {
override fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo> {
if (!root.exists()) return emptyMap()
val includeExts = pair.includeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val excludeExts = pair.excludeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val minBytes = pair.minFileSizeKb * 1024L
val maxBytes = if (pair.maxFileSizeKb > 0) pair.maxFileSizeKb * 1024L else Long.MAX_VALUE
return root.walkTopDown()
.onEnter { dir -> pair.recursive || dir == root }
.filter { it.isFile }
.filter { f -> !pair.skipHiddenFiles || !f.name.startsWith('.') }
.filter { f -> pair.excludePatterns.none { pat -> f.name.matches(globToRegex(pat)) } }
.filter { f ->
val ext = f.extension.lowercase()
(includeExts.isEmpty() || ext in includeExts) && ext !in excludeExts
}
.filter { f -> f.length() in minBytes..maxBytes }
.associate { f ->
val rel = f.relativeTo(root).path
rel to LocalFileInfo(rel, f.length(), f.lastModified())
}
}
override fun openInputStream(relativePath: String): InputStream =
File(root, relativePath).inputStream()
override fun createOutputStream(relativePath: String): OutputStream {
val dest = File(root, relativePath)
dest.parentFile?.mkdirs()
return dest.outputStream()
}
override fun delete(relativePath: String): Boolean = File(root, relativePath).delete()
override fun lastModifiedMs(relativePath: String): Long = File(root, relativePath).lastModified()
}
// ── SAF backend (content:// tree URIs from Storage Access Framework) ─────
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)
}
private fun cursorWalk(childrenUri: Uri, base: String, pair: SyncPair): Map<String, LocalFileInfo> {
val result = mutableMapOf<String, LocalFileInfo>()
val includeExts = pair.includeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val excludeExts = pair.excludeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val minBytes = pair.minFileSizeKb * 1024L
val maxBytes = if (pair.maxFileSizeKb > 0) pair.maxFileSizeKb * 1024L else Long.MAX_VALUE
val cursor = resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
),
null, null, null,
) ?: return result
cursor.use {
while (it.moveToNext()) {
val docId = it.getString(0) ?: continue
val name = it.getString(1) ?: continue
val mime = it.getString(2) ?: continue
val size = it.getLong(3)
val modified = it.getLong(4)
val rel = if (base.isEmpty()) name else "$base/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
if (pair.recursive) {
val subUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, docId)
result.putAll(cursorWalk(subUri, rel, pair))
}
} else {
if (pair.skipHiddenFiles && name.startsWith('.')) continue
if (pair.excludePatterns.any { pat -> name.matches(globToRegex(pat)) }) continue
val ext = name.substringAfterLast('.', "").lowercase()
if (includeExts.isNotEmpty() && ext !in includeExts) continue
if (ext in excludeExts) continue
if (size !in minBytes..maxBytes) continue
result[rel] = LocalFileInfo(rel, size, modified)
docIdCache[rel] = docId
}
}
}
return result
}
override fun openInputStream(relativePath: String): InputStream? {
val docUri = docIdCache[relativePath]
?.let { DocumentsContract.buildDocumentUriUsingTree(treeUri, it) }
?: findDocUri(relativePath)
?: return null
return resolver.openInputStream(docUri)
}
override fun createOutputStream(relativePath: String): OutputStream? {
val parts = relativePath.replace('\\', '/').split('/')
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
for (i in 0 until parts.size - 1) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
currentId = findChildId(childrenUri, parts[i]) ?: run {
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
val newDir = DocumentsContract.createDocument(
resolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, parts[i]
) ?: return null
DocumentsContract.getDocumentId(newDir)
}
}
val fileName = parts.last()
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
// Delete existing to allow overwrite
findChildId(childrenUri, fileName)?.let { existingId ->
DocumentsContract.deleteDocument(
resolver,
DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
)
}
val newUri = DocumentsContract.createDocument(
resolver, parentUri, "application/octet-stream", fileName
) ?: return null
return resolver.openOutputStream(newUri)
}
override fun delete(relativePath: String): Boolean {
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 {
val docUri = findDocUri(relativePath) ?: return 0L
val cursor = resolver.query(
docUri,
arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED),
null, null, null,
) ?: return 0L
return cursor.use { if (it.moveToFirst()) it.getLong(0) else 0L }
}
private fun findDocUri(relativePath: String): Uri? {
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
for (part in relativePath.replace('\\', '/').split('/')) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
currentId = findChildId(childrenUri, part) ?: return null
}
return DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
}
private fun findChildId(childrenUri: Uri, name: String): String? {
val cursor = resolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
),
null, null, null,
) ?: return null
return cursor.use {
while (it.moveToNext()) {
if (it.getString(1) == name) return@use it.getString(0)
}
null
}
}
}
}
internal fun globToRegex(pat: String): Regex =
Regex(pat.replace(".", "\\.").replace("*", ".*").replace("?", "."), RegexOption.IGNORE_CASE)
@@ -1,5 +1,7 @@
package com.syncflow.domain.sync package com.syncflow.domain.sync
import android.content.Context
import android.net.Uri
import com.syncflow.data.db.SyncConflictDao import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncFileStateDao import com.syncflow.data.db.SyncFileStateDao
@@ -15,12 +17,12 @@ import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.model.SyncEventType import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncPair import com.syncflow.domain.model.SyncPair
import com.syncflow.domain.model.SyncStatus import com.syncflow.domain.model.SyncStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.security.MessageDigest
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -29,6 +31,7 @@ class SyncEngine @Inject constructor(
private val fileStateDao: SyncFileStateDao, private val fileStateDao: SyncFileStateDao,
private val conflictDao: SyncConflictDao, private val conflictDao: SyncConflictDao,
private val eventDao: SyncEventDao, private val eventDao: SyncEventDao,
@ApplicationContext private val context: Context,
) { ) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult { suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING) syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
@@ -53,127 +56,201 @@ class SyncEngine @Inject constructor(
} }
} }
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult { private fun makeAccessor(localPath: String): LocalAccessor =
val localRoot = File(pair.localPath) if (localPath.startsWith("content://"))
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } LocalAccessor.Saf(Uri.parse(localPath), context.contentResolver)
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow().associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } else
val localFiles = localRoot.walkFiles(pair) LocalAccessor.JavaFile(File(localPath))
var uploaded = 0; var downloaded = 0; var deleted = 0; var skipped = 0; var failed = 0; var conflicts = 0 private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult {
var bytesTransferred = 0L val accessor = makeAccessor(pair.localPath)
val newStates = mutableListOf<SyncFileStateEntity>() val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet() val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
// Fan out with bounded parallelism
val semaphore = Semaphore(4) 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 -> allPaths.map { rel ->
async { async {
semaphore.withPermit { semaphore.withPermit {
val local = localFiles[rel] val local = localFiles[rel]
val remote = remoteFiles[rel] val remote = remoteFiles[rel]
val known = knownStates[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) { when (decision) {
SyncDecision.UPLOAD -> { SyncDecision.UPLOAD -> {
val file = File(localRoot, rel) var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching { val bytes = runCatching {
file.inputStream().use { stream -> ensureRemoteDirs(provider, pair.remotePath, rel)
provider.uploadFile(stream, "${pair.remotePath}/$rel", file.length()) { } accessor.openInputStream(rel)?.use { stream ->
uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
} }
file.length() local!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
Timber.e(e, "Upload failed: $rel") Timber.e(e, "Upload failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0) 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, file, remote)
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
} }
SyncDecision.DOWNLOAD -> { SyncDecision.DOWNLOAD -> {
val dest = File(localRoot, rel).also { it.parentFile?.mkdirs() }
val bytes = runCatching { val bytes = runCatching {
dest.outputStream().use { stream -> accessor.createOutputStream(rel)?.use { stream ->
provider.downloadFile("${pair.remotePath}/$rel", stream) { } provider.downloadFile("${pair.remotePath}/$rel", stream) { }
} }
remote!!.sizeBytes remote!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
Timber.e(e, "Download failed: $rel") Timber.e(e, "Download failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0) logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit return@withPermit FileOutcome(failed = 1)
} }
downloaded++ // Read the actual local mtime written by the OS/SAF after download.
bytesTransferred += bytes val localMtime = runCatching { accessor.lastModifiedMs(rel) }
newStates += buildState(pair.id, rel, dest, remote) .getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
?: System.currentTimeMillis()
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) 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 -> { SyncDecision.DELETE_LOCAL -> {
File(localRoot, rel).delete() accessor.delete(rel)
fileStateDao.delete(pair.id, rel) fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
FileOutcome(deleted = 1)
} }
SyncDecision.DELETE_REMOTE -> { SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel") provider.deleteFile("${pair.remotePath}/$rel")
fileStateDao.delete(pair.id, rel) fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
FileOutcome(deleted = 1)
} }
SyncDecision.CONFLICT -> { SyncDecision.CONFLICT -> {
conflicts++
conflictDao.insert(SyncConflictEntity( conflictDao.insert(SyncConflictEntity(
syncPairId = pair.id, syncPairId = pair.id,
relativePath = rel, relativePath = rel,
localModifiedAt = local?.lastModified()?.let { Instant.ofEpochMilli(it) } ?: Instant.EPOCH, localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } ?: Instant.EPOCH,
localSizeBytes = local?.length() ?: 0L, localSizeBytes = local?.sizeBytes ?: 0L,
remoteModifiedAt = remote?.modifiedAt ?: Instant.EPOCH, remoteModifiedAt = remote?.modifiedAt ?: Instant.EPOCH,
remoteSizeBytes = remote?.sizeBytes ?: 0L, remoteSizeBytes = remote?.sizeBytes ?: 0L,
resolution = null, resolution = null,
detectedAt = Instant.now(), detectedAt = Instant.now(),
)) ))
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0) 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() }.awaitAll()
} }
fileStateDao.upsertAll(newStates) fileStateDao.upsertAll(outcomes.mapNotNull { it.newState })
return SyncResult(uploaded, downloaded, deleted, skipped, failed, conflicts, bytesTransferred) 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( 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}")
}
}
}
private fun buildState(
pairId: Long,
rel: String,
local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?,
) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
remoteSizeBytes = remoteAfterTransfer?.sizeBytes ?: 0L,
remoteEtag = remoteAfterTransfer?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
}
}
// Top-level so unit tests can call it directly without instantiating SyncEngine.
internal fun syncDecide(
direction: SyncDirection, direction: SyncDirection,
conflictStrategy: ConflictStrategy, conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior, deleteBehavior: DeleteBehavior,
local: File?, local: LocalFileInfo?,
remote: RemoteFile?, remote: RemoteFile?,
known: SyncFileStateEntity?, known: SyncFileStateEntity?,
hasPriorSyncState: Boolean = false,
): SyncDecision { ): SyncDecision {
val localExists = local?.exists() == true val localExists = local != null
val remoteExists = remote != null val remoteExists = remote != null
val localChanged = known == null || (localExists && local!!.lastModified() != known.localModifiedAt?.toEpochMilli()) // Treat null known timestamps as "not yet recorded" — don't treat as changed.
val remoteChanged = known == null || (remoteExists && remote!!.etag != known.remoteEtag && remote.modifiedAt != known.remoteModifiedAt) // 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 { return when {
!localExists && !remoteExists -> SyncDecision.SKIP !localExists && !remoteExists -> SyncDecision.SKIP
// File only exists locally
localExists && !remoteExists -> when { localExists && !remoteExists -> when {
known == null -> when (direction) { known == null -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP else -> SyncDecision.SKIP
} }
// Remote was deleted — respect deleteBehavior
else -> when { else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL
@@ -181,13 +258,23 @@ class SyncEngine @Inject constructor(
} }
} }
// File only exists remotely
!localExists && remoteExists -> when { !localExists && remoteExists -> when {
known == null -> when (direction) { 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 SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP else -> SyncDecision.SKIP
} }
// Local was deleted — respect deleteBehavior } 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 { else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
@@ -195,16 +282,15 @@ class SyncEngine @Inject constructor(
} }
} }
// Both changed — conflict
localChanged && remoteChanged -> when (direction) { localChanged && remoteChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD
SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD
SyncDirection.TWO_WAY -> when (conflictStrategy) { SyncDirection.TWO_WAY -> when (conflictStrategy) {
ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD
ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModified() ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModifiedMs ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_LARGEST -> if ((local?.length() ?: 0L) >= (remote?.sizeBytes ?: 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 // engine keeps both via rename ConflictStrategy.KEEP_BOTH -> SyncDecision.CONFLICT
ConflictStrategy.ASK -> SyncDecision.CONFLICT ConflictStrategy.ASK -> SyncDecision.CONFLICT
} }
} }
@@ -221,49 +307,6 @@ class SyncEngine @Inject constructor(
} }
} }
private fun buildState(pairId: Long, rel: String, local: File, remote: RemoteFile?) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = if (local.exists()) Instant.ofEpochMilli(local.lastModified()) else null,
localSizeBytes = local.length(),
localHash = null,
remoteModifiedAt = remote?.modifiedAt,
remoteSizeBytes = remote?.sizeBytes ?: 0L,
remoteEtag = remote?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
}
private fun File.walkFiles(pair: SyncPair): Map<String, File> {
if (!exists()) return emptyMap()
val includeExts = pair.includeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val excludeExts = pair.excludeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val minBytes = pair.minFileSizeKb * 1024
val maxBytes = if (pair.maxFileSizeKb > 0) pair.maxFileSizeKb * 1024 else Long.MAX_VALUE
return walkTopDown()
.onEnter { dir ->
pair.recursive || dir == this
}
.filter { it.isFile }
.filter { f -> !pair.skipHiddenFiles || !f.name.startsWith('.') }
.filter { f -> pair.excludePatterns.none { pat -> f.name.matches(pat.toGlob()) } }
.filter { f ->
val ext = f.extension.lowercase()
(includeExts.isEmpty() || ext in includeExts) && ext !in excludeExts
}
.filter { f -> f.length() >= minBytes && f.length() <= maxBytes }
.associate { f -> f.relativeTo(this).path to f }
}
private fun String.toGlob(): Regex =
Regex(replace(".", "\\.").replace("*", ".*").replace("?", "."), RegexOption.IGNORE_CASE)
}
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP } enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
data class SyncResult( data class SyncResult(
@@ -1,5 +1,6 @@
package com.syncflow.ui.addpair package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -14,6 +15,7 @@ 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -29,10 +31,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState() val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() } LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) } var showRemoteBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> 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) { if (showRemoteBrowser && s.selectedAccountId != -1L) {
@@ -23,7 +23,7 @@ class HomeViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun triggerSync(pair: SyncPairEntity) { 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) workManager.enqueue(req)
} }
@@ -48,6 +48,7 @@ fun SyncFlowNavGraph(navController: NavHostController) {
) { ) {
PairDetailScreen( PairDetailScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onEdit = { id -> navController.navigate(Screen.AddPair.route(id)) },
onConflicts = { id -> navController.navigate(Screen.Conflicts.route(id)) }, onConflicts = { id -> navController.navigate(Screen.Conflicts.route(id)) },
) )
} }
@@ -21,6 +21,7 @@ import java.time.format.FormatStyle
@Composable @Composable
fun PairDetailScreen( fun PairDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEdit: (Long) -> Unit,
onConflicts: (Long) -> Unit, onConflicts: (Long) -> Unit,
vm: PairDetailViewModel = hiltViewModel(), vm: PairDetailViewModel = hiltViewModel(),
) { ) {
@@ -49,6 +50,7 @@ fun PairDetailScreen(
title = { Text(pair?.name ?: "") }, title = { Text(pair?.name ?: "") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = { 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 = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") } IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
}, },
@@ -36,7 +36,7 @@ class PairDetailViewModel @Inject constructor(
fun syncNow() { fun syncNow() {
val p = pair.value ?: return 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() { fun delete() {
@@ -3,6 +3,8 @@ package com.syncflow.worker
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.* import androidx.work.*
@@ -59,7 +61,11 @@ class SyncWorker @AssistedInject constructor(
.setSmallIcon(R.drawable.ic_sync) .setSmallIcon(R.drawable.ic_sync)
.setOngoing(true) .setOngoing(true)
.build() .build()
return ForegroundInfo(NOTIFICATION_ID, notification) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(NOTIFICATION_ID, notification)
}
} }
companion object { companion object {
@@ -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.0 VERSION_NAME=1.0.12
VERSION_CODE=1 VERSION_CODE=13