Compare commits

...

4 Commits

Author SHA1 Message Date
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
13 changed files with 300 additions and 65 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>
@@ -0,0 +1,201 @@
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() {
override fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo> {
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)
}
}
}
return result
}
override fun openInputStream(relativePath: String): InputStream? {
val docUri = 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 = findDocUri(relativePath) ?: return false
return DocumentsContract.deleteDocument(resolver, docUri)
}
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,12 +1,13 @@
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
import com.syncflow.data.db.SyncPairDao import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncConflictEntity import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.data.db.entities.SyncEventEntity import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.ConflictStrategy import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior import com.syncflow.domain.model.DeleteBehavior
@@ -15,12 +16,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 +30,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,20 +55,26 @@ class SyncEngine @Inject constructor(
} }
} }
private fun makeAccessor(localPath: String): LocalAccessor =
if (localPath.startsWith("content://"))
LocalAccessor.Saf(Uri.parse(localPath), context.contentResolver)
else
LocalAccessor.JavaFile(File(localPath))
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult { private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult {
val localRoot = File(pair.localPath) val accessor = makeAccessor(pair.localPath)
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath } val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow().associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') } val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
val localFiles = localRoot.walkFiles(pair) .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 uploaded = 0; var downloaded = 0; var deleted = 0; var skipped = 0; var failed = 0; var conflicts = 0
var bytesTransferred = 0L var bytesTransferred = 0L
val newStates = mutableListOf<SyncFileStateEntity>() val newStates = mutableListOf<com.syncflow.data.db.entities.SyncFileStateEntity>()
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet() val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
// Fan out with bounded parallelism
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
coroutineScope { coroutineScope {
allPaths.map { rel -> allPaths.map { rel ->
async { async {
@@ -78,12 +86,11 @@ class SyncEngine @Inject constructor(
when (decision) { when (decision) {
SyncDecision.UPLOAD -> { SyncDecision.UPLOAD -> {
val file = File(localRoot, rel)
val bytes = runCatching { val bytes = runCatching {
file.inputStream().use { stream -> accessor.openInputStream(rel)?.use { stream ->
provider.uploadFile(stream, "${pair.remotePath}/$rel", file.length()) { } provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }
} }
file.length() local!!.sizeBytes
}.getOrElse { e -> }.getOrElse { e ->
Timber.e(e, "Upload failed: $rel") Timber.e(e, "Upload failed: $rel")
failed++ failed++
@@ -92,13 +99,12 @@ class SyncEngine @Inject constructor(
} }
uploaded++ uploaded++
bytesTransferred += bytes bytesTransferred += bytes
newStates += buildState(pair.id, rel, file, remote) newStates += buildState(pair.id, rel, local!!, remote)
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
} }
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
@@ -110,11 +116,11 @@ class SyncEngine @Inject constructor(
} }
downloaded++ downloaded++
bytesTransferred += bytes bytesTransferred += bytes
newStates += buildState(pair.id, rel, dest, remote) newStates += buildState(pair.id, rel, null, remote)
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes) logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
} }
SyncDecision.DELETE_LOCAL -> { SyncDecision.DELETE_LOCAL -> {
File(localRoot, rel).delete() accessor.delete(rel)
fileStateDao.delete(pair.id, rel) fileStateDao.delete(pair.id, rel)
deleted++ deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0) logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
@@ -130,8 +136,8 @@ class SyncEngine @Inject constructor(
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,
@@ -154,26 +160,24 @@ class SyncEngine @Inject constructor(
direction: SyncDirection, direction: SyncDirection,
conflictStrategy: ConflictStrategy, conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior, deleteBehavior: DeleteBehavior,
local: File?, local: LocalFileInfo?,
remote: RemoteFile?, remote: RemoteFile?,
known: SyncFileStateEntity?, known: com.syncflow.data.db.entities.SyncFileStateEntity?,
): 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()) val localChanged = known == null || (localExists && local!!.lastModifiedMs != known.localModifiedAt?.toEpochMilli())
val remoteChanged = known == null || (remoteExists && remote!!.etag != known.remoteEtag && remote.modifiedAt != known.remoteModifiedAt) val remoteChanged = known == null || (remoteExists && 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 +185,11 @@ class SyncEngine @Inject constructor(
} }
} }
// File only exists remotely
!localExists && remoteExists -> when { !localExists && remoteExists -> when {
known == null -> when (direction) { known == null -> 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 -> 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 +197,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,11 +222,16 @@ class SyncEngine @Inject constructor(
} }
} }
private fun buildState(pairId: Long, rel: String, local: File, remote: RemoteFile?) = SyncFileStateEntity( private fun buildState(
pairId: Long,
rel: String,
local: LocalFileInfo?,
remote: RemoteFile?,
) = com.syncflow.data.db.entities.SyncFileStateEntity(
syncPairId = pairId, syncPairId = pairId,
relativePath = rel, relativePath = rel,
localModifiedAt = if (local.exists()) Instant.ofEpochMilli(local.lastModified()) else null, localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = local.length(), localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null, localHash = null,
remoteModifiedAt = remote?.modifiedAt, remoteModifiedAt = remote?.modifiedAt,
remoteSizeBytes = remote?.sizeBytes ?: 0L, remoteSizeBytes = remote?.sizeBytes ?: 0L,
@@ -237,31 +243,6 @@ class SyncEngine @Inject constructor(
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) { 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)) 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 }
@@ -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 {
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.0 VERSION_NAME=1.0.3
VERSION_CODE=1 VERSION_CODE=4