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,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() }
@@ -0,0 +1,28 @@
package com.syncflow.data.preferences
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore by preferencesDataStore(name = "app_prefs")
@Singleton
class AppPreferences @Inject constructor(@ApplicationContext private val context: Context) {
val biometricLockEnabled: Flow<Boolean> =
context.dataStore.data.map { it[BIOMETRIC_LOCK] ?: false }
suspend fun setBiometricLock(enabled: Boolean) {
context.dataStore.edit { it[BIOMETRIC_LOCK] = enabled }
}
companion object {
private val BIOMETRIC_LOCK = booleanPreferencesKey("biometric_lock")
}
}
@@ -0,0 +1,31 @@
package com.syncflow.data.providers
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.flow.Flow
import java.io.InputStream
import java.io.OutputStream
interface CloudProvider {
suspend fun testConnection(): Result<Unit>
suspend fun listFiles(remotePath: String): Result<List<RemoteFile>>
suspend fun uploadFile(
localStream: InputStream,
remotePath: String,
sizeBytes: Long,
onProgress: (bytesWritten: Long) -> Unit = {},
): Result<RemoteFile>
suspend fun downloadFile(
remotePath: String,
destination: OutputStream,
onProgress: (bytesRead: Long) -> Unit = {},
): Result<Unit>
suspend fun deleteFile(remotePath: String): Result<Unit>
suspend fun createDirectory(remotePath: String): Result<Unit>
suspend fun getFileMetadata(remotePath: String): Result<RemoteFile>
suspend fun moveFile(fromPath: String, toPath: String): Result<Unit>
companion object {
const val CHUNK_SIZE = 8 * 1024 * 1024L // 8 MB
}
}
@@ -0,0 +1,27 @@
package com.syncflow.data.providers
import com.syncflow.data.providers.dropbox.DropboxProvider
import com.syncflow.data.providers.google.GoogleDriveProvider
import com.syncflow.data.providers.nextcloud.NextcloudProvider
import com.syncflow.data.providers.owncloud.OwnCloudProvider
import com.syncflow.data.providers.onedrive.OneDriveProvider
import com.syncflow.data.providers.sftp.SftpProvider
import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ProviderType
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProviderFactory @Inject constructor() {
fun create(account: CloudAccount): CloudProvider = when (account.providerType) {
ProviderType.GOOGLE_DRIVE -> GoogleDriveProvider(account)
ProviderType.DROPBOX -> DropboxProvider(account)
ProviderType.ONEDRIVE -> OneDriveProvider(account)
ProviderType.WEBDAV -> WebDavProvider(account)
ProviderType.SFTP -> SftpProvider(account)
ProviderType.NEXTCLOUD -> NextcloudProvider(account)
ProviderType.OWNCLOUD -> OwnCloudProvider(account)
ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV
}
}
@@ -0,0 +1,115 @@
package com.syncflow.data.providers.dropbox
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
class DropboxProvider(private val account: CloudAccount) : CloudProvider {
private val token: String by lazy {
Json.parseToJsonElement(account.credentialJson).jsonObject["access_token"]?.jsonPrimitive?.content ?: ""
}
private val client = OkHttpClient()
private fun apiReq(url: String, bodyJson: String): Request =
Request.Builder().url(url)
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.build()
override suspend fun testConnection(): Result<Unit> = runCatching {
val req = Request.Builder().url("https://api.dropboxapi.com/2/users/get_current_account")
.post("null".toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token").build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val path = if (remotePath == "/" || remotePath.isBlank()) "" else remotePath
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", """{"path":"$path","recursive":false}""")
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
Json.parseToJsonElement(body).jsonObject["entries"]?.jsonArray
?.map { it.jsonObject.toRemoteFile() } ?: emptyList()
}
}
override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes()
val argHeader = """{"path":"${remotePath.normalizeDropbox()}","mode":"overwrite","autorename":false}"""
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/upload")
.post(bytes.toRequestBody("application/octet-stream".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
onProgress(bytes.size.toLong())
Json.parseToJsonElement(body).jsonObject.toRemoteFile()
}
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val argHeader = """{"path":"${remotePath.normalizeDropbox()}"}"""
val req = Request.Builder().url("https://content.dropboxapi.com/2/files/download")
.post("".toRequestBody())
.header("Authorization", "Bearer $token")
.header("Dropbox-API-Arg", argHeader).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L
resp.body?.byteStream()?.use { src ->
val buf = ByteArray(65536)
var n: Int
while (src.read(buf).also { n = it } != -1) { destination.write(buf, 0, n); total += n; onProgress(total) }
}
}
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", """{"path":"${remotePath.normalizeDropbox()}"}""")
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", """{"path":"${remotePath.normalizeDropbox()}"}""")
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful && resp.code != 409) throw Exception("HTTP ${resp.code}") }
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", """{"path":"${remotePath.normalizeDropbox()}"}""")
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toRemoteFile()
}
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2",
"""{"from_path":"${fromPath.normalizeDropbox()}","to_path":"${toPath.normalizeDropbox()}"}""")
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
private fun JsonObject.toRemoteFile(): RemoteFile {
val tag = get(".tag")?.jsonPrimitive?.content ?: ""
return RemoteFile(
path = get("path_display")?.jsonPrimitive?.content ?: "",
name = get("name")?.jsonPrimitive?.content ?: "",
isDirectory = tag == "folder",
sizeBytes = get("size")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L,
modifiedAt = get("server_modified")?.jsonPrimitive?.content?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.EPOCH,
etag = get("content_hash")?.jsonPrimitive?.content,
mimeType = null,
)
}
private fun String.normalizeDropbox() = if (this == "/") "" else if (!startsWith("/")) "/$this" else this
}
@@ -0,0 +1,122 @@
package com.syncflow.data.providers.google
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
private val token: String by lazy {
Json.parseToJsonElement(account.credentialJson).jsonObject["access_token"]?.jsonPrimitive?.content ?: ""
}
private val client = OkHttpClient()
private fun auth(builder: Request.Builder) = builder.header("Authorization", "Bearer $token")
override suspend fun testConnection(): Result<Unit> = runCatching {
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/about?fields=user")).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val folderId = if (remotePath == "/" || remotePath.isBlank()) "root" else remotePath
val q = "'$folderId' in parents and trashed = false"
val url = "https://www.googleapis.com/drive/v3/files?q=${q.encodeUrl()}&fields=files(id,name,mimeType,size,modifiedTime,md5Checksum)&pageSize=1000"
val req = auth(Request.Builder().url(url)).build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
val files = Json.parseToJsonElement(body).jsonObject["files"]?.jsonArray ?: return@use emptyList()
files.map { it.jsonObject.toDriveFile() }
}
}
override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes()
val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
// Multipart upload
val metaPart = """{"name":"$name","parents":["$parentId"]}"""
.toRequestBody("application/json".toMediaType())
val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addPart(metaPart)
.addPart(dataPart)
.build()
val url = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,mimeType,size,modifiedTime,md5Checksum"
val req = auth(Request.Builder().url(url).post(multipart)).build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
onProgress(bytes.size.toLong())
Json.parseToJsonElement(body).jsonObject.toDriveFile()
}
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val fileId = remotePath
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$fileId?alt=media")).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L
resp.body?.byteStream()?.use { src ->
val buf = ByteArray(65536)
var n: Int
while (src.read(buf).also { n = it } != -1) { destination.write(buf, 0, n); total += n; onProgress(total) }
}
}
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$remotePath").delete()).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful && resp.code != 404) throw Exception("HTTP ${resp.code}") }
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val name = remotePath.substringAfterLast('/')
val parentId = if ('/' in remotePath.dropLast(1)) remotePath.substringBeforeLast('/') else "root"
val body = """{"name":"$name","mimeType":"application/vnd.google-apps.folder","parents":["$parentId"]}"""
.toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files").post(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$remotePath?fields=id,name,mimeType,size,modifiedTime,md5Checksum")).build()
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toDriveFile()
}
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/')
val body = """{"name":"$newName"}""".toRequestBody("application/json".toMediaType())
val req = auth(Request.Builder().url("https://www.googleapis.com/drive/v3/files/$fromPath").patch(body)).build()
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
private fun JsonObject.toDriveFile() = RemoteFile(
path = get("id")?.jsonPrimitive?.content ?: "",
name = get("name")?.jsonPrimitive?.content ?: "",
isDirectory = get("mimeType")?.jsonPrimitive?.content == "application/vnd.google-apps.folder",
sizeBytes = get("size")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L,
modifiedAt = get("modifiedTime")?.jsonPrimitive?.content?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.EPOCH,
etag = get("md5Checksum")?.jsonPrimitive?.content,
mimeType = get("mimeType")?.jsonPrimitive?.content,
)
private fun String.encodeUrl() = java.net.URLEncoder.encode(this, "UTF-8")
private fun Request.Builder.patch(body: RequestBody) = method("PATCH", body)
}
@@ -0,0 +1,14 @@
package com.syncflow.data.providers.nextcloud
import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.domain.model.CloudAccount
class NextcloudProvider(account: CloudAccount) : WebDavProvider(account) {
// Nextcloud WebDAV endpoint is /remote.php/dav/files/<username>/
override val baseUrl: String
get() {
val server = account.serverUrl?.trimEnd('/') ?: ""
val email = account.email ?: "user"
return "$server/remote.php/dav/files/$email"
}
}
@@ -0,0 +1,103 @@
package com.syncflow.data.providers.onedrive
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
class OneDriveProvider(private val account: CloudAccount) : CloudProvider {
private val token: String by lazy {
Json.parseToJsonElement(account.credentialJson).jsonObject["access_token"]?.jsonPrimitive?.content ?: ""
}
private val client = OkHttpClient()
private val base = "https://graph.microsoft.com/v1.0/me/drive/root"
private fun auth(url: String) = Request.Builder().url(url).header("Authorization", "Bearer $token")
override suspend fun testConnection(): Result<Unit> = runCatching {
auth("https://graph.microsoft.com/v1.0/me").build()
.let { client.newCall(it).execute() }.use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
val url = if (remotePath == "/" || remotePath.isBlank()) "$base/children" else "$base:${remotePath}:/children"
auth("$url?\$select=id,name,folder,size,lastModifiedDateTime,eTag,file").build()
.let { client.newCall(it).execute() }.use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
Json.parseToJsonElement(body).jsonObject["value"]?.jsonArray
?.map { it.jsonObject.toFile(remotePath) } ?: emptyList()
}
}
override suspend fun uploadFile(localStream: InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> = runCatching {
val bytes = localStream.readBytes()
val url = "$base:${remotePath}:/content"
auth(url).put(bytes.toRequestBody("application/octet-stream".toMediaType())).build()
.let { client.newCall(it).execute() }.use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}: $body")
onProgress(bytes.size.toLong())
Json.parseToJsonElement(body).jsonObject.toFile(remotePath.substringBeforeLast('/'))
}
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
auth("$base:${remotePath}:/content").build()
.let { client.newCall(it).execute() }.use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L
resp.body?.byteStream()?.use { src ->
val buf = ByteArray(65536)
var n: Int
while (src.read(buf).also { n = it } != -1) { destination.write(buf, 0, n); total += n; onProgress(total) }
}
}
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
auth("$base:${remotePath}:").delete().build()
.let { client.newCall(it).execute() }.use { resp -> if (!resp.isSuccessful && resp.code != 404) throw Exception("HTTP ${resp.code}") }
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
val name = remotePath.substringAfterLast('/')
val parent = remotePath.substringBeforeLast('/').ifBlank { "/" }
val parentUrl = if (parent == "/") "$base/children" else "$base:${parent}:/children"
val body = """{"name":"$name","folder":{}}""".toRequestBody("application/json".toMediaType())
auth(parentUrl).post(body).build()
.let { client.newCall(it).execute() }.use { resp -> if (!resp.isSuccessful && resp.code != 409) throw Exception("HTTP ${resp.code}") }
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
auth("$base:${remotePath}:?\$select=id,name,folder,size,lastModifiedDateTime,eTag,file").build()
.let { client.newCall(it).execute() }.use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toFile(remotePath.substringBeforeLast('/'))
}
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/')
val body = """{"name":"$newName"}""".toRequestBody("application/json".toMediaType())
auth("$base:${fromPath}:").method("PATCH", body).build()
.let { client.newCall(it).execute() }.use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
private fun JsonObject.toFile(parentPath: String) = RemoteFile(
path = "$parentPath/${get("name")?.jsonPrimitive?.content}".replace("//", "/"),
name = get("name")?.jsonPrimitive?.content ?: "",
isDirectory = containsKey("folder"),
sizeBytes = get("size")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L,
modifiedAt = get("lastModifiedDateTime")?.jsonPrimitive?.content?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: Instant.EPOCH,
etag = get("eTag")?.jsonPrimitive?.content,
mimeType = get("file")?.jsonObject?.get("mimeType")?.jsonPrimitive?.content,
)
}
@@ -0,0 +1,10 @@
package com.syncflow.data.providers.owncloud
import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.domain.model.CloudAccount
class OwnCloudProvider(account: CloudAccount) : WebDavProvider(account) {
// ownCloud WebDAV endpoint is /remote.php/webdav/ (no username in path)
override val baseUrl: String
get() = "${account.serverUrl?.trimEnd('/') ?: ""}/remote.php/webdav"
}
@@ -0,0 +1,117 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.sftp.SFTPClient
import net.schmizz.sshj.transport.verification.PromiscuousVerifier
import net.schmizz.sshj.xfer.InMemorySourceFile
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
class SftpProvider(private val account: CloudAccount) : CloudProvider {
private val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
private val host = account.serverUrl ?: "localhost"
private val port = account.port ?: 22
private val username = creds["username"]?.jsonPrimitive?.content ?: ""
private val password = creds["password"]?.jsonPrimitive?.content
private val privateKey = creds["private_key"]?.jsonPrimitive?.content
private fun <T> withSftp(block: (SFTPClient) -> T): T {
val ssh = SSHClient()
ssh.addHostKeyVerifier(PromiscuousVerifier()) // TODO: replace with proper key pinning
ssh.connect(host, port)
try {
if (!privateKey.isNullOrBlank()) {
ssh.authPublickey(username, ssh.loadKeys(privateKey, null, null))
} else {
ssh.authPassword(username, password ?: "")
}
return ssh.newSFTPClient().use(block)
} finally {
ssh.disconnect()
}
}
override suspend fun testConnection(): Result<Unit> = runCatching { withSftp { it.ls("/") } }
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withSftp { sftp ->
sftp.ls(remotePath).map { entry ->
RemoteFile(
path = "$remotePath/${entry.name}".replace("//", "/"),
name = entry.name,
isDirectory = entry.isDirectory,
sizeBytes = entry.attributes.size,
modifiedAt = Instant.ofEpochSecond(entry.attributes.mtime.toLong()),
etag = null,
mimeType = null,
)
}
}
}
override suspend fun uploadFile(
localStream: InputStream,
remotePath: String,
sizeBytes: Long,
onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching {
withSftp { sftp ->
sftp.put(object : InMemorySourceFile() {
override fun getName() = remotePath.substringAfterLast('/')
override fun getLength() = sizeBytes
override fun getInputStream() = localStream
}, remotePath)
}
getFileMetadata(remotePath).getOrThrow()
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val tmp = java.io.File.createTempFile("sf_", ".tmp")
try {
withSftp { sftp -> sftp.get(remotePath, tmp.absolutePath) }
var total = 0L
tmp.inputStream().use { src ->
val buf = ByteArray(65536)
var n: Int
while (src.read(buf).also { n = it } != -1) { destination.write(buf, 0, n); total += n; onProgress(total) }
}
} finally {
tmp.delete()
}
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
withSftp { it.rm(remotePath) }
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
withSftp { it.mkdirs(remotePath) }
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
withSftp { sftp ->
val attr = sftp.stat(remotePath)
RemoteFile(
path = remotePath,
name = remotePath.substringAfterLast('/'),
isDirectory = attr.type.toString().contains("DIRECTORY"),
sizeBytes = attr.size,
modifiedAt = Instant.ofEpochSecond(attr.mtime.toLong()),
etag = null,
mimeType = null,
)
}
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
withSftp { it.rename(fromPath, toPath) }
}
}
@@ -0,0 +1,210 @@
package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.concurrent.TimeUnit
open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
protected open val baseUrl: String
get() = account.serverUrl?.trimEnd('/') ?: ""
protected val client: OkHttpClient by lazy {
val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
val user = creds["username"]?.jsonPrimitive?.content ?: ""
val pass = creds["password"]?.jsonPrimitive?.content ?: ""
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("Authorization", Credentials.basic(user, pass))
.build()
val resp = chain.proceed(req)
// follow redirect manually for WebDAV methods (OkHttp skips non-GET/HEAD redirects)
if (resp.code in 301..308) {
val location = resp.header("Location") ?: return@addInterceptor resp
resp.close()
val redirectReq = req.newBuilder().url(location).build()
chain.proceed(redirectReq)
} else resp
}
.build()
}
override suspend fun testConnection(): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(baseUrl).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp ->
Log.d("SyncFlow/WebDAV", "testConnection ${resp.code} url=$baseUrl")
if (!resp.isSuccessful && resp.code != 207) {
val body = resp.body?.string()?.take(300) ?: ""
throw Exception("HTTP ${resp.code} ${resp.message}$body")
}
}
}
}.onFailure { e -> Log.e("SyncFlow/WebDAV", "testConnection failed: ${e::class.simpleName}: ${e.message}", e.cause) }
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "1").build()
client.newCall(req).execute().use { resp ->
if (resp.code != 207) throw Exception("HTTP ${resp.code}")
parsePropfind(resp.body?.string() ?: "", remotePath)
}
}
}
override suspend fun uploadFile(
localStream: InputStream,
remotePath: String,
sizeBytes: Long,
onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) {
val bytes = localStream.readBytes()
val body = bytes.toRequestBody("application/octet-stream".toMediaType())
val req = Request.Builder().url(url(remotePath)).put(body).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
}
onProgress(bytes.size.toLong())
getFileMetadata(remotePath).getOrThrow()
}
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).get().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Download HTTP ${resp.code}")
val buf = ByteArray(65536)
var total = 0L
resp.body?.byteStream()?.use { src ->
var n: Int
while (src.read(buf).also { n = it } != -1) {
destination.write(buf, 0, n)
total += n
onProgress(total)
}
}
}
}
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).delete().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Delete HTTP ${resp.code}")
}
}
}
override suspend fun createDirectory(remotePath: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful && resp.code != 405) throw Exception("MKCOL HTTP ${resp.code}")
}
}
}
override suspend fun getFileMetadata(remotePath: String): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).method("PROPFIND", PROPFIND_BODY).header("Depth", "0").build()
client.newCall(req).execute().use { resp ->
if (resp.code != 207) throw Exception("HTTP ${resp.code}")
parsePropfind(resp.body?.string() ?: "", remotePath.substringBeforeLast('/'))
.firstOrNull() ?: throw Exception("File not found")
}
}
}
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(fromPath))
.method("MOVE", null)
.header("Destination", url(toPath))
.header("Overwrite", "T")
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("MOVE HTTP ${resp.code}")
}
}
}
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
private fun parsePropfind(xml: String, parentPath: String): List<RemoteFile> {
val results = mutableListOf<RemoteFile>()
try {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(xml.reader())
var href = ""; var isCollection = false; var contentLength = 0L
var lastModified: Instant = Instant.EPOCH; var etag: String? = null; var contentType: String? = null
var inResponse = false; var inProp = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
val tag = parser.name?.substringAfterLast(':')?.lowercase()
when (eventType) {
XmlPullParser.START_TAG -> when (tag) {
"response" -> { inResponse = true; href = ""; isCollection = false; contentLength = 0L; lastModified = Instant.EPOCH; etag = null; contentType = null }
"prop" -> inProp = true
"href" -> if (!inProp) href = parser.nextText().trim()
"collection" -> if (inProp) isCollection = true
"getcontentlength" -> if (inProp) contentLength = parser.nextText().trim().toLongOrNull() ?: 0L
"getlastmodified" -> if (inProp) lastModified = parseHttpDate(parser.nextText().trim())
"getetag" -> if (inProp) etag = parser.nextText().trim().trim('"')
"getcontenttype" -> if (inProp) contentType = parser.nextText().trim()
}
XmlPullParser.END_TAG -> when (tag) {
"prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) {
val name = href.trimEnd('/').substringAfterLast('/')
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
}
}
eventType = parser.next()
}
} catch (_: Exception) {}
return results.drop(1) // drop the parent folder itself
}
private fun parseHttpDate(value: String): Instant = try {
DateTimeFormatter.RFC_1123_DATE_TIME.parse(value, Instant::from)
} catch (_: Exception) { Instant.EPOCH }
companion object {
private val PROPFIND_BODY = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:resourcetype/><d:getcontentlength/><d:getlastmodified/><d:getetag/><d:getcontenttype/>
</d:prop>
</d:propfind>""".toRequestBody("application/xml".toMediaType())
}
}
@@ -0,0 +1,76 @@
package com.syncflow.data.repository
import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val accountDao: CloudAccountDao,
private val credentialStore: CredentialStore,
) {
/** Observe all accounts (display-only, no credentials). */
fun observeAll(): Flow<List<CloudAccountEntity>> = accountDao.observeAll()
/**
* Load a fully-hydrated account with credentials from encrypted storage.
* Auto-migrates any legacy account whose credentialJson is still in the DB.
*/
suspend fun getAccount(id: Long): CloudAccount? {
val entity = accountDao.getById(id) ?: return null
val credJson = resolveCredentials(entity)
return entity.toDomain(credJson)
}
/**
* Insert a new account. Credentials go to EncryptedSharedPreferences; the DB
* row stores an empty placeholder so the plain database is never sensitive.
*/
suspend fun insert(
entity: CloudAccountEntity,
credJson: String,
): Long {
val id = accountDao.insert(entity.copy(credentialJson = ""))
credentialStore.save(id, credJson)
return id
}
/** Delete account from DB and wipe its credentials from the encrypted store. */
suspend fun delete(entity: CloudAccountEntity) {
accountDao.delete(entity)
credentialStore.remove(entity.id)
}
// ── Internal helpers ──────────────────────────────────────────────────────
private suspend fun resolveCredentials(entity: CloudAccountEntity): String {
val stored = credentialStore.getCredJson(entity.id)
if (stored != null) return stored
// Legacy path: credential was saved to DB before encryption was added.
// Migrate it on first access and clear the DB copy.
val legacy = entity.credentialJson
return if (legacy.isNotEmpty()) {
credentialStore.save(entity.id, legacy)
accountDao.update(entity.copy(credentialJson = ""))
legacy
} else {
"{}"
}
}
private fun CloudAccountEntity.toDomain(credJson: String) = CloudAccount(
id = id,
displayName = displayName,
email = email,
providerType = providerType,
credentialJson = credJson,
serverUrl = serverUrl,
port = port,
)
}
@@ -0,0 +1,56 @@
package com.syncflow.data.security
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CredentialStore @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"syncflow_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
// ── Account credentials ───────────────────────────────────────────────────
fun save(accountId: Long, credJson: String) {
prefs.edit().putString(credKey(accountId), credJson).apply()
}
/** Returns null if no credential has been stored for this account yet. */
fun getCredJson(accountId: Long): String? = prefs.getString(credKey(accountId), null)
fun remove(accountId: Long) {
prefs.edit().remove(credKey(accountId)).apply()
}
// ── PKCE verifiers (OAuth flow) ───────────────────────────────────────────
fun savePkceVerifier(provider: String, verifier: String) {
prefs.edit().putString(pkceKey(provider), verifier).apply()
}
fun getPkceVerifier(provider: String): String? = prefs.getString(pkceKey(provider), null)
fun removePkceVerifier(provider: String) {
prefs.edit().remove(pkceKey(provider)).apply()
}
// ── Key helpers ───────────────────────────────────────────────────────────
private fun credKey(accountId: Long) = "cred_$accountId"
private fun pkceKey(provider: String) = "pkce_$provider"
}