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() }
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user