Compare commits

..

6 Commits

Author SHA1 Message Date
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
17 changed files with 415 additions and 111 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
@@ -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,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,21 +55,31 @@ 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()
// 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: com.syncflow.data.db.entities.SyncFileStateEntity? = null,
)
val outcomes: List<FileOutcome> = coroutineScope {
allPaths.map { rel -> allPaths.map { rel ->
async { async {
semaphore.withPermit { semaphore.withPermit {
@@ -78,102 +90,100 @@ 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++
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!!, remote))
} }
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++
bytesTransferred += bytes
newStates += buildState(pair.id, rel, dest, remote)
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, null, 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 -> skipped++ SyncDecision.SKIP -> FileOutcome(skipped = 1)
} }
} }
} }
}.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 fun decide(
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 +191,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 +203,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 +228,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 +249,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.5
VERSION_CODE=1 VERSION_CODE=6