Initial commit — SyncFlow Android file sync app

Supports WebDAV, SFTP, SFTPGo, Nextcloud, ownCloud, Google Drive,
Dropbox, and OneDrive. Credentials encrypted with Android Keystore.
Biometric app-lock, conflict resolution, and auto-sync via WorkManager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 20:21:20 +00:00
commit cff4233de6
95 changed files with 5381 additions and 0 deletions
@@ -0,0 +1,41 @@
package com.syncflow.worker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import javax.inject.Inject
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val wm = WorkManager.getInstance(context)
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
syncPairDao.getEnabled()
.filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE }
.forEach { pair ->
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req)
}
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,95 @@
package com.syncflow.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.*
import com.syncflow.R
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.sync.SyncEngine
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import timber.log.Timber
import java.util.concurrent.TimeUnit
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val syncPairDao: SyncPairDao,
private val accountRepository: AccountRepository,
private val syncEngine: SyncEngine,
private val providerFactory: ProviderFactory,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
return try {
val domainPair = pair.toDomain()
val provider = providerFactory.create(account)
syncEngine.sync(domainPair, provider)
Result.success()
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
private fun buildForegroundInfo(progress: String): ForegroundInfo {
val channelId = "sync_channel"
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW))
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("SyncFlow")
.setContentText(progress)
.setSmallIcon(R.drawable.ic_sync)
.setOngoing(true)
.build()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
companion object {
const val KEY_PAIR_ID = "pair_id"
private const val NOTIFICATION_ID = 1001
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
.build()
}
fun buildPeriodicRequest(pairId: Long, intervalMinutes: Long, wifiOnly: Boolean, chargingOnly: Boolean): PeriodicWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return PeriodicWorkRequestBuilder<SyncWorker>(intervalMinutes, TimeUnit.MINUTES)
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
.build()
}
}
}