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
+12
View File
@@ -0,0 +1,12 @@
*.iml
.gradle/
local.properties
.idea/
.DS_Store
/build/
app/build/
captures/
.externalNativeBuild/
.cxx/
*.keystore
*.jks
+38
View File
@@ -0,0 +1,38 @@
# SyncFlow
Native Android file sync app — sync any folder to WebDAV, SFTP, Nextcloud, ownCloud, Google Drive, Dropbox, or OneDrive.
## Features
- **Multi-provider** — WebDAV, SFTP, SFTPGo, Nextcloud, ownCloud, Google Drive, Dropbox, OneDrive
- **Flexible sync** — one-way upload, one-way download, or two-way mirror
- **Auto-sync** — schedule by interval or trigger on Wi-Fi connect / device charge
- **Conflict resolution** — keep local, keep remote, keep newer, or keep both
- **Secure** — credentials encrypted with Android Keystore; biometric app-lock option
- **No cloud dependency** — runs fully on-device, no third-party relay
## Install
1. Download `SyncFlow.apk` from the [latest release](../../releases/latest)
2. On your Android phone: **Settings → Apps → Install unknown apps** → allow your browser/file manager
3. Open the downloaded APK and tap Install
4. Open **SyncFlow**, go to **Accounts** tab → **Add Account**, pick your provider and sign in
5. Tap **+** on the **Syncs** tab to create your first sync pair
## Supported Providers
| Provider | Auth |
|---|---|
| WebDAV | Username + password |
| SFTP | Password or private key |
| SFTPGo | Username + password |
| Nextcloud | Username + password |
| ownCloud | Username + password |
| Google Drive | OAuth 2.0 (PKCE) |
| Dropbox | OAuth 2.0 (PKCE) |
| OneDrive | OAuth 2.0 (PKCE) |
## Requirements
- Android 8.0+ (API 26)
- Storage permission (or SAF picker) for local folder access
+132
View File
@@ -0,0 +1,132 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.syncflow"
compileSdk = 34
defaultConfig {
applicationId = "com.syncflow"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Placeholder — replace with real keys before release
manifestPlaceholders["GOOGLE_CLIENT_ID"] = "YOUR_GOOGLE_CLIENT_ID"
manifestPlaceholders["DROPBOX_APP_KEY"] = "YOUR_DROPBOX_APP_KEY"
manifestPlaceholders["MSAL_REDIRECT_URI"] = "msauth://com.syncflow/YOUR_BASE64_SIGNATURE"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "META-INF/DEPENDENCIES"
}
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.splashscreen)
// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
debugImplementation(libs.androidx.ui.tooling)
// Navigation
implementation(libs.androidx.navigation.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work)
ksp(libs.hilt.work.compiler)
// Room
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// WorkManager
implementation(libs.work.runtime.ktx)
// DataStore
implementation(libs.datastore.preferences)
// Networking
implementation(libs.okhttp)
debugImplementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// OAuth browser flow
implementation(libs.androidx.browser)
implementation(libs.androidx.localbroadcastmanager)
// All cloud providers use OkHttp REST directly — no vendor SDKs needed
// SFTP (WebDAV uses OkHttp directly)
implementation(libs.sshj)
// Image loading
implementation(libs.coil.compose)
// Security
implementation(libs.security.crypto)
implementation(libs.biometric)
// Logging
implementation(libs.timber)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
+8
View File
@@ -0,0 +1,8 @@
-keep class com.syncflow.data.db.entities.** { *; }
-keep class com.syncflow.domain.model.** { *; }
-keep class com.google.api.** { *; }
-keep class com.dropbox.** { *; }
-keep class com.microsoft.graph.** { *; }
-dontwarn org.bouncycastle.**
-dontwarn org.conscrypt.**
-dontwarn org.openjsse.**
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<application
android:name=".SyncFlowApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:networkSecurityConfig="@xml/network_security_config"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SyncFlow"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.SyncFlow.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- OAuth2 redirect handler (Dropbox + OneDrive PKCE) -->
<activity
android:name=".ui.auth.OAuthRedirectActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="syncflow" android:host="oauth" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".worker.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,112 @@
package com.syncflow
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.syncflow.data.preferences.AppPreferences
import com.syncflow.ui.navigation.SyncFlowNavGraph
import com.syncflow.ui.theme.SyncFlowTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var appPreferences: AppPreferences
private var isLocked by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SyncFlowTheme {
Surface(modifier = Modifier.fillMaxSize()) {
SyncFlowNavGraph(rememberNavController())
}
if (isLocked) {
LockOverlay()
LaunchedEffect(Unit) {
showBiometricPrompt(onSuccess = { isLocked = false })
}
}
}
}
}
override fun onStop() {
super.onStop()
if (isChangingConfigurations) return
lifecycleScope.launch {
if (appPreferences.biometricLockEnabled.first() && canAuthenticate()) {
isLocked = true
}
}
}
private fun canAuthenticate(): Boolean {
val authenticators = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
BIOMETRIC_STRONG or DEVICE_CREDENTIAL
else
BIOMETRIC_STRONG
return BiometricManager.from(this).canAuthenticate(authenticators) ==
BiometricManager.BIOMETRIC_SUCCESS
}
private fun showBiometricPrompt(onSuccess: () -> Unit) {
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
})
val promptInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
.build()
} else {
BiometricPrompt.PromptInfo.Builder()
.setTitle("Unlock SyncFlow")
.setSubtitle("Confirm your identity to continue")
.setNegativeButtonText("Cancel")
.build()
}
prompt.authenticate(promptInfo)
}
}
@Composable
private fun LockOverlay() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@@ -0,0 +1,25 @@
package com.syncflow
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class SyncFlowApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
}
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(android.util.Log.INFO)
.build()
}
@@ -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"
}
@@ -0,0 +1,52 @@
package com.syncflow.di
import android.content.Context
import androidx.room.Room
import androidx.work.WorkManager
import com.syncflow.data.db.*
import com.syncflow.data.preferences.AppPreferences
import com.syncflow.data.repository.AccountRepository
import com.syncflow.data.security.CredentialStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase =
Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db")
// Only fall back to destructive migration for very old dev builds (v1).
// All future version bumps must include a proper Migration object.
.fallbackToDestructiveMigrationFrom(1)
.build()
@Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao()
@Provides fun provideSyncPairDao(db: SyncDatabase): SyncPairDao = db.syncPairDao()
@Provides fun provideSyncFileStateDao(db: SyncDatabase): SyncFileStateDao = db.syncFileStateDao()
@Provides fun provideSyncConflictDao(db: SyncDatabase): SyncConflictDao = db.syncConflictDao()
@Provides fun provideSyncEventDao(db: SyncDatabase): SyncEventDao = db.syncEventDao()
@Provides @Singleton
fun provideCredentialStore(@ApplicationContext ctx: Context): CredentialStore =
CredentialStore(ctx)
@Provides @Singleton
fun provideAccountRepository(
accountDao: CloudAccountDao,
credentialStore: CredentialStore,
): AccountRepository = AccountRepository(accountDao, credentialStore)
@Provides @Singleton
fun provideAppPreferences(@ApplicationContext ctx: Context): AppPreferences =
AppPreferences(ctx)
@Provides @Singleton
fun provideWorkManager(@ApplicationContext ctx: Context): WorkManager =
WorkManager.getInstance(ctx)
}
@@ -0,0 +1,22 @@
package com.syncflow.domain.model
data class CloudAccount(
val id: Long = 0,
val displayName: String,
val email: String?,
val providerType: ProviderType,
val credentialJson: String,
val serverUrl: String?,
val port: Int?,
)
enum class ProviderType {
GOOGLE_DRIVE,
DROPBOX,
ONEDRIVE,
WEBDAV,
SFTP,
NEXTCLOUD,
OWNCLOUD,
SFTPGO,
}
@@ -0,0 +1,13 @@
package com.syncflow.domain.model
import java.time.Instant
data class RemoteFile(
val path: String,
val name: String,
val isDirectory: Boolean,
val sizeBytes: Long,
val modifiedAt: Instant,
val etag: String?,
val mimeType: String?,
)
@@ -0,0 +1,22 @@
package com.syncflow.domain.model
import java.time.Instant
data class SyncConflict(
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,
)
enum class ConflictResolution {
KEEP_LOCAL,
KEEP_REMOTE,
KEEP_BOTH,
SKIP,
}
@@ -0,0 +1,25 @@
package com.syncflow.domain.model
import java.time.Instant
data class SyncEvent(
val id: Long = 0,
val syncPairId: Long,
val timestamp: Instant,
val eventType: SyncEventType,
val filePath: String?,
val message: String?,
val bytesTransferred: Long,
)
enum class SyncEventType {
SYNC_STARTED,
SYNC_COMPLETED,
SYNC_FAILED,
FILE_UPLOADED,
FILE_DOWNLOADED,
FILE_DELETED,
FILE_SKIPPED,
CONFLICT_DETECTED,
CONFLICT_RESOLVED,
}
@@ -0,0 +1,73 @@
package com.syncflow.domain.model
import java.time.Instant
data class SyncPair(
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?, // "HH:mm"
val scheduleWeekdays: Int, // bitmask Mon=1..Sun=64, 0=every day
// ── Constraints ─────────────────────────────────────────────────────────
val wifiOnly: Boolean,
val wifiSsid: String, // blank = any wifi
val chargingOnly: Boolean,
val minBatteryPct: Int, // 0 = no requirement
// ── File filters ────────────────────────────────────────────────────────
val excludePatterns: List<String>,
val includeExtensions: List<String>, // empty = all
val excludeExtensions: List<String>,
val skipHiddenFiles: Boolean,
val minFileSizeKb: Long, // 0 = no limit
val maxFileSizeKb: Long, // 0 = no limit
// ── Notifications ───────────────────────────────────────────────────────
val notifyOnComplete: Boolean,
val notifyOnError: Boolean,
// ── State (read-only, managed by engine) ────────────────────────────────
val isEnabled: Boolean,
val lastSyncAt: Instant?,
val lastSyncResult: SyncStatus,
val pendingConflicts: Int,
)
enum class SyncDirection(val label: String, val description: String) {
UPLOAD_ONLY("Upload only", "Local → Remote"),
DOWNLOAD_ONLY("Download only", "Remote → Local"),
TWO_WAY("Two-way sync", "Bidirectional"),
}
enum class ConflictStrategy(val label: String) {
KEEP_LOCAL("Keep local file"),
KEEP_REMOTE("Keep remote file"),
KEEP_NEWEST("Keep most recently modified"),
KEEP_LARGEST("Keep largest file"),
KEEP_BOTH("Keep both (rename copy)"),
ASK("Ask me each time"),
}
enum class DeleteBehavior(val label: String, val description: String) {
MIRROR("Mirror deletions", "Delete on target when deleted on source"),
KEEP("Keep deleted files", "Never delete — only add/update"),
}
enum class ScheduleType(val label: String) {
MANUAL("Manual only"),
ON_CHANGE("When files change"),
INTERVAL("Every N minutes"),
DAILY("Daily at a set time"),
WEEKLY("Weekly on set days"),
}
enum class SyncStatus {
IDLE, SYNCING, SUCCESS, PARTIAL, FAILED, CONFLICT,
}
@@ -0,0 +1,278 @@
package com.syncflow.domain.sync
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.CloudProvider
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.RemoteFile
import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncPair
import com.syncflow.domain.model.SyncStatus
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import timber.log.Timber
import java.io.File
import java.security.MessageDigest
import java.time.Instant
import javax.inject.Inject
class SyncEngine @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val conflictDao: SyncConflictDao,
private val eventDao: SyncEventDao,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
return try {
val result = performSync(pair, provider)
val finalStatus = when {
result.failedFiles > 0 && result.conflicts > 0 -> SyncStatus.CONFLICT
result.failedFiles > 0 -> SyncStatus.PARTIAL
result.conflicts > 0 -> SyncStatus.CONFLICT
else -> SyncStatus.SUCCESS
}
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.failedFiles}", result.bytesTransferred)
result
} catch (e: Exception) {
Timber.e(e, "Sync failed for pair ${pair.id}")
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, e.message, 0)
SyncResult(failedFiles = 1, error = e)
}
}
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult {
val localRoot = File(pair.localPath)
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow().associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = localRoot.walkFiles(pair)
var uploaded = 0; var downloaded = 0; var deleted = 0; var skipped = 0; var failed = 0; var conflicts = 0
var bytesTransferred = 0L
val newStates = mutableListOf<SyncFileStateEntity>()
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
// Fan out with bounded parallelism
val semaphore = Semaphore(4)
coroutineScope {
allPaths.map { rel ->
async {
semaphore.withPermit {
val local = localFiles[rel]
val remote = remoteFiles[rel]
val known = knownStates[rel]
val decision = decide(pair.syncDirection, pair.conflictStrategy, pair.deleteBehavior, local, remote, known)
when (decision) {
SyncDecision.UPLOAD -> {
val file = File(localRoot, rel)
val bytes = runCatching {
file.inputStream().use { stream ->
provider.uploadFile(stream, "${pair.remotePath}/$rel", file.length()) { }
}
file.length()
}.getOrElse { e ->
Timber.e(e, "Upload failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit
}
uploaded++
bytesTransferred += bytes
newStates += buildState(pair.id, rel, file, remote)
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
}
SyncDecision.DOWNLOAD -> {
val dest = File(localRoot, rel).also { it.parentFile?.mkdirs() }
val bytes = runCatching {
dest.outputStream().use { stream ->
provider.downloadFile("${pair.remotePath}/$rel", stream) { }
}
remote!!.sizeBytes
}.getOrElse { e ->
Timber.e(e, "Download failed: $rel")
failed++
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, e.message, 0)
return@withPermit
}
downloaded++
bytesTransferred += bytes
newStates += buildState(pair.id, rel, dest, remote)
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
}
SyncDecision.DELETE_LOCAL -> {
File(localRoot, rel).delete()
fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
}
SyncDecision.DELETE_REMOTE -> {
provider.deleteFile("${pair.remotePath}/$rel")
fileStateDao.delete(pair.id, rel)
deleted++
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
}
SyncDecision.CONFLICT -> {
conflicts++
conflictDao.insert(SyncConflictEntity(
syncPairId = pair.id,
relativePath = rel,
localModifiedAt = local?.lastModified()?.let { Instant.ofEpochMilli(it) } ?: Instant.EPOCH,
localSizeBytes = local?.length() ?: 0L,
remoteModifiedAt = remote?.modifiedAt ?: Instant.EPOCH,
remoteSizeBytes = remote?.sizeBytes ?: 0L,
resolution = null,
detectedAt = Instant.now(),
))
logEvent(pair.id, SyncEventType.CONFLICT_DETECTED, rel, null, 0)
}
SyncDecision.SKIP -> skipped++
}
}
}
}.awaitAll()
}
fileStateDao.upsertAll(newStates)
return SyncResult(uploaded, downloaded, deleted, skipped, failed, conflicts, bytesTransferred)
}
private fun decide(
direction: SyncDirection,
conflictStrategy: ConflictStrategy,
deleteBehavior: DeleteBehavior,
local: File?,
remote: RemoteFile?,
known: SyncFileStateEntity?,
): SyncDecision {
val localExists = local?.exists() == true
val remoteExists = remote != null
val localChanged = known == null || (localExists && local!!.lastModified() != known.localModifiedAt?.toEpochMilli())
val remoteChanged = known == null || (remoteExists && remote!!.etag != known.remoteEtag && remote.modifiedAt != known.remoteModifiedAt)
return when {
!localExists && !remoteExists -> SyncDecision.SKIP
// File only exists locally
localExists && !remoteExists -> when {
known == null -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
// Remote was deleted — respect deleteBehavior
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.DOWNLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_LOCAL
else -> SyncDecision.SKIP
}
}
// File only exists remotely
!localExists && remoteExists -> when {
known == null -> when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
// Local was deleted — respect deleteBehavior
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
// Both changed — conflict
localChanged && remoteChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY -> SyncDecision.UPLOAD
SyncDirection.DOWNLOAD_ONLY -> SyncDecision.DOWNLOAD
SyncDirection.TWO_WAY -> when (conflictStrategy) {
ConflictStrategy.KEEP_LOCAL -> SyncDecision.UPLOAD
ConflictStrategy.KEEP_REMOTE -> SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_NEWEST -> if ((local?.lastModified() ?: 0L) >= (remote?.modifiedAt?.toEpochMilli() ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_LARGEST -> if ((local?.length() ?: 0L) >= (remote?.sizeBytes ?: 0L)) SyncDecision.UPLOAD else SyncDecision.DOWNLOAD
ConflictStrategy.KEEP_BOTH -> SyncDecision.CONFLICT // engine keeps both via rename
ConflictStrategy.ASK -> SyncDecision.CONFLICT
}
}
localChanged -> when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.UPLOAD
else -> SyncDecision.SKIP
}
remoteChanged -> when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
else -> SyncDecision.SKIP
}
}
private fun buildState(pairId: Long, rel: String, local: File, remote: RemoteFile?) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = if (local.exists()) Instant.ofEpochMilli(local.lastModified()) else null,
localSizeBytes = local.length(),
localHash = null,
remoteModifiedAt = remote?.modifiedAt,
remoteSizeBytes = remote?.sizeBytes ?: 0L,
remoteEtag = remote?.etag,
lastSyncedAt = Instant.now(),
syncedHash = null,
)
private suspend fun logEvent(pairId: Long, type: SyncEventType, file: String?, msg: String?, bytes: Long) {
eventDao.insert(SyncEventEntity(syncPairId = pairId, timestamp = Instant.now(), eventType = type, filePath = file, message = msg, bytesTransferred = bytes))
}
private fun File.walkFiles(pair: SyncPair): Map<String, File> {
if (!exists()) return emptyMap()
val includeExts = pair.includeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val excludeExts = pair.excludeExtensions.map { it.lowercase().trimStart('.') }.toSet()
val minBytes = pair.minFileSizeKb * 1024
val maxBytes = if (pair.maxFileSizeKb > 0) pair.maxFileSizeKb * 1024 else Long.MAX_VALUE
return walkTopDown()
.onEnter { dir ->
pair.recursive || dir == this
}
.filter { it.isFile }
.filter { f -> !pair.skipHiddenFiles || !f.name.startsWith('.') }
.filter { f -> pair.excludePatterns.none { pat -> f.name.matches(pat.toGlob()) } }
.filter { f ->
val ext = f.extension.lowercase()
(includeExts.isEmpty() || ext in includeExts) && ext !in excludeExts
}
.filter { f -> f.length() >= minBytes && f.length() <= maxBytes }
.associate { f -> f.relativeTo(this).path to f }
}
private fun String.toGlob(): Regex =
Regex(replace(".", "\\.").replace("*", ".*").replace("?", "."), RegexOption.IGNORE_CASE)
}
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
data class SyncResult(
val uploaded: Int = 0,
val downloaded: Int = 0,
val deleted: Int = 0,
val skipped: Int = 0,
val failedFiles: Int = 0,
val conflicts: Int = 0,
val bytesTransferred: Long = 0L,
val error: Exception? = null,
)
@@ -0,0 +1,404 @@
package com.syncflow.ui.addpair
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.*
import com.syncflow.ui.browser.RemoteBrowserDialog
import java.time.DayOfWeek
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
var showRemoteBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
uri?.let { vm.update { copy(localPath = it.toString()) } }
}
if (showRemoteBrowser && s.selectedAccountId != -1L) {
RemoteBrowserDialog(
accountId = s.selectedAccountId,
initialPath = s.remotePath.ifBlank { "/" },
onSelect = { path ->
vm.update { copy(remotePath = path) }
showRemoteBrowser = false
},
onDismiss = { showRemoteBrowser = false },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (s.name.isBlank()) "New Sync Pair" else s.name, fontWeight = FontWeight.SemiBold) },
navigationIcon = { IconButton(onClick = onDone) { Icon(Icons.Default.Close, null) } },
actions = {
TextButton(onClick = vm::save, enabled = !s.isSaving) {
if (s.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Save", fontWeight = FontWeight.SemiBold)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// ── Pair name ────────────────────────────────────────────────────
Section(title = null) {
OutlinedTextField(
value = s.name, onValueChange = { vm.update { copy(name = it) } },
label = { Text("Sync pair name") },
leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
}
// ── Folders ──────────────────────────────────────────────────────
Section(title = "Folders", icon = Icons.Default.FolderOpen) {
// Account
if (s.accounts.isEmpty()) {
Text("No accounts added — go to Settings first.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} else {
Text("Cloud account", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
s.accounts.forEachIndexed { idx, acct ->
SegmentedButton(
selected = s.selectedAccountId == acct.id,
onClick = { vm.update { copy(selectedAccountId = acct.id, remotePath = "") } },
shape = SegmentedButtonDefaults.itemShape(idx, s.accounts.size),
label = { Text(acct.displayName, maxLines = 1) },
)
}
}
}
Spacer(Modifier.height(4.dp))
// Local folder
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = {
IconButton(onClick = { dirPicker.launch(null) }) {
Icon(Icons.Default.FolderOpen, "Browse")
}
},
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
// Remote folder
OutlinedTextField(
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } },
label = { Text("Remote folder") },
leadingIcon = { Icon(Icons.Default.Cloud, null) },
trailingIcon = {
IconButton(
onClick = { if (s.selectedAccountId != -1L) showRemoteBrowser = true },
enabled = s.selectedAccountId != -1L,
) { Icon(Icons.Default.Folder, "Browse remote") }
},
singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("/ or /Documents/Photos") },
)
// Recursive
ToggleRow(
label = "Include subfolders",
description = "Sync all nested folders recursively",
checked = s.recursive,
onToggle = { vm.update { copy(recursive = it) } },
)
}
// ── Sync type ────────────────────────────────────────────────────
Section(title = "Sync Type", icon = Icons.Default.SyncAlt) {
RadioGroup(
label = "Direction",
options = SyncDirection.entries,
selected = s.syncDirection,
onSelect = { vm.update { copy(syncDirection = it) } },
itemLabel = { "${it.label}${it.description}" },
)
Spacer(Modifier.height(8.dp))
RadioGroup(
label = "Conflict resolution",
options = ConflictStrategy.entries,
selected = s.conflictStrategy,
onSelect = { vm.update { copy(conflictStrategy = it) } },
itemLabel = { it.label },
)
Spacer(Modifier.height(8.dp))
RadioGroup(
label = "Deletion behaviour",
options = DeleteBehavior.entries,
selected = s.deleteBehavior,
onSelect = { vm.update { copy(deleteBehavior = it) } },
itemLabel = { "${it.label}${it.description}" },
)
}
// ── Schedule ─────────────────────────────────────────────────────
Section(title = "Schedule", icon = Icons.Default.Schedule) {
RadioGroup(
label = null,
options = ScheduleType.entries,
selected = s.scheduleType,
onSelect = { vm.update { copy(scheduleType = it) } },
itemLabel = { it.label },
)
AnimatedVisibility(s.scheduleType == ScheduleType.INTERVAL) {
Column(Modifier.padding(top = 8.dp)) {
OutlinedTextField(
value = s.intervalMinutes.toString(),
onValueChange = { vm.update { copy(intervalMinutes = it.toIntOrNull()?.coerceAtLeast(15) ?: 15) } },
label = { Text("Interval (minutes, min 15)") },
leadingIcon = { Icon(Icons.Default.Timer, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
AnimatedVisibility(s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) {
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = s.dailyTime,
onValueChange = { vm.update { copy(dailyTime = it) } },
label = { Text("Time (HH:mm)") },
leadingIcon = { Icon(Icons.Default.AccessTime, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("02:00") },
)
if (s.scheduleType == ScheduleType.WEEKLY) {
WeekdayPicker(weekdays = s.weekdays, onChange = { vm.update { copy(weekdays = it) } })
}
}
}
}
// ── Conditions ───────────────────────────────────────────────────
Section(title = "Run Conditions", icon = Icons.Default.Tune) {
ToggleRow("Wi-Fi only", "Only sync on unmetered Wi-Fi connections", s.wifiOnly) {
vm.update { copy(wifiOnly = it, wifiSsid = if (!it) "" else wifiSsid) }
}
AnimatedVisibility(s.wifiOnly) {
OutlinedTextField(
value = s.wifiSsid,
onValueChange = { vm.update { copy(wifiSsid = it) } },
label = { Text("Specific Wi-Fi network (SSID)") },
placeholder = { Text("Leave blank for any Wi-Fi") },
leadingIcon = { Icon(Icons.Default.Wifi, null) },
singleLine = true, modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
ToggleRow("Charging only", "Only sync when device is charging", s.chargingOnly) {
vm.update { copy(chargingOnly = it) }
}
Spacer(Modifier.height(4.dp))
Text("Minimum battery level: ${if (s.minBatteryPct == 0) "None" else "${s.minBatteryPct}%"}",
style = MaterialTheme.typography.bodySmall)
Slider(
value = s.minBatteryPct.toFloat(),
onValueChange = { vm.update { copy(minBatteryPct = it.toInt()) } },
valueRange = 0f..90f, steps = 17,
modifier = Modifier.fillMaxWidth(),
)
}
// ── File filters ─────────────────────────────────────────────────
Section(title = "File Filters", icon = Icons.Default.FilterAlt) {
ToggleRow("Skip hidden files", "Skip files/folders starting with '.'", s.skipHiddenFiles) {
vm.update { copy(skipHiddenFiles = it) }
}
Spacer(Modifier.height(8.dp))
Text("Extensions to sync (leave blank = all files)",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = s.includeExtensions,
onValueChange = { vm.update { copy(includeExtensions = it) } },
label = { Text("Include only (e.g. jpg png pdf)") },
placeholder = { Text("Space-separated, blank = all") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(4.dp))
OutlinedTextField(
value = s.excludeExtensions,
onValueChange = { vm.update { copy(excludeExtensions = it) } },
label = { Text("Exclude extensions (e.g. tmp bak log)") },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text("Exclude filename patterns",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(
value = s.excludePatterns,
onValueChange = { vm.update { copy(excludePatterns = it) } },
label = { Text("One pattern per line (supports *)") },
modifier = Modifier.fillMaxWidth().height(90.dp),
)
Spacer(Modifier.height(8.dp))
Text("File size limits (0 = no limit)",
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = if (s.minFileSizeKb == 0L) "" else s.minFileSizeKb.toString(),
onValueChange = { vm.update { copy(minFileSizeKb = it.toLongOrNull() ?: 0L) } },
label = { Text("Min size (KB)") },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = if (s.maxFileSizeKb == 0L) "" else s.maxFileSizeKb.toString(),
onValueChange = { vm.update { copy(maxFileSizeKb = it.toLongOrNull() ?: 0L) } },
label = { Text("Max size (KB)") },
singleLine = true, modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
}
// ── Notifications ────────────────────────────────────────────────
Section(title = "Notifications", icon = Icons.Default.Notifications) {
ToggleRow("Notify on sync complete", "Show notification when sync finishes successfully", s.notifyOnComplete) {
vm.update { copy(notifyOnComplete = it) }
}
ToggleRow("Notify on errors", "Show notification when sync encounters errors", s.notifyOnError) {
vm.update { copy(notifyOnError = it) }
}
}
// ── Error ────────────────────────────────────────────────────────
s.error?.let { err ->
Box(Modifier.padding(horizontal = 20.dp)) {
Text(err, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
Spacer(Modifier.height(40.dp))
}
}
}
// ─── Section wrapper ──────────────────────────────────────────────────────────
@Composable
private fun Section(
title: String?,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) {
if (title != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp)) {
icon?.let {
Icon(it, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(6.dp))
}
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
}
HorizontalDivider()
Spacer(Modifier.height(8.dp))
}
content()
}
}
// ─── Reusable row components ──────────────────────────────────────────────────
@Composable
private fun ToggleRow(label: String, description: String, checked: Boolean, onToggle: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = checked, onCheckedChange = onToggle)
}
}
@Composable
private fun <T> RadioGroup(
label: String?,
options: List<T>,
selected: T,
onSelect: (T) -> Unit,
itemLabel: (T) -> String,
) {
Column {
label?.let {
Text(it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 2.dp))
}
options.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
Text(itemLabel(option), style = MaterialTheme.typography.bodyMedium)
}
}
}
}
@Composable
private fun WeekdayPicker(weekdays: Int, onChange: (Int) -> Unit) {
val days = listOf("M", "T", "W", "T", "F", "S", "S")
val fullNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
Column {
Text("Repeat on", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
days.forEachIndexed { i, label ->
val bit = 1 shl i
val selected = (weekdays and bit) != 0
FilterChip(
selected = selected,
onClick = { onChange(if (selected) weekdays and bit.inv() else weekdays or bit) },
label = { Text(label) },
modifier = Modifier.weight(1f),
)
}
}
}
}
private fun uriToDisplay(uriString: String): String {
if (uriString.isBlank()) return ""
return try {
val uri = android.net.Uri.parse(uriString)
uri.lastPathSegment?.replace(":", "/") ?: uriString
} catch (e: Exception) {
uriString
}
}
@@ -0,0 +1,154 @@
package com.syncflow.ui.addpair
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AddPairUiState(
// ── Identity ─────────────────────────────────────────────────────────────
val name: String = "",
// ── Folders ──────────────────────────────────────────────────────────────
val localPath: String = "",
val remotePath: String = "",
val selectedAccountId: Long = -1L,
val accounts: List<CloudAccountEntity> = emptyList(),
// ── Sync type ────────────────────────────────────────────────────────────
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
val recursive: Boolean = true,
// ── Schedule ─────────────────────────────────────────────────────────────
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
val intervalMinutes: Int = 30,
val dailyTime: String = "02:00",
val weekdays: Int = 0b1111111, // all 7 days by default
// ── Constraints ──────────────────────────────────────────────────────────
val wifiOnly: Boolean = true,
val wifiSsid: String = "",
val chargingOnly: Boolean = false,
val minBatteryPct: Int = 0,
// ── File filters ─────────────────────────────────────────────────────────
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db",
val includeExtensions: String = "",
val excludeExtensions: String = "",
val skipHiddenFiles: Boolean = true,
val minFileSizeKb: Long = 0L,
val maxFileSizeKb: Long = 0L,
// ── Notifications ────────────────────────────────────────────────────────
val notifyOnComplete: Boolean = false,
val notifyOnError: Boolean = true,
// ── Form state ───────────────────────────────────────────────────────────
val isSaving: Boolean = false,
val error: String? = null,
val done: Boolean = false,
)
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val accountDao: CloudAccountDao,
savedState: SavedStateHandle,
) : ViewModel() {
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
private val _state = MutableStateFlow(AddPairUiState())
val state = _state.asStateFlow()
init {
viewModelScope.launch {
accountDao.observeAll().collect { accounts ->
_state.update { s ->
s.copy(
accounts = accounts,
selectedAccountId = if (s.selectedAccountId == -1L) accounts.firstOrNull()?.id ?: -1L else s.selectedAccountId,
)
}
}
}
editPairId?.let { id ->
viewModelScope.launch {
syncPairDao.getById(id)?.let { pair ->
_state.update { _ ->
AddPairUiState(
name = pair.name,
localPath = pair.localPath,
remotePath = pair.remotePath,
selectedAccountId = pair.accountId,
syncDirection = pair.syncDirection,
conflictStrategy = pair.conflictStrategy,
deleteBehavior = pair.deleteBehavior,
recursive = pair.recursive,
scheduleType = pair.scheduleType,
intervalMinutes = pair.scheduleIntervalMinutes,
dailyTime = pair.scheduleDailyTime ?: "02:00",
weekdays = pair.scheduleWeekdays,
wifiOnly = pair.wifiOnly,
wifiSsid = pair.wifiSsid,
chargingOnly = pair.chargingOnly,
minBatteryPct = pair.minBatteryPct,
excludePatterns = pair.excludePatterns,
includeExtensions = pair.includeExtensions,
excludeExtensions = pair.excludeExtensions,
skipHiddenFiles = pair.skipHiddenFiles,
minFileSizeKb = pair.minFileSizeKb,
maxFileSizeKb = pair.maxFileSizeKb,
notifyOnComplete = pair.notifyOnComplete,
notifyOnError = pair.notifyOnError,
)
}
}
}
}
}
fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform)
fun save() {
val s = _state.value
val errors = buildList {
if (s.name.isBlank()) add("Name is required")
if (s.localPath.isBlank()) add("Local folder is required")
if (s.remotePath.isBlank()) add("Remote folder is required")
if (s.selectedAccountId == -1L) add("Select a cloud account")
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
}
if (errors.isNotEmpty()) { _state.update { it.copy(error = errors.first()) }; return }
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) }
runCatching {
val entity = SyncPairEntity(
id = editPairId ?: 0L,
name = s.name, localPath = s.localPath, remotePath = s.remotePath,
accountId = s.selectedAccountId,
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes,
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null,
scheduleWeekdays = s.weekdays,
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid,
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct,
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions,
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles,
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb,
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity)
}
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
}
@@ -0,0 +1,494 @@
package com.syncflow.ui.auth
import android.accounts.AccountManager
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.R
import com.syncflow.domain.model.ProviderType
// All providers in display order
private val ALL_PROVIDERS = listOf(
ProviderType.NEXTCLOUD,
ProviderType.OWNCLOUD,
ProviderType.SFTPGO,
ProviderType.WEBDAV,
ProviderType.SFTP,
ProviderType.GOOGLE_DRIVE,
ProviderType.DROPBOX,
ProviderType.ONEDRIVE,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSetupScreen(
onDone: () -> Unit,
vm: AccountSetupViewModel = hiltViewModel(),
) {
val state by vm.state.collectAsState()
val context = LocalContext.current
LaunchedEffect(state.done) { if (state.done) onDone() }
val googleAccountLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val name = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
if (name != null) vm.onGoogleAccountChosen(name)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
if (state.providerType == null) "Choose a service" else state.providerType!!.friendlyName(),
fontWeight = FontWeight.SemiBold,
)
},
navigationIcon = {
IconButton(onClick = {
if (state.providerType != null) vm.update { copy(providerType = null, testResult = null, error = null) }
else onDone()
}) {
Icon(
if (state.providerType != null) Icons.Default.ArrowBack else Icons.Default.Close,
contentDescription = null,
)
}
},
actions = {
if (state.providerType != null) {
TextButton(
onClick = vm::save,
enabled = !state.isSaving && state.testResult is TestResult.Success,
) {
if (state.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Text("Save")
}
}
},
)
},
) { padding ->
if (state.providerType == null) {
ProviderPickerContent(
modifier = Modifier.padding(padding),
onPick = { vm.update { copy(providerType = it) } },
)
} else {
CredentialContent(
state = state,
modifier = Modifier.padding(padding),
vm = vm,
onDropboxConnect = {
launchDropboxOAuth(context, vm.credentialStore, context.getString(R.string.dropbox_app_key))
},
onGoogleSignIn = {
val intent = AccountManager.newChooseAccountIntent(
null, null, arrayOf("com.google"), null, null, null, null,
)
googleAccountLauncher.launch(intent)
},
onOneDriveSignIn = {
launchOneDriveOAuth(context, vm.credentialStore, context.getString(R.string.onedrive_client_id))
},
)
}
}
}
// ─── Step 1: Provider picker grid ────────────────────────────────────────────
@Composable
private fun ProviderPickerContent(modifier: Modifier = Modifier, onPick: (ProviderType) -> Unit) {
Column(modifier = modifier.fillMaxSize()) {
Text(
"Select the service you want to sync with",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize(),
) {
items(ALL_PROVIDERS) { provider ->
ProviderCard(provider = provider, onClick = { onPick(provider) })
}
}
}
}
@Composable
private fun ProviderCard(provider: ProviderType, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1.4f)
.clip(MaterialTheme.shapes.large)
.clickable(onClick = onClick),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(provider.iconRes()),
contentDescription = null,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(10.dp))
Text(
provider.friendlyName(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
provider.subtitle()?.let { sub ->
Text(
sub,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// ─── Step 2: Credential form ──────────────────────────────────────────────────
@Composable
private fun CredentialContent(
state: AccountSetupState,
modifier: Modifier,
vm: AccountSetupViewModel,
onDropboxConnect: () -> Unit,
onGoogleSignIn: () -> Unit,
onOneDriveSignIn: () -> Unit,
) {
val provider = state.providerType ?: return
Column(
modifier = modifier
.padding(horizontal = 20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Spacer(Modifier.height(4.dp))
// Account display name
OutlinedTextField(
value = state.displayName,
onValueChange = { vm.update { copy(displayName = it) } },
label = { Text("Account name (optional)") },
placeholder = { Text(provider.friendlyName()) },
leadingIcon = { Icon(Icons.Default.Badge, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
HorizontalDivider()
// Provider-specific fields
when {
provider == ProviderType.GOOGLE_DRIVE -> OAuthSection(
providerName = "Google Drive",
email = state.oauthEmail,
isConnected = state.oauthToken.isNotBlank(),
onConnect = onGoogleSignIn,
connectLabel = "Choose Google Account",
description = "Picks any Google account already added to this device.",
)
provider == ProviderType.DROPBOX -> OAuthSection(
providerName = "Dropbox",
email = state.oauthEmail,
isConnected = state.oauthToken.isNotBlank(),
onConnect = onDropboxConnect,
connectLabel = "Authorize with Dropbox",
description = "Opens Dropbox in your browser. Works with any Dropbox account.",
)
provider == ProviderType.ONEDRIVE -> OAuthSection(
providerName = "OneDrive",
email = state.oauthEmail,
isConnected = state.oauthToken.isNotBlank(),
onConnect = onOneDriveSignIn,
connectLabel = "Sign in with Microsoft",
description = "Personal, work, or school Microsoft account.",
)
provider == ProviderType.SFTP -> SftpFields(state, vm)
else -> ServerFields(provider, state, vm)
}
// Test Connection (required before Save)
HorizontalDivider()
Button(
onClick = vm::testConnection,
enabled = !state.isTestingConnection && credentialsFilled(state),
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = when (state.testResult) {
is TestResult.Success -> MaterialTheme.colorScheme.primary
is TestResult.Failure -> MaterialTheme.colorScheme.error
null -> MaterialTheme.colorScheme.secondary
},
),
) {
when {
state.isTestingConnection -> {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
Spacer(Modifier.width(10.dp))
Text("Testing…")
}
state.testResult is TestResult.Success -> {
Icon(Icons.Default.CheckCircle, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Connected — tap Save ↑")
}
state.testResult is TestResult.Failure -> {
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Retry Test Connection")
}
else -> {
Icon(Icons.Default.NetworkCheck, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Test Connection")
}
}
}
when (val r = state.testResult) {
is TestResult.Failure -> Text(r.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
is TestResult.Success -> Text("Connection successful!", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
null -> if (credentialsFilled(state)) Text("Test required before saving.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.height(32.dp))
}
}
// ─── Credential field composables ────────────────────────────────────────────
@Composable
private fun OAuthSection(
providerName: String,
email: String,
isConnected: Boolean,
onConnect: () -> Unit,
connectLabel: String,
description: String,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!isConnected) {
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
Column {
Text("Authorized", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
if (email.isNotBlank()) Text(email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
OutlinedButton(onClick = onConnect, modifier = Modifier.fillMaxWidth()) {
Icon(if (isConnected) Icons.Default.SwitchAccount else Icons.Default.Login, null, Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(if (isConnected) "Switch account" else connectLabel)
}
}
}
@Composable
private fun ServerFields(provider: ProviderType, state: AccountSetupState, vm: AccountSetupViewModel) {
val urlHint = when (provider) {
ProviderType.NEXTCLOUD -> "https://cloud.example.com"
ProviderType.OWNCLOUD -> "https://owncloud.example.com"
ProviderType.SFTPGO -> "https://sftpgo.example.com"
else -> "https://dav.example.com"
}
val urlNote = when (provider) {
ProviderType.NEXTCLOUD -> "Just the base URL — WebDAV path is added automatically."
ProviderType.OWNCLOUD -> "Just the base URL — WebDAV path is added automatically."
ProviderType.SFTPGO -> "Base URL only — SFTPGo WebDAV path added automatically."
else -> null
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.serverUrl,
onValueChange = { vm.update { copy(serverUrl = it) } },
label = { Text("Server URL") },
placeholder = { Text(urlHint) },
leadingIcon = { Icon(Icons.Default.Language, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
urlNote?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
if (state.httpWarning) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.Warning, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
Text(
"HTTP sends credentials in plaintext. Use HTTPS for security.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error,
)
}
}
OutlinedTextField(
value = state.username,
onValueChange = { vm.update { copy(username = it) } },
label = { Text("Username") },
leadingIcon = { Icon(Icons.Default.Person, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
}
}
@Composable
private fun SftpFields(state: AccountSetupState, vm: AccountSetupViewModel) {
var useKey by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.serverUrl,
onValueChange = { vm.update { copy(serverUrl = it) } },
label = { Text("Hostname or IP") },
placeholder = { Text("sftp.example.com") },
leadingIcon = { Icon(Icons.Default.Dns, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
OutlinedTextField(
value = state.port,
onValueChange = { vm.update { copy(port = it) } },
label = { Text("Port") },
placeholder = { Text("22") },
leadingIcon = { Icon(Icons.Default.SettingsEthernet, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
)
OutlinedTextField(
value = state.username,
onValueChange = { vm.update { copy(username = it) } },
label = { Text("Username") },
leadingIcon = { Icon(Icons.Default.Person, null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useKey, onCheckedChange = { useKey = it })
Spacer(Modifier.width(10.dp))
Text("Use SSH private key", style = MaterialTheme.typography.bodySmall)
}
if (useKey) {
OutlinedTextField(
value = state.privateKey,
onValueChange = { vm.update { copy(privateKey = it) } },
label = { Text("Private key (PEM)") },
placeholder = { Text("-----BEGIN RSA PRIVATE KEY-----\n") },
modifier = Modifier.fillMaxWidth().height(120.dp),
)
} else {
PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password")
}
}
}
@Composable
private fun PasswordField(value: String, onValueChange: (String) -> Unit, label: String) {
var visible by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
}
},
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
private fun credentialsFilled(state: AccountSetupState): Boolean {
val p = state.providerType ?: return false
return when {
p.isOAuth() -> state.oauthToken.isNotBlank()
p == ProviderType.SFTP -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
else -> state.serverUrl.isNotBlank() && state.username.isNotBlank()
}
}
private fun ProviderType.iconRes() = when (this) {
ProviderType.NEXTCLOUD -> R.drawable.ic_provider_nextcloud
ProviderType.OWNCLOUD -> R.drawable.ic_provider_owncloud
ProviderType.SFTPGO -> R.drawable.ic_provider_sftpgo
ProviderType.WEBDAV -> R.drawable.ic_provider_webdav
ProviderType.SFTP -> R.drawable.ic_provider_sftp
ProviderType.GOOGLE_DRIVE -> R.drawable.ic_provider_googledrive
ProviderType.DROPBOX -> R.drawable.ic_provider_dropbox
ProviderType.ONEDRIVE -> R.drawable.ic_provider_onedrive
}
private fun ProviderType.subtitle() = when (this) {
ProviderType.NEXTCLOUD -> "Self-hosted"
ProviderType.OWNCLOUD -> "Self-hosted"
ProviderType.SFTPGO -> "Self-hosted"
ProviderType.WEBDAV -> "Any WebDAV server"
ProviderType.SFTP -> "SSH file transfer"
ProviderType.GOOGLE_DRIVE -> "Google account"
ProviderType.DROPBOX -> "Dropbox account"
ProviderType.ONEDRIVE -> "Microsoft account"
}
@@ -0,0 +1,201 @@
package com.syncflow.ui.auth
import android.accounts.Account
import android.accounts.AccountManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.model.ProviderType
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.*
import javax.inject.Inject
data class AccountSetupState(
val providerType: ProviderType? = null,
val displayName: String = "",
val serverUrl: String = "",
val port: String = "",
val username: String = "",
val password: String = "",
val privateKey: String = "",
val oauthToken: String = "",
val oauthEmail: String = "",
val httpWarning: Boolean = false,
val isTestingConnection: Boolean = false,
val testResult: TestResult? = null,
val isSaving: Boolean = false,
val error: String? = null,
val done: Boolean = false,
)
sealed interface TestResult {
object Success : TestResult
data class Failure(val message: String) : TestResult
}
@HiltViewModel
class AccountSetupViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
val credentialStore: com.syncflow.data.security.CredentialStore,
@ApplicationContext private val context: Context,
) : ViewModel() {
private val _state = MutableStateFlow(AccountSetupState())
val state = _state.asStateFlow()
private val oauthReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
val token = intent.getStringExtra(OAUTH_EXTRA_TOKEN) ?: return
val email = intent.getStringExtra(OAUTH_EXTRA_EMAIL) ?: ""
_state.update { s ->
s.copy(
oauthToken = token,
oauthEmail = email,
displayName = s.displayName.ifBlank { email.ifBlank { s.providerType?.friendlyName() ?: "" } },
)
}
}
}
init {
LocalBroadcastManager.getInstance(context)
.registerReceiver(oauthReceiver, IntentFilter(OAUTH_REDIRECT_ACTION))
}
override fun onCleared() {
LocalBroadcastManager.getInstance(context).unregisterReceiver(oauthReceiver)
super.onCleared()
}
fun update(transform: AccountSetupState.() -> AccountSetupState) {
_state.update { s ->
val next = transform(s).copy(testResult = null)
next.copy(httpWarning = next.serverUrl.startsWith("http://", ignoreCase = true))
}
}
fun onGoogleAccountChosen(accountName: String) {
_state.update { it.copy(oauthEmail = accountName, oauthToken = "google_account:$accountName", displayName = it.displayName.ifBlank { accountName }) }
}
fun testConnection() {
val entity = buildEntity() ?: run {
_state.update { it.copy(error = "Fill in all required fields first") }
return
}
viewModelScope.launch {
_state.update { it.copy(isTestingConnection = true, testResult = null, error = null) }
val provider = runCatching { providerFactory.create(entity.toDomain()) }.getOrElse { e ->
_state.update { it.copy(isTestingConnection = false, testResult = TestResult.Failure(e.message ?: "Provider error")) }
return@launch
}
val result = provider.testConnection()
_state.update { s ->
s.copy(
isTestingConnection = false,
testResult = if (result.isSuccess) TestResult.Success else TestResult.Failure(result.exceptionOrNull()?.message ?: "Connection failed"),
)
}
}
}
fun save() {
val built = buildEntityWithCreds() ?: run {
_state.update { it.copy(error = "Fill in all required fields") }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) }
runCatching { accountRepository.insert(built.first, built.second) }
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
/** Returns Pair(entity, credentialJson) or null if validation fails. */
private fun buildEntityWithCreds(): Pair<CloudAccountEntity, String>? {
val s = _state.value
val provider = s.providerType ?: return null
val credJson: String
val serverUrl: String?
val port: Int?
val email: String?
when {
provider.isOAuth() -> {
if (s.oauthToken.isBlank()) return null
credJson = buildJsonObject { put("access_token", s.oauthToken) }.toString()
serverUrl = null
port = null
email = s.oauthEmail.ifBlank { null }
}
provider == ProviderType.SFTP -> {
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
credJson = buildJsonObject {
put("username", s.username)
if (s.privateKey.isNotBlank()) put("private_key", s.privateKey)
else put("password", s.password)
}.toString()
serverUrl = s.serverUrl
port = s.port.toIntOrNull() ?: 22
email = s.username
}
else -> {
if (s.serverUrl.isBlank() || s.username.isBlank()) return null
credJson = buildJsonObject {
put("username", s.username)
put("password", s.password)
}.toString()
serverUrl = s.serverUrl.trimEnd('/')
port = s.port.toIntOrNull()
email = s.username
}
}
val entity = CloudAccountEntity(
displayName = s.displayName.ifBlank { provider.friendlyName() },
email = email,
providerType = provider,
credentialJson = "", // never persisted to plaintext DB
serverUrl = serverUrl,
port = port,
)
return Pair(entity, credJson)
}
// testConnection() still uses in-memory credentials — build a temporary CloudAccount
private fun buildEntity(): CloudAccountEntity? = buildEntityWithCreds()?.let { (entity, cred) ->
entity.copy(credentialJson = cred)
}
}
fun ProviderType.isOAuth() = this in setOf(ProviderType.GOOGLE_DRIVE, ProviderType.DROPBOX, ProviderType.ONEDRIVE)
fun ProviderType.friendlyName() = when (this) {
ProviderType.GOOGLE_DRIVE -> "Google Drive"
ProviderType.DROPBOX -> "Dropbox"
ProviderType.ONEDRIVE -> "OneDrive"
ProviderType.WEBDAV -> "WebDAV"
ProviderType.SFTP -> "SFTP"
ProviderType.NEXTCLOUD -> "Nextcloud"
ProviderType.OWNCLOUD -> "ownCloud"
ProviderType.SFTPGO -> "SFTPGo"
}
private fun CloudAccountEntity.toDomain() = com.syncflow.domain.model.CloudAccount(
id = id, displayName = displayName, email = email, providerType = providerType,
credentialJson = credentialJson, serverUrl = serverUrl, port = port,
)
@@ -0,0 +1,3 @@
package com.syncflow.ui.auth
// Removed — OneDrive auth is now handled via OAuthHelper (PKCE + Chrome Custom Tab)
@@ -0,0 +1,120 @@
package com.syncflow.ui.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import androidx.browser.customtabs.CustomTabsIntent
import com.syncflow.data.security.CredentialStore
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.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.security.MessageDigest
import java.security.SecureRandom
const val OAUTH_REDIRECT_ACTION = "com.syncflow.OAUTH_RESULT"
const val OAUTH_EXTRA_TOKEN = "token"
const val OAUTH_EXTRA_EMAIL = "email"
const val OAUTH_EXTRA_PROVIDER = "provider"
private val client = OkHttpClient()
private val random = SecureRandom()
private fun generateVerifier(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
private fun generateChallenge(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier)
val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" +
"&response_type=code" +
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&token_access_type=offline"
openCustomTab(context, url)
}
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier)
val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
"?client_id=$clientId" +
"&response_type=code" +
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256"
openCustomTab(context, url)
}
private fun openCustomTab(context: Context, url: String) {
try {
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
} catch (_: Exception) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK })
}
}
suspend fun exchangeDropboxCode(
credentialStore: CredentialStore,
code: String,
appKey: String,
): Pair<String, String>? = withContext(Dispatchers.IO) {
val verifier = credentialStore.getPkceVerifier("dropbox") ?: return@withContext null
credentialStore.removePkceVerifier("dropbox")
val body = FormBody.Builder()
.add("code", code)
.add("grant_type", "authorization_code")
.add("client_id", appKey)
.add("redirect_uri", "syncflow://oauth/dropbox")
.add("code_verifier", verifier)
.build()
val req = Request.Builder().url("https://api.dropboxapi.com/oauth2/token").post(body).build()
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
val text = resp.body?.string() ?: return@withContext null
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
val email = json["account_id"]?.jsonPrimitive?.content ?: ""
Pair(token, email)
}
suspend fun exchangeOneDriveCode(
credentialStore: CredentialStore,
code: String,
clientId: String,
): Pair<String, String>? = withContext(Dispatchers.IO) {
val verifier = credentialStore.getPkceVerifier("onedrive") ?: return@withContext null
credentialStore.removePkceVerifier("onedrive")
val body = FormBody.Builder()
.add("code", code)
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("redirect_uri", "syncflow://oauth/onedrive")
.add("code_verifier", verifier)
.add("scope", "Files.ReadWrite User.Read offline_access")
.build()
val req = Request.Builder().url("https://login.microsoftonline.com/common/oauth2/v2.0/token").post(body).build()
val resp = runCatching { client.newCall(req).execute() }.getOrNull() ?: return@withContext null
val text = resp.body?.string() ?: return@withContext null
val json = runCatching { Json.parseToJsonElement(text).jsonObject }.getOrNull() ?: return@withContext null
val token = json["access_token"]?.jsonPrimitive?.content ?: return@withContext null
Pair(token, "")
}
@@ -0,0 +1,53 @@
package com.syncflow.ui.auth
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.syncflow.data.security.CredentialStore
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class OAuthRedirectActivity : ComponentActivity() {
@Inject lateinit var credentialStore: CredentialStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return }
val provider = when {
uri.host == "oauth" && uri.path?.contains("dropbox") == true -> "dropbox"
uri.host == "oauth" && uri.path?.contains("onedrive") == true -> "onedrive"
else -> run { finish(); return }
}
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch {
val result = when (provider) {
"dropbox" -> exchangeDropboxCode(credentialStore, code, appKey)
"onedrive" -> exchangeOneDriveCode(credentialStore, code, odClientId)
else -> null
} ?: run { finish(); return@launch }
val broadcast = Intent(OAUTH_REDIRECT_ACTION).apply {
putExtra(OAUTH_EXTRA_TOKEN, result.first)
putExtra(OAUTH_EXTRA_EMAIL, result.second)
putExtra(OAUTH_EXTRA_PROVIDER, provider)
}
LocalBroadcastManager.getInstance(this@OAuthRedirectActivity).sendBroadcast(broadcast)
finish()
}
}
}
@@ -0,0 +1,153 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.RemoteFile
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteBrowserDialog(
accountId: Long,
initialPath: String = "/",
onSelect: (path: String) -> Unit,
onDismiss: () -> Unit,
vm: RemoteBrowserViewModel = hiltViewModel(),
) {
LaunchedEffect(accountId) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState()
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
) {
Column {
// Title bar
TopAppBar(
title = {
Column {
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
Text(
state.currentPath,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
Icon(Icons.Default.ArrowBack, null)
}
},
actions = {
// Select current folder
TextButton(onClick = { onSelect(state.currentPath) }) {
Text("Select here")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
)
HorizontalDivider()
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = MaterialTheme.colorScheme.error)
}
}
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
}
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.entries, key = { it.path }) { entry ->
BrowserEntry(
file = entry,
onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
},
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
)
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
}
}
}
}
}
}
}
@Composable
private fun BrowserEntry(
file: RemoteFile,
onClick: () -> Unit,
onSelectFolder: (() -> Unit)?,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!file.isDirectory) {
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (onSelectFolder != null) {
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
} else {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
private fun Long.formatBytes(): String = when {
this < 1024 -> "${this}B"
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)}KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)}MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)}GB"
}
@@ -0,0 +1,75 @@
package com.syncflow.ui.browser
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.model.RemoteFile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class BrowserState(
val accountId: Long = -1L,
val currentPath: String = "/",
val pathStack: List<String> = listOf("/"),
val entries: List<RemoteFile> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class RemoteBrowserViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
) : ViewModel() {
private val _state = MutableStateFlow(BrowserState())
val state = _state.asStateFlow()
fun init(accountId: Long, startPath: String = "/") {
_state.update { it.copy(accountId = accountId, currentPath = startPath, pathStack = listOf(startPath)) }
loadPath(accountId, startPath)
}
fun navigateTo(path: String) {
val accountId = _state.value.accountId
_state.update { s -> s.copy(currentPath = path, pathStack = s.pathStack + path) }
loadPath(accountId, path)
}
fun navigateUp(): Boolean {
val stack = _state.value.pathStack
if (stack.size <= 1) return false
val newStack = stack.dropLast(1)
val parent = newStack.last()
_state.update { it.copy(currentPath = parent, pathStack = newStack) }
loadPath(_state.value.accountId, parent)
return true
}
private fun loadPath(accountId: Long, path: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val account = accountRepository.getAccount(accountId)
if (account == null) {
_state.update { it.copy(isLoading = false, error = "Account not found") }
return@launch
}
val provider = runCatching { providerFactory.create(account) }.getOrElse { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
return@launch
}
provider.listFiles(path)
.onSuccess { files ->
_state.update { it.copy(isLoading = false, entries = files.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message ?: "Failed to list files") }
}
}
}
}
@@ -0,0 +1,141 @@
package com.syncflow.ui.conflict
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.domain.model.ConflictResolution
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConflictScreen(onBack: () -> Unit, vm: ConflictViewModel = hiltViewModel()) {
val conflicts by vm.conflicts.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Conflicts (${conflicts.size})") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
if (conflicts.isNotEmpty()) {
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = true }) { Icon(Icons.Default.MoreVert, "Menu") }
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(
text = { Text("Keep all local") },
onClick = { vm.resolveAll(ConflictResolution.KEEP_LOCAL); showMenu = false },
)
DropdownMenuItem(
text = { Text("Keep all remote") },
onClick = { vm.resolveAll(ConflictResolution.KEEP_REMOTE); showMenu = false },
)
DropdownMenuItem(
text = { Text("Keep both (rename)") },
onClick = { vm.resolveAll(ConflictResolution.KEEP_BOTH); showMenu = false },
)
}
}
},
)
},
) { padding ->
if (conflicts.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(12.dp))
Text("No conflicts!", style = MaterialTheme.typography.titleMedium)
}
}
} else {
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(conflicts, key = { it.id }) { conflict ->
ConflictCard(conflict = conflict, onResolve = { vm.resolve(conflict, it) })
}
}
}
}
}
@Composable
private fun ConflictCard(conflict: SyncConflictEntity, onResolve: (ConflictResolution) -> Unit) {
val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
conflict.relativePath,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
VersionBox(
label = "Local",
modifiedAt = fmt.format(conflict.localModifiedAt.atZone(zone)),
size = conflict.localSizeBytes.formatBytes(),
icon = Icons.Default.PhoneAndroid,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primaryContainer,
)
Icon(Icons.Default.SwapHoriz, null, modifier = Modifier.align(Alignment.CenterVertically))
VersionBox(
label = "Remote",
modifiedAt = fmt.format(conflict.remoteModifiedAt.atZone(zone)),
size = conflict.remoteSizeBytes.formatBytes(),
icon = Icons.Default.Cloud,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.secondaryContainer,
)
}
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_LOCAL) }, modifier = Modifier.weight(1f)) {
Text("Keep Local", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_REMOTE) }, modifier = Modifier.weight(1f)) {
Text("Keep Remote", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(onClick = { onResolve(ConflictResolution.KEEP_BOTH) }, modifier = Modifier.weight(1f)) {
Text("Keep Both", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
@Composable
private fun VersionBox(label: String, modifiedAt: String, size: String, icon: androidx.compose.ui.graphics.vector.ImageVector, modifier: Modifier, color: androidx.compose.ui.graphics.Color) {
Surface(modifier = modifier, shape = MaterialTheme.shapes.small, color = color) {
Column(modifier = Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, null, Modifier.size(20.dp))
Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold)
Text(modifiedAt, style = MaterialTheme.typography.labelSmall)
Text(size, style = MaterialTheme.typography.labelSmall)
}
}
}
private fun Long.formatBytes(): String = when {
this < 1024 -> "${this}B"
this < 1024 * 1024 -> "${"%.1f".format(this / 1024.0)}KB"
this < 1024 * 1024 * 1024 -> "${"%.1f".format(this / 1024.0 / 1024)}MB"
else -> "${"%.1f".format(this / 1024.0 / 1024 / 1024)}GB"
}
@@ -0,0 +1,35 @@
package com.syncflow.ui.conflict
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.entities.SyncConflictEntity
import com.syncflow.domain.model.ConflictResolution
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ConflictViewModel @Inject constructor(
private val conflictDao: SyncConflictDao,
savedState: SavedStateHandle,
) : ViewModel() {
private val pairId = savedState.get<Long>("pairId")!!
val conflicts = conflictDao.observeUnresolved(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun resolve(conflict: SyncConflictEntity, resolution: ConflictResolution) {
viewModelScope.launch { conflictDao.resolve(conflict.id, resolution) }
}
fun resolveAll(resolution: ConflictResolution) {
viewModelScope.launch {
conflicts.value.forEach { conflictDao.resolve(it.id, resolution) }
}
}
}
@@ -0,0 +1,146 @@
package com.syncflow.ui.home
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun HomeScreen(
onAddPair: () -> Unit,
onPairClick: (Long) -> Unit,
modifier: Modifier = Modifier,
vm: HomeViewModel = hiltViewModel(),
) {
val pairs by vm.syncPairs.collectAsState()
if (pairs.isEmpty()) {
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
} else {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(pairs, key = { it.id }) { pair ->
SyncPairCard(
pair = pair,
onClick = { onPairClick(pair.id) },
onSync = { vm.triggerSync(pair) },
onToggle = { vm.toggleEnabled(pair) },
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun SyncPairCard(
pair: SyncPairEntity,
onClick: () -> Unit,
onSync: () -> Unit,
onToggle: () -> Unit,
) {
ElevatedCard(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(pair.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(2.dp))
Text(
pair.localPath.takeLast(40),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = pair.isEnabled, onCheckedChange = { onToggle() })
}
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatusChip(pair.lastSyncResult)
if (pair.pendingConflicts > 0) {
AssistChip(
onClick = {},
label = { Text("${pair.pendingConflicts} conflicts") },
leadingIcon = { Icon(Icons.Default.Warning, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
)
}
Spacer(Modifier.weight(1f))
pair.lastSyncAt?.let { at ->
Text(
at.formatRelative(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp))
}
}
}
}
}
@Composable
private fun StatusChip(status: SyncStatus) {
val (icon, label, color) = when (status) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber, "Partial", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.IDLE -> Triple(Icons.Outlined.Circle, "Idle", MaterialTheme.colorScheme.surfaceVariant)
}
AssistChip(
onClick = {},
label = { Text(label) },
leadingIcon = { Icon(icon, null, Modifier.size(16.dp)) },
colors = AssistChipDefaults.assistChipColors(containerColor = color),
)
}
@Composable
private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Outlined.CloudSync, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text("No sync pairs yet", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text("Tap + to create your first sync", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
Button(onClick = onAdd) { Text("Add Sync Pair") }
}
}
private val fmt = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
private fun Instant.formatRelative(): String = fmt.format(atZone(ZoneId.systemDefault()))
@@ -0,0 +1,50 @@
package com.syncflow.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val workManager: WorkManager,
) : ViewModel() {
val syncPairs = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun triggerSync(pair: SyncPairEntity) {
val req = SyncWorker.buildOneTimeRequest(pair.id, pair.wifiOnly, pair.chargingOnly)
workManager.enqueue(req)
}
fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch {
syncPairDao.update(pair.copy(isEnabled = !pair.isEnabled))
if (!pair.isEnabled && pair.scheduleType != ScheduleType.MANUAL) {
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
workManager.enqueueUniquePeriodicWork(
"periodic_${pair.id}",
androidx.work.ExistingPeriodicWorkPolicy.UPDATE,
req,
)
} else {
workManager.cancelAllWorkByTag("sync_${pair.id}")
}
}
}
}
@@ -0,0 +1,100 @@
package com.syncflow.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun MainShell(
onAddPair: () -> Unit,
onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit,
) {
val pagerState = rememberPagerState(pageCount = { 2 })
val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage
Scaffold(
topBar = {
TopAppBar(
title = { Text("SyncFlow", fontWeight = FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = currentPage == 0,
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
icon = {
Icon(
if (currentPage == 0) Icons.Filled.Sync else Icons.Outlined.Sync,
contentDescription = null,
)
},
label = { Text("Syncs") },
)
NavigationBarItem(
selected = currentPage == 1,
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
icon = {
Icon(
if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null,
)
},
label = { Text("Accounts") },
)
}
},
floatingActionButton = {
AnimatedVisibility(
visible = currentPage == 0,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
) {
ExtendedFloatingActionButton(
text = { Text("Add Sync") },
icon = { Icon(Icons.Default.Add, null) },
onClick = onAddPair,
)
}
},
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(padding),
) { page ->
when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> SettingsScreen(onAddAccount = onAddAccount)
}
}
}
}
@@ -0,0 +1,64 @@
package com.syncflow.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.syncflow.ui.addpair.AddPairScreen
import com.syncflow.ui.auth.AccountSetupScreen
import com.syncflow.ui.conflict.ConflictScreen
import com.syncflow.ui.main.MainShell
import com.syncflow.ui.pairdetail.PairDetailScreen
sealed class Screen(val route: String) {
object Main : Screen("main")
object AddPair : Screen("add_pair?pairId={pairId}") {
fun route(pairId: Long? = null) = if (pairId != null) "add_pair?pairId=$pairId" else "add_pair"
}
object PairDetail : Screen("pair/{pairId}") {
fun route(pairId: Long) = "pair/$pairId"
}
object Conflicts : Screen("conflicts/{pairId}") {
fun route(pairId: Long) = "conflicts/$pairId"
}
object AddAccount : Screen("add_account")
}
@Composable
fun SyncFlowNavGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = Screen.Main.route) {
composable(Screen.Main.route) {
MainShell(
onAddPair = { navController.navigate(Screen.AddPair.route()) },
onPairClick = { id -> navController.navigate(Screen.PairDetail.route(id)) },
onAddAccount = { navController.navigate(Screen.AddAccount.route) },
)
}
composable(
route = "add_pair?pairId={pairId}",
arguments = listOf(navArgument("pairId") { type = NavType.LongType; defaultValue = -1L }),
) {
AddPairScreen(onDone = { navController.popBackStack() })
}
composable(
route = "pair/{pairId}",
arguments = listOf(navArgument("pairId") { type = NavType.LongType }),
) {
PairDetailScreen(
onBack = { navController.popBackStack() },
onConflicts = { id -> navController.navigate(Screen.Conflicts.route(id)) },
)
}
composable(
route = "conflicts/{pairId}",
arguments = listOf(navArgument("pairId") { type = NavType.LongType }),
) {
ConflictScreen(onBack = { navController.popBackStack() })
}
composable(Screen.AddAccount.route) {
AccountSetupScreen(onDone = { navController.popBackStack() })
}
}
}
@@ -0,0 +1,165 @@
package com.syncflow.ui.pairdetail
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.domain.model.SyncEventType
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PairDetailScreen(
onBack: () -> Unit,
onConflicts: (Long) -> Unit,
vm: PairDetailViewModel = hiltViewModel(),
) {
val pair by vm.pair.collectAsState()
val events by vm.events.collectAsState()
val conflictCount by vm.unresolvedConflicts.collectAsState()
var showDelete by remember { mutableStateOf(false) }
if (showDelete) {
AlertDialog(
onDismissRequest = { showDelete = false },
title = { Text("Delete sync pair?") },
text = { Text("This removes the pair and all sync history. Files are NOT deleted.") },
confirmButton = {
TextButton(onClick = { vm.delete(); onBack() }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
Text("Delete")
}
},
dismissButton = { TextButton(onClick = { showDelete = false }) { Text("Cancel") } },
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(pair?.name ?: "") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
},
)
},
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
pair?.let { p ->
InfoCard(p.localPath, p.remotePath, p.syncDirection.name, p.scheduleType.name)
}
}
if (conflictCount > 0) {
item {
FilledTonalButton(
onClick = { pair?.let { onConflicts(it.id) } },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(Icons.Default.Warning, null)
Spacer(Modifier.width(8.dp))
Text("$conflictCount unresolved conflict${if (conflictCount != 1) "s" else ""}")
}
}
}
item {
Text("Activity", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
if (events.isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text("No sync activity yet", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
items(events, key = { it.id }) { event ->
EventRow(event)
}
}
}
}
}
@Composable
private fun InfoCard(localPath: String, remotePath: String, direction: String, schedule: String) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow(Icons.Default.PhoneAndroid, "Local", localPath)
InfoRow(Icons.Default.Cloud, "Remote", remotePath)
InfoRow(Icons.Default.SwapHoriz, "Direction", direction)
InfoRow(Icons.Default.Schedule, "Schedule", schedule)
}
}
}
@Composable
private fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("$label: ", style = MaterialTheme.typography.labelMedium)
Text(value, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f))
}
}
@Composable
private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
val (icon, tint) = eventIcon(event.eventType)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, Modifier.size(16.dp), tint = tint)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(event.filePath ?: event.message ?: event.eventType.name, style = MaterialTheme.typography.bodySmall)
event.message?.takeIf { event.filePath != null }?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(fmt.format(event.timestamp.atZone(zone)), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun eventIcon(type: SyncEventType): Pair<androidx.compose.ui.graphics.vector.ImageVector, androidx.compose.ui.graphics.Color> {
val green = MaterialTheme.colorScheme.primary
val red = MaterialTheme.colorScheme.error
val orange = MaterialTheme.colorScheme.tertiary
val grey = MaterialTheme.colorScheme.onSurfaceVariant
return when (type) {
SyncEventType.SYNC_STARTED -> Pair(Icons.Default.PlayArrow, green)
SyncEventType.SYNC_COMPLETED -> Pair(Icons.Default.CheckCircle, green)
SyncEventType.SYNC_FAILED -> Pair(Icons.Default.Error, red)
SyncEventType.FILE_UPLOADED -> Pair(Icons.Default.Upload, green)
SyncEventType.FILE_DOWNLOADED -> Pair(Icons.Default.Download, green)
SyncEventType.FILE_DELETED -> Pair(Icons.Default.Delete, orange)
SyncEventType.FILE_SKIPPED -> Pair(Icons.Default.SkipNext, grey)
SyncEventType.CONFLICT_DETECTED -> Pair(Icons.Default.Warning, orange)
SyncEventType.CONFLICT_RESOLVED -> Pair(Icons.Default.CheckCircle, green)
}
}
@@ -0,0 +1,47 @@
package com.syncflow.ui.pairdetail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkManager
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PairDetailViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val eventDao: SyncEventDao,
private val conflictDao: SyncConflictDao,
private val workManager: WorkManager,
savedState: SavedStateHandle,
) : ViewModel() {
private val pairId = savedState.get<Long>("pairId")!!
val pair = syncPairDao.observeById(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
val events = eventDao.observeRecent(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
fun syncNow() {
val p = pair.value ?: return
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, p.wifiOnly, p.chargingOnly))
}
fun delete() {
viewModelScope.launch {
pair.value?.let { syncPairDao.delete(it) }
}
}
}
@@ -0,0 +1,159 @@
package com.syncflow.ui.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.domain.model.ProviderType
@Composable
fun SettingsScreen(
onAddAccount: () -> Unit,
modifier: Modifier = Modifier,
vm: SettingsViewModel = hiltViewModel(),
) {
val accounts by vm.accounts.collectAsState()
val biometricEnabled by vm.biometricEnabled.collectAsState()
var deleteTarget by remember { mutableStateOf<CloudAccountEntity?>(null) }
deleteTarget?.let { acct ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Remove account?") },
text = { Text("\"${acct.displayName}\" and all associated sync pairs will be removed.") },
confirmButton = {
TextButton(
onClick = { vm.removeAccount(acct); deleteTarget = null },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { Text("Remove") }
},
dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("Cancel") } },
)
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Cloud Accounts", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
FilledTonalButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Add Account")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
if (accounts.isEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.CloudOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("No accounts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Add a cloud account to start syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(onClick = onAddAccount) {
Icon(Icons.Default.Add, null, Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Add your first account")
}
}
}
} else {
items(accounts, key = { it.id }) { acct ->
AccountCard(acct = acct, onDelete = { deleteTarget = acct })
}
}
item {
Spacer(Modifier.height(16.dp))
Text("Security", style = MaterialTheme.typography.titleMedium)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("Biometric Lock", style = MaterialTheme.typography.bodyMedium)
Text(
"Require biometrics when returning to app",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = biometricEnabled, onCheckedChange = { vm.setBiometricLock(it) })
}
}
item {
Spacer(Modifier.height(16.dp))
Text("About", style = MaterialTheme.typography.titleMedium)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text("SyncFlow v1.0.0 — Free, no subscription.", style = MaterialTheme.typography.bodySmall)
Text("Open source. No ads. No tracking.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun AccountCard(acct: CloudAccountEntity, onDelete: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(providerIcon(acct.providerType), null, Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(acct.displayName, style = MaterialTheme.typography.bodyMedium)
Text(
buildString {
append(friendlyProviderName(acct.providerType))
acct.email?.let { append(" · $it") }
},
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
acct.serverUrl?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, "Remove", tint = MaterialTheme.colorScheme.error)
}
}
}
}
private fun providerIcon(type: ProviderType) = when (type) {
ProviderType.GOOGLE_DRIVE -> Icons.Default.Cloud
ProviderType.DROPBOX -> Icons.Default.CloudQueue
ProviderType.ONEDRIVE -> Icons.Default.CloudDone
ProviderType.WEBDAV -> Icons.Default.Storage
ProviderType.SFTP -> Icons.Default.Terminal
ProviderType.NEXTCLOUD -> Icons.Default.CloudCircle
ProviderType.OWNCLOUD -> Icons.Default.CloudCircle
ProviderType.SFTPGO -> Icons.Default.Storage
}
private fun friendlyProviderName(type: ProviderType) = when (type) {
ProviderType.GOOGLE_DRIVE -> "Google Drive"
ProviderType.DROPBOX -> "Dropbox"
ProviderType.ONEDRIVE -> "OneDrive"
ProviderType.WEBDAV -> "WebDAV"
ProviderType.SFTP -> "SFTP"
ProviderType.NEXTCLOUD -> "Nextcloud"
ProviderType.OWNCLOUD -> "ownCloud"
ProviderType.SFTPGO -> "SFTPGo"
}
@@ -0,0 +1,33 @@
package com.syncflow.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.preferences.AppPreferences
import com.syncflow.data.repository.AccountRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val appPreferences: AppPreferences,
) : ViewModel() {
val accounts = accountRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val biometricEnabled = appPreferences.biometricLockEnabled
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun setBiometricLock(enabled: Boolean) {
viewModelScope.launch { appPreferences.setBiometricLock(enabled) }
}
fun removeAccount(account: CloudAccountEntity) {
viewModelScope.launch { accountRepository.delete(account) }
}
}
@@ -0,0 +1,9 @@
package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color
val SyncBlue = Color(0xFF2196F3)
val SyncGreen = Color(0xFF4CAF50)
val SyncOrange = Color(0xFFFF9800)
val SyncRed = Color(0xFFF44336)
val SyncPurple = Color(0xFF9C27B0)
@@ -0,0 +1,50 @@
package com.syncflow.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme(
primary = SyncBlue,
onPrimary = androidx.compose.ui.graphics.Color.White,
secondary = SyncGreen,
tertiary = SyncPurple,
)
private val DarkColors = darkColorScheme(
primary = SyncBlue,
secondary = SyncGreen,
tertiary = SyncPurple,
)
@Composable
fun SyncFlowTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> DarkColors
else -> LightColors
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content)
}
@@ -0,0 +1,41 @@
package com.syncflow.worker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.WorkManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import javax.inject.Inject
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val wm = WorkManager.getInstance(context)
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
syncPairDao.getEnabled()
.filter { it.scheduleType != ScheduleType.MANUAL && it.scheduleType != ScheduleType.ON_CHANGE }
.forEach { pair ->
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", androidx.work.ExistingPeriodicWorkPolicy.UPDATE, req)
}
} finally {
pending.finish()
}
}
}
}
@@ -0,0 +1,95 @@
package com.syncflow.worker
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.hilt.work.HiltWorker
import androidx.work.*
import com.syncflow.R
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import com.syncflow.domain.sync.SyncEngine
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import timber.log.Timber
import java.util.concurrent.TimeUnit
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val syncPairDao: SyncPairDao,
private val accountRepository: AccountRepository,
private val syncEngine: SyncEngine,
private val providerFactory: ProviderFactory,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val pairId = inputData.getLong(KEY_PAIR_ID, -1L)
if (pairId == -1L) return Result.failure()
setForeground(buildForegroundInfo("Syncing…"))
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
return try {
val domainPair = pair.toDomain()
val provider = providerFactory.create(account)
syncEngine.sync(domainPair, provider)
Result.success()
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
private fun buildForegroundInfo(progress: String): ForegroundInfo {
val channelId = "sync_channel"
val nm = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
nm.createNotificationChannel(NotificationChannel(channelId, "Sync", NotificationManager.IMPORTANCE_LOW))
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("SyncFlow")
.setContentText(progress)
.setSmallIcon(R.drawable.ic_sync)
.setOngoing(true)
.build()
return ForegroundInfo(NOTIFICATION_ID, notification)
}
companion object {
const val KEY_PAIR_ID = "pair_id"
private const val NOTIFICATION_ID = 1001
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
.build()
}
fun buildPeriodicRequest(pairId: Long, intervalMinutes: Long, wifiOnly: Boolean, chargingOnly: Boolean): PeriodicWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return PeriodicWorkRequestBuilder<SyncWorker>(intervalMinutes, TimeUnit.MINUTES)
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
.build()
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#0061FF"
android:pathData="M12,0 A12,12 0 1,0 12,24 A12,12 0 1,0 12,0 Z"/>
<group android:scaleX="0.75" android:scaleY="0.75"
android:translateX="3" android:translateY="3">
<path android:fillColor="#FFFFFF"
android:pathData="M6 1.807L0 5.629l6 3.822 6.001-3.822L6 1.807zM18 1.807l-6 3.822 6 3.822 6-3.822-6-3.822zM0 13.274l6 3.822 6.001-3.822L6 9.452l-6 3.822zM18 9.452l-6 3.822 6 3.822 6-3.822-6-3.822zM6 18.371l6.001 3.822 6-3.822-6-3.822L6 18.371z"/>
</group>
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF"
android:pathData="M12,0 A12,12 0 1,0 12,24 A12,12 0 1,0 12,0 Z"/>
<group android:scaleX="0.78" android:scaleY="0.78"
android:translateX="2.64" android:translateY="2.64">
<path android:fillColor="#34A853"
android:pathData="M12.01 1.485c-2.082 0-3.754 0.02-3.743 0.047 0.01 0.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-0.02 3.742-0.047-0.005-0.02-1.708-3.001-3.775-6.62l-3.76-6.574Z"/>
<path android:fillColor="#4285F4"
android:pathData="M7.25 3.215a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214Z"/>
<path android:fillColor="#FBBC05"
android:pathData="M9.509 15.868l-0.203 0.348c-0.114 0.198-0.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-0.01 0.026 3.24 0.042 7.222 0.042h7.244l1.796-3.157c0.992-1.734 1.85-3.23 1.906-3.323l0.104-0.167h-7.249Z"/>
</group>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#0082C9"
android:pathData="M12,0 A12,12 0 1,0 12,24 A12,12 0 1,0 12,0 Z"/>
<group android:scaleX="0.75" android:scaleY="0.75"
android:translateX="3" android:translateY="3">
<path android:fillColor="#FFFFFF"
android:pathData="M12.018 6.537c-2.5 0-4.6 1.712-5.241 4.015-0.56-1.232-1.793-2.105-3.225-2.105A3.569 3.569 0 0 0 0 12a3.569 3.569 0 0 0 3.552 3.553c1.432 0 2.664-0.874 3.224-2.106 0.641 2.304 2.742 4.016 5.242 4.016 2.487 0 4.576-1.693 5.231-3.977 0.569 1.21 1.783 2.067 3.198 2.067A3.568 3.568 0 0 0 24 12a3.569 3.569 0 0 0-3.553-3.553c-1.416 0-2.63 0.858-3.199 2.067-0.654-2.284-2.743-3.978-5.23-3.977zm0 2.085c1.878 0 3.378 1.5 3.378 3.378 0 1.878-1.5 3.378-3.378 3.378A3.362 3.362 0 0 1 8.641 12c0-1.878 1.5-3.378 3.377-3.378zm-8.466 1.91c0.822 0 1.467 0.645 1.467 1.468s-0.644 1.467-1.467 1.468A1.452 1.452 0 0 1 2.085 12c0-0.823 0.644-1.467 1.467-1.467zm16.895 0c0.823 0 1.468 0.645 1.468 1.468s-0.645 1.468-1.468 1.468A1.452 1.452 0 0 1 18.98 12c0-0.823 0.644-1.467 1.467-1.467z"/>
</group>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#0078D4"
android:pathData="M12,0 A12,12 0 1,0 12,24 A12,12 0 1,0 12,0 Z"/>
<group android:scaleX="0.75" android:scaleY="0.75"
android:translateX="3" android:translateY="3">
<path android:fillColor="#FFFFFF"
android:pathData="M19.453 9.95q0.961 0.058 1.787 0.468 0.826 0.41 1.442 1.066 0.615 0.657 0.966 1.512 0.352 0.856 0.352 1.816 0 1.008-0.387 1.893-0.386 0.885-1.049 1.547-0.662 0.662-1.546 1.049-0.885 0.387-1.893 0.387H6q-1.242 0-2.332-0.475-1.09-0.475-1.904-1.29-0.815-0.814-1.29-1.903Q0 14.93 0 13.688q0-0.985 0.31-1.887 0.311-0.903 0.862-1.658 0.55-0.756 1.324-1.325 0.774-0.568 1.711-0.861 0.434-0.129 0.85-0.187 0.416-0.06 0.861-0.082h0.012q0.515-0.786 1.207-1.413 0.691-0.627 1.5-1.066 0.808-0.44 1.705-0.668 0.896-0.229 1.845-0.229 1.278 0 2.456 0.417 1.177 0.416 2.144 1.16 0.967 0.744 1.658 1.78 0.692 1.038 1.008 2.28z"/>
</group>
</vector>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SFTP: dark indigo circle, white terminal >_ prompt with cursor -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#283593"
android:pathData="M24,4 A20,20 0 1,1 24,44 A20,20 0 1,1 24,4 Z"/>
<!-- Terminal window background -->
<path android:fillColor="#1A237E"
android:pathData="M9,14 Q8,14 8,15 L8,34 Q8,35 9,35 L39,35 Q40,35 40,34 L40,15 Q40,14 39,14 Z"/>
<!-- Title bar -->
<path android:fillColor="#283593"
android:pathData="M9,14 L39,14 L39,19 L9,19 Z"/>
<!-- Window control dots -->
<path android:fillColor="#EF5350" android:pathData="M12,15.5 A1.5,1.5 0 1,1 12,18.5 A1.5,1.5 0 1,1 12,15.5 Z"/>
<path android:fillColor="#FFA726" android:pathData="M16,15.5 A1.5,1.5 0 1,1 16,18.5 A1.5,1.5 0 1,1 16,15.5 Z"/>
<path android:fillColor="#66BB6A" android:pathData="M20,15.5 A1.5,1.5 0 1,1 20,18.5 A1.5,1.5 0 1,1 20,15.5 Z"/>
<!-- > prompt arrow -->
<path android:fillColor="#FFFFFF"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M12,23 L17,26 L12,29"/>
<!-- _ underscore cursor -->
<path android:fillColor="#FFFFFF"
android:pathData="M19,29 L28,29 L28,31 L19,31 Z"/>
</vector>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SFTPGo: teal circle, white server rack with status LEDs -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#00796B"
android:pathData="M24,4 A20,20 0 1,1 24,44 A20,20 0 1,1 24,4 Z"/>
<!-- Server rack: 3 units with rounded corners -->
<path android:fillColor="#FFFFFF"
android:pathData="M11,12 Q10,12 10,13 L10,18 Q10,19 11,19 L37,19 Q38,19 38,18 L38,13 Q38,12 37,12 Z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M11,21 Q10,21 10,22 L10,27 Q10,28 11,28 L37,28 Q38,28 38,27 L38,22 Q38,21 37,21 Z"/>
<path android:fillColor="#FFFFFF"
android:pathData="M11,30 Q10,30 10,31 L10,36 Q10,37 11,37 L37,37 Q38,37 38,36 L38,31 Q38,30 37,30 Z"/>
<!-- Status LED dots -->
<path android:fillColor="#00796B"
android:pathData="M33,14 A2,2 0 1,1 33,18 A2,2 0 1,1 33,14 Z"/>
<path android:fillColor="#00796B"
android:pathData="M33,23 A2,2 0 1,1 33,27 A2,2 0 1,1 33,23 Z"/>
<path android:fillColor="#00796B"
android:pathData="M33,32 A2,2 0 1,1 33,36 A2,2 0 1,1 33,32 Z"/>
<!-- Disk/activity bars on each unit -->
<path android:fillColor="#00796B"
android:pathData="M13,14.5 L27,14.5 L27,17.5 L13,17.5 Z"/>
<path android:fillColor="#00796B"
android:pathData="M13,23.5 L27,23.5 L27,26.5 L13,26.5 Z"/>
<path android:fillColor="#00796B"
android:pathData="M13,32.5 L27,32.5 L27,35.5 L13,35.5 Z"/>
</vector>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- WebDAV: blue-gray circle, white globe with grid lines -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#37474F"
android:pathData="M24,4 A20,20 0 1,1 24,44 A20,20 0 1,1 24,4 Z"/>
<!-- Globe outline circle -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:pathData="M24,12 A12,12 0 1,1 24,36 A12,12 0 1,1 24,12 Z"/>
<!-- Equator -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.5"
android:pathData="M12,24 L36,24"/>
<!-- Left meridian -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.5"
android:pathData="M24,12 C19,16 19,32 24,36"/>
<!-- Right meridian -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.5"
android:pathData="M24,12 C29,16 29,32 24,36"/>
<!-- Upper latitude -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.2"
android:pathData="M15,19 Q24,17 33,19"/>
<!-- Lower latitude -->
<path android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="1.2"
android:pathData="M15,29 Q24,31 33,29"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,4V1L8,5l4,4V6c3.31,0 6,2.69 6,6 0,1.01-0.25,1.97-0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0-4.42-3.58-8-8-8zM12,18c-3.31,0-6,-2.69-6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4-4,-4v3z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_sync"/>
</adaptive-icon>
+13
View File
@@ -0,0 +1,13 @@
{
"client_id": "YOUR_AZURE_CLIENT_ID",
"authorization_user_agent": "DEFAULT",
"redirect_uri": "msauth://com.syncflow/YOUR_BASE64_SIGNATURE",
"authorities": [
{
"type": "AAD",
"audience": {
"type": "AzureADandPersonalMicrosoftAccount"
}
}
]
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2196F3</color>
</resources>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Replace the placeholder values here with your real API keys.
DO NOT commit real keys to source control — use a secrets manager or
local.properties + BuildConfig injection instead.
-->
<resources>
<!-- Dropbox: create an app at https://www.dropbox.com/developers/apps -->
<string name="dropbox_app_key" translatable="false">YOUR_DROPBOX_APP_KEY</string>
<!-- Google Drive: create OAuth 2.0 client ID at https://console.cloud.google.com -->
<string name="google_client_id" translatable="false">YOUR_GOOGLE_CLIENT_ID</string>
<!-- OneDrive: create app at https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps -->
<string name="onedrive_client_id" translatable="false">YOUR_ONEDRIVE_CLIENT_ID</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">SyncFlow</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SyncFlow" parent="Theme.AppCompat.Light.NoActionBar" />
<style name="Theme.SyncFlow.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="postSplashScreenTheme">@style/Theme.SyncFlow</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="database" path="syncflow.db" />
<exclude domain="sharedpref" path="syncflow_credentials.xml" />
</full-backup-content>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="database" path="syncflow.db" />
<exclude domain="sharedpref" path="syncflow_credentials.xml" />
</cloud-backup>
<device-transfer>
<exclude domain="database" path="syncflow.db" />
<exclude domain="sharedpref" path="syncflow_credentials.xml" />
</device-transfer>
</data-extraction-rules>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." />
</paths>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
+8
View File
@@ -0,0 +1,8 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.parallel=true
android.useAndroidX=true
android.enableJetifier=false
kotlin.code.style=official
+125
View File
@@ -0,0 +1,125 @@
[versions]
agp = "8.4.2"
kotlin = "2.0.0"
coreKtx = "1.13.1"
lifecycleRuntime = "2.8.3"
activityCompose = "1.9.0"
appcompat = "1.7.0"
composeBom = "2024.06.00"
navigationCompose = "2.7.7"
hilt = "2.51.1"
hiltNavigationCompose = "1.2.0"
ksp = "2.0.0-1.0.22"
room = "2.6.1"
workManager = "2.9.0"
datastore = "1.1.1"
okhttp = "4.12.0"
retrofit = "2.11.0"
kotlinxSerialization = "1.7.0"
kotlinxCoroutines = "1.8.1"
googleApiClient = "2.6.0"
googleDrive = "v3-rev20231219-2.0.0"
dropboxSdk = "7.0.0"
microsoftGraph = "6.6.0"
sshj = "0.38.0"
sardine = "REMOVED" # replaced by OkHttp WebDAV implementation
browser = "1.8.0"
localbroadcastmanager = "1.1.0"
coil = "2.7.0"
splashscreen = "1.0.1"
timber = "5.0.1"
securityCrypto = "1.1.0-alpha06"
biometric = "1.2.0-alpha05"
junit = "4.13.2"
androidxTestExt = "1.2.1"
espresso = "3.6.1"
[libraries]
# AndroidX Core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
# Compose BOM
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Hilt DI
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# WorkManager
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version = "1.2.0" }
hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.2.0" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Networking
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version = "1.0.0" }
# Kotlin Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
# Cloud SDKs
google-api-client-android = { group = "com.google.api-client", name = "google-api-client-android", version.ref = "googleApiClient" }
google-drive = { group = "com.google.apis", name = "google-api-services-drive", version.ref = "googleDrive" }
google-auth-library = { group = "com.google.auth", name = "google-auth-library-oauth2-http", version = "1.23.0" }
dropbox-sdk = { group = "com.dropbox.core", name = "dropbox-core-sdk", version.ref = "dropboxSdk" }
microsoft-graph = { group = "com.microsoft.graph", name = "microsoft-graph", version.ref = "microsoftGraph" }
microsoft-identity = { group = "com.microsoft.identity.client", name = "msal", version = "5.1.0" }
# SFTP / WebDAV
sshj = { group = "com.hierynomus", name = "sshj", version.ref = "sshj" }
# sardine-android removed — WebDAV implemented via OkHttp directly
# Browser / OAuth
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
# Image loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
biometric = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometric" }
# Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file\:///home/amir/gradle/gradle-8.6/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exec /home/amir/gradle/gradle-8.6/bin/gradle "$@"
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "SyncFlow"
include(":app")