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:
@@ -0,0 +1,26 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.*
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CloudAccountDao {
|
||||
@Query("SELECT * FROM cloud_accounts ORDER BY displayName")
|
||||
fun observeAll(): Flow<List<CloudAccountEntity>>
|
||||
|
||||
@Query("SELECT * FROM cloud_accounts")
|
||||
suspend fun getAll(): List<CloudAccountEntity>
|
||||
|
||||
@Query("SELECT * FROM cloud_accounts WHERE id = :id")
|
||||
suspend fun getById(id: Long): CloudAccountEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insert(entity: CloudAccountEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(entity: CloudAccountEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(entity: CloudAccountEntity)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import java.time.Instant
|
||||
|
||||
class DbConverters {
|
||||
@TypeConverter fun fromInstant(v: Instant?): Long? = v?.epochSecond
|
||||
@TypeConverter fun toInstant(v: Long?): Instant? = v?.let { Instant.ofEpochSecond(it) }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.*
|
||||
import com.syncflow.data.db.entities.SyncConflictEntity
|
||||
import com.syncflow.domain.model.ConflictResolution
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SyncConflictDao {
|
||||
@Query("SELECT * FROM sync_conflicts WHERE syncPairId = :pairId AND resolution IS NULL ORDER BY detectedAt DESC")
|
||||
fun observeUnresolved(pairId: Long): Flow<List<SyncConflictEntity>>
|
||||
|
||||
@Query("SELECT * FROM sync_conflicts WHERE syncPairId = :pairId ORDER BY detectedAt DESC")
|
||||
fun observeAll(pairId: Long): Flow<List<SyncConflictEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM sync_conflicts WHERE syncPairId = :pairId AND resolution IS NULL")
|
||||
fun observeUnresolvedCount(pairId: Long): Flow<Int>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(entity: SyncConflictEntity): Long
|
||||
|
||||
@Query("UPDATE sync_conflicts SET resolution = :resolution WHERE id = :id")
|
||||
suspend fun resolve(id: Long, resolution: ConflictResolution)
|
||||
|
||||
@Query("DELETE FROM sync_conflicts WHERE syncPairId = :pairId AND resolution IS NOT NULL")
|
||||
suspend fun deleteResolved(pairId: Long)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.syncflow.data.db.entities.*
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
CloudAccountEntity::class,
|
||||
SyncPairEntity::class,
|
||||
SyncFileStateEntity::class,
|
||||
SyncConflictEntity::class,
|
||||
SyncEventEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(DbConverters::class)
|
||||
abstract class SyncDatabase : RoomDatabase() {
|
||||
abstract fun cloudAccountDao(): CloudAccountDao
|
||||
abstract fun syncPairDao(): SyncPairDao
|
||||
abstract fun syncFileStateDao(): SyncFileStateDao
|
||||
abstract fun syncConflictDao(): SyncConflictDao
|
||||
abstract fun syncEventDao(): SyncEventDao
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.*
|
||||
import com.syncflow.data.db.entities.SyncEventEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface SyncEventDao {
|
||||
@Query("SELECT * FROM sync_events WHERE syncPairId = :pairId ORDER BY timestamp DESC LIMIT :limit")
|
||||
fun observeRecent(pairId: Long, limit: Int = 200): Flow<List<SyncEventEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(entity: SyncEventEntity): Long
|
||||
|
||||
@Query("DELETE FROM sync_events WHERE syncPairId = :pairId AND timestamp < :olderThan")
|
||||
suspend fun pruneOld(pairId: Long, olderThan: Long)
|
||||
|
||||
@Query("SELECT SUM(bytesTransferred) FROM sync_events WHERE syncPairId = :pairId AND timestamp >= :since")
|
||||
suspend fun totalBytesTransferred(pairId: Long, since: Long): Long?
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.*
|
||||
import com.syncflow.data.db.entities.SyncFileStateEntity
|
||||
|
||||
@Dao
|
||||
interface SyncFileStateDao {
|
||||
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId")
|
||||
suspend fun getForPair(pairId: Long): List<SyncFileStateEntity>
|
||||
|
||||
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId AND relativePath = :path")
|
||||
suspend fun get(pairId: Long, path: String): SyncFileStateEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(entity: SyncFileStateEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(entities: List<SyncFileStateEntity>)
|
||||
|
||||
@Query("DELETE FROM sync_file_states WHERE syncPairId = :pairId AND relativePath = :path")
|
||||
suspend fun delete(pairId: Long, path: String)
|
||||
|
||||
@Query("DELETE FROM sync_file_states WHERE syncPairId = :pairId")
|
||||
suspend fun deleteForPair(pairId: Long)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.syncflow.data.db
|
||||
|
||||
import androidx.room.*
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.Instant
|
||||
|
||||
@Dao
|
||||
interface SyncPairDao {
|
||||
@Query("SELECT * FROM sync_pairs ORDER BY name")
|
||||
fun observeAll(): Flow<List<SyncPairEntity>>
|
||||
|
||||
@Query("SELECT * FROM sync_pairs WHERE isEnabled = 1")
|
||||
suspend fun getEnabled(): List<SyncPairEntity>
|
||||
|
||||
@Query("SELECT * FROM sync_pairs WHERE id = :id")
|
||||
suspend fun getById(id: Long): SyncPairEntity?
|
||||
|
||||
@Query("SELECT * FROM sync_pairs WHERE id = :id")
|
||||
fun observeById(id: Long): Flow<SyncPairEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insert(entity: SyncPairEntity): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(entity: SyncPairEntity)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(entity: SyncPairEntity)
|
||||
|
||||
@Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts WHERE id = :id")
|
||||
suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int)
|
||||
|
||||
@Query("UPDATE sync_pairs SET lastSyncResult = :status WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: SyncStatus)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.syncflow.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
|
||||
@Entity(tableName = "cloud_accounts")
|
||||
data class CloudAccountEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val displayName: String,
|
||||
val email: String?,
|
||||
val providerType: ProviderType,
|
||||
val credentialJson: String,
|
||||
val serverUrl: String?,
|
||||
val port: Int?,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.syncflow.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.syncflow.domain.model.ConflictResolution
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "sync_conflicts",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = SyncPairEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["syncPairId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)],
|
||||
indices = [Index("syncPairId")],
|
||||
)
|
||||
data class SyncConflictEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val syncPairId: Long,
|
||||
val relativePath: String,
|
||||
val localModifiedAt: Instant,
|
||||
val localSizeBytes: Long,
|
||||
val remoteModifiedAt: Instant,
|
||||
val remoteSizeBytes: Long,
|
||||
val resolution: ConflictResolution?,
|
||||
val detectedAt: Instant,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.syncflow.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.syncflow.domain.model.SyncEventType
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "sync_events",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = SyncPairEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["syncPairId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)],
|
||||
indices = [Index("syncPairId"), Index("timestamp")],
|
||||
)
|
||||
data class SyncEventEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val syncPairId: Long,
|
||||
val timestamp: Instant,
|
||||
val eventType: SyncEventType,
|
||||
val filePath: String?,
|
||||
val message: String?,
|
||||
val bytesTransferred: Long,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.syncflow.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "sync_file_states",
|
||||
primaryKeys = ["syncPairId", "relativePath"],
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = SyncPairEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["syncPairId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)],
|
||||
indices = [Index("syncPairId")],
|
||||
)
|
||||
data class SyncFileStateEntity(
|
||||
val syncPairId: Long,
|
||||
val relativePath: String,
|
||||
val localModifiedAt: Instant?,
|
||||
val localSizeBytes: Long,
|
||||
val localHash: String?,
|
||||
val remoteModifiedAt: Instant?,
|
||||
val remoteSizeBytes: Long,
|
||||
val remoteEtag: String?,
|
||||
val lastSyncedAt: Instant,
|
||||
val syncedHash: String?,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.syncflow.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.syncflow.domain.model.*
|
||||
import java.time.Instant
|
||||
|
||||
@Entity(
|
||||
tableName = "sync_pairs",
|
||||
foreignKeys = [ForeignKey(
|
||||
entity = CloudAccountEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["accountId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
)],
|
||||
indices = [Index("accountId")],
|
||||
)
|
||||
data class SyncPairEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val localPath: String,
|
||||
val remotePath: String,
|
||||
val accountId: Long,
|
||||
// Sync behaviour
|
||||
val syncDirection: SyncDirection,
|
||||
val conflictStrategy: ConflictStrategy,
|
||||
val deleteBehavior: DeleteBehavior,
|
||||
val recursive: Boolean,
|
||||
// Schedule
|
||||
val scheduleType: ScheduleType,
|
||||
val scheduleIntervalMinutes: Int,
|
||||
val scheduleDailyTime: String?,
|
||||
val scheduleWeekdays: Int,
|
||||
// Constraints
|
||||
val wifiOnly: Boolean,
|
||||
val wifiSsid: String,
|
||||
val chargingOnly: Boolean,
|
||||
val minBatteryPct: Int,
|
||||
// File filters (newline-separated lists stored as strings)
|
||||
val excludePatterns: String,
|
||||
val includeExtensions: String,
|
||||
val excludeExtensions: String,
|
||||
val skipHiddenFiles: Boolean,
|
||||
val minFileSizeKb: Long,
|
||||
val maxFileSizeKb: Long,
|
||||
// Notifications
|
||||
val notifyOnComplete: Boolean,
|
||||
val notifyOnError: Boolean,
|
||||
// State
|
||||
val isEnabled: Boolean,
|
||||
val lastSyncAt: Instant?,
|
||||
val lastSyncResult: SyncStatus,
|
||||
val pendingConflicts: Int,
|
||||
)
|
||||
|
||||
fun SyncPairEntity.toDomain() = SyncPair(
|
||||
id = id, name = name, localPath = localPath, remotePath = remotePath, accountId = accountId,
|
||||
syncDirection = syncDirection, conflictStrategy = conflictStrategy, deleteBehavior = deleteBehavior,
|
||||
recursive = recursive, scheduleType = scheduleType, scheduleIntervalMinutes = scheduleIntervalMinutes,
|
||||
scheduleDailyTime = scheduleDailyTime, scheduleWeekdays = scheduleWeekdays,
|
||||
wifiOnly = wifiOnly, wifiSsid = wifiSsid, chargingOnly = chargingOnly, minBatteryPct = minBatteryPct,
|
||||
excludePatterns = excludePatterns.splitList(), includeExtensions = includeExtensions.splitList(),
|
||||
excludeExtensions = excludeExtensions.splitList(), skipHiddenFiles = skipHiddenFiles,
|
||||
minFileSizeKb = minFileSizeKb, maxFileSizeKb = maxFileSizeKb,
|
||||
notifyOnComplete = notifyOnComplete, notifyOnError = notifyOnError,
|
||||
isEnabled = isEnabled, lastSyncAt = lastSyncAt, lastSyncResult = lastSyncResult, pendingConflicts = pendingConflicts,
|
||||
)
|
||||
|
||||
private fun String.splitList() = if (isBlank()) emptyList() else trim().split("\n").filter { it.isNotBlank() }
|
||||
Reference in New Issue
Block a user