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>
This commit is contained in:
2026-05-22 21:14:51 +00:00
parent c54730d3fb
commit d647e86e88
5 changed files with 256 additions and 62 deletions
+6
View File
@@ -68,6 +68,12 @@
</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 --> <!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider -->
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
@@ -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 }
@@ -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.1
VERSION_CODE=1 VERSION_CODE=2