commit cff4233de65766fa06c6a15628a1f2e5d6b95bda Author: Amir Khodak Date: Fri May 22 20:21:20 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e22edec --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.iml +.gradle/ +local.properties +.idea/ +.DS_Store +/build/ +app/build/ +captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +*.jks diff --git a/README.md b/README.md new file mode 100644 index 0000000..faff524 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a0fcd6c --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..8350558 --- /dev/null +++ b/app/proguard-rules.pro @@ -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.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..81d95e2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/com/syncflow/MainActivity.kt b/app/src/main/kotlin/com/syncflow/MainActivity.kt new file mode 100644 index 0000000..6abfbf3 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/MainActivity.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt b/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt new file mode 100644 index 0000000..8275229 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/SyncFlowApp.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/CloudAccountDao.kt b/app/src/main/kotlin/com/syncflow/data/db/CloudAccountDao.kt new file mode 100644 index 0000000..7f5cd57 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/CloudAccountDao.kt @@ -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> + + @Query("SELECT * FROM cloud_accounts") + suspend fun getAll(): List + + @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) +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt b/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt new file mode 100644 index 0000000..09b86cb --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/DbConverters.kt @@ -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) } +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncConflictDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncConflictDao.kt new file mode 100644 index 0000000..41811d8 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncConflictDao.kt @@ -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> + + @Query("SELECT * FROM sync_conflicts WHERE syncPairId = :pairId ORDER BY detectedAt DESC") + fun observeAll(pairId: Long): Flow> + + @Query("SELECT COUNT(*) FROM sync_conflicts WHERE syncPairId = :pairId AND resolution IS NULL") + fun observeUnresolvedCount(pairId: Long): Flow + + @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) +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt new file mode 100644 index 0000000..66d3c62 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncDatabase.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt new file mode 100644 index 0000000..7b97ac2 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncEventDao.kt @@ -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> + + @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? +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt new file mode 100644 index 0000000..dfe11cd --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncFileStateDao.kt @@ -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 + + @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) + + @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) +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt b/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt new file mode 100644 index 0000000..45f64ac --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/SyncPairDao.kt @@ -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> + + @Query("SELECT * FROM sync_pairs WHERE isEnabled = 1") + suspend fun getEnabled(): List + + @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 + + @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) +} diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/CloudAccountEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/CloudAccountEntity.kt new file mode 100644 index 0000000..c3689bb --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/CloudAccountEntity.kt @@ -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?, +) diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncConflictEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncConflictEntity.kt new file mode 100644 index 0000000..2ef5a01 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncConflictEntity.kt @@ -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, +) diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncEventEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncEventEntity.kt new file mode 100644 index 0000000..8e61a6d --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncEventEntity.kt @@ -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, +) diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncFileStateEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncFileStateEntity.kt new file mode 100644 index 0000000..52f695b --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncFileStateEntity.kt @@ -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?, +) diff --git a/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt new file mode 100644 index 0000000..b751a43 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/db/entities/SyncPairEntity.kt @@ -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() } diff --git a/app/src/main/kotlin/com/syncflow/data/preferences/AppPreferences.kt b/app/src/main/kotlin/com/syncflow/data/preferences/AppPreferences.kt new file mode 100644 index 0000000..df3de45 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/preferences/AppPreferences.kt @@ -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 = + 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") + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/CloudProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/CloudProvider.kt new file mode 100644 index 0000000..f8dfcef --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/CloudProvider.kt @@ -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 + suspend fun listFiles(remotePath: String): Result> + suspend fun uploadFile( + localStream: InputStream, + remotePath: String, + sizeBytes: Long, + onProgress: (bytesWritten: Long) -> Unit = {}, + ): Result + suspend fun downloadFile( + remotePath: String, + destination: OutputStream, + onProgress: (bytesRead: Long) -> Unit = {}, + ): Result + suspend fun deleteFile(remotePath: String): Result + suspend fun createDirectory(remotePath: String): Result + suspend fun getFileMetadata(remotePath: String): Result + suspend fun moveFile(fromPath: String, toPath: String): Result + + companion object { + const val CHUNK_SIZE = 8 * 1024 * 1024L // 8 MB + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/ProviderFactory.kt b/app/src/main/kotlin/com/syncflow/data/providers/ProviderFactory.kt new file mode 100644 index 0000000..fe0a951 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/ProviderFactory.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/dropbox/DropboxProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/dropbox/DropboxProvider.kt new file mode 100644 index 0000000..cd9aecc --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/dropbox/DropboxProvider.kt @@ -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 = 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> = 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 = 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 = 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 = 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 = 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 = 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 = 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 +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/google/GoogleDriveProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/google/GoogleDriveProvider.kt new file mode 100644 index 0000000..4383e8e --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/google/GoogleDriveProvider.kt @@ -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 = 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> = 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 = 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 = 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 = 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 = 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 = 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 = 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) +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/nextcloud/NextcloudProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/nextcloud/NextcloudProvider.kt new file mode 100644 index 0000000..e1b072a --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/nextcloud/NextcloudProvider.kt @@ -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// + override val baseUrl: String + get() { + val server = account.serverUrl?.trimEnd('/') ?: "" + val email = account.email ?: "user" + return "$server/remote.php/dav/files/$email" + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/onedrive/OneDriveProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/onedrive/OneDriveProvider.kt new file mode 100644 index 0000000..0a897b2 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/onedrive/OneDriveProvider.kt @@ -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 = 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> = 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 = 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 = 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 = 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 = 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 = 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 = 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, + ) +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/owncloud/OwnCloudProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/owncloud/OwnCloudProvider.kt new file mode 100644 index 0000000..ad49d9a --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/owncloud/OwnCloudProvider.kt @@ -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" +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/sftp/SftpProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/sftp/SftpProvider.kt new file mode 100644 index 0000000..99088f7 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/sftp/SftpProvider.kt @@ -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 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 = runCatching { withSftp { it.ls("/") } } + + override suspend fun listFiles(remotePath: String): Result> = 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 = 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 = 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 = runCatching { + withSftp { it.rm(remotePath) } + } + + override suspend fun createDirectory(remotePath: String): Result = runCatching { + withSftp { it.mkdirs(remotePath) } + } + + override suspend fun getFileMetadata(remotePath: String): Result = 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 = runCatching { + withSftp { it.rename(fromPath, toPath) } + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt new file mode 100644 index 0000000..de19788 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt @@ -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 = 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> = 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 = 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 = 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 = 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 = 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 = 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 = 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 { + val results = mutableListOf() + 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 = """ + + + + +""".toRequestBody("application/xml".toMediaType()) + } +} diff --git a/app/src/main/kotlin/com/syncflow/data/repository/AccountRepository.kt b/app/src/main/kotlin/com/syncflow/data/repository/AccountRepository.kt new file mode 100644 index 0000000..f294857 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/repository/AccountRepository.kt @@ -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> = 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, + ) +} diff --git a/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt b/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt new file mode 100644 index 0000000..c593921 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/data/security/CredentialStore.kt @@ -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" +} diff --git a/app/src/main/kotlin/com/syncflow/di/AppModule.kt b/app/src/main/kotlin/com/syncflow/di/AppModule.kt new file mode 100644 index 0000000..70c5524 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/di/AppModule.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/syncflow/domain/model/CloudAccount.kt b/app/src/main/kotlin/com/syncflow/domain/model/CloudAccount.kt new file mode 100644 index 0000000..d918ad4 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/model/CloudAccount.kt @@ -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, +} diff --git a/app/src/main/kotlin/com/syncflow/domain/model/RemoteFile.kt b/app/src/main/kotlin/com/syncflow/domain/model/RemoteFile.kt new file mode 100644 index 0000000..fdcb5c6 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/model/RemoteFile.kt @@ -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?, +) diff --git a/app/src/main/kotlin/com/syncflow/domain/model/SyncConflict.kt b/app/src/main/kotlin/com/syncflow/domain/model/SyncConflict.kt new file mode 100644 index 0000000..6029909 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/model/SyncConflict.kt @@ -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, +} diff --git a/app/src/main/kotlin/com/syncflow/domain/model/SyncEvent.kt b/app/src/main/kotlin/com/syncflow/domain/model/SyncEvent.kt new file mode 100644 index 0000000..e792138 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/model/SyncEvent.kt @@ -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, +} diff --git a/app/src/main/kotlin/com/syncflow/domain/model/SyncPair.kt b/app/src/main/kotlin/com/syncflow/domain/model/SyncPair.kt new file mode 100644 index 0000000..c45a61e --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/model/SyncPair.kt @@ -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, + val includeExtensions: List, // empty = all + val excludeExtensions: List, + 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, +} diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt new file mode 100644 index 0000000..a26383b --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -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() + + 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 { + 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, +) diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt new file mode 100644 index 0000000..21fedae --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairScreen.kt @@ -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 RadioGroup( + label: String?, + options: List, + 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 + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt new file mode 100644 index 0000000..df004ca --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/addpair/AddPairViewModel.kt @@ -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 = 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("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) } } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupScreen.kt b/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupScreen.kt new file mode 100644 index 0000000..f68b377 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupScreen.kt @@ -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" +} diff --git a/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupViewModel.kt new file mode 100644 index 0000000..74e48d2 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/auth/AccountSetupViewModel.kt @@ -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? { + 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, +) diff --git a/app/src/main/kotlin/com/syncflow/ui/auth/MsalAuthHelper.kt b/app/src/main/kotlin/com/syncflow/ui/auth/MsalAuthHelper.kt new file mode 100644 index 0000000..ac0ad77 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/auth/MsalAuthHelper.kt @@ -0,0 +1,3 @@ +package com.syncflow.ui.auth + +// Removed — OneDrive auth is now handled via OAuthHelper (PKCE + Chrome Custom Tab) diff --git a/app/src/main/kotlin/com/syncflow/ui/auth/OAuthHelper.kt b/app/src/main/kotlin/com/syncflow/ui/auth/OAuthHelper.kt new file mode 100644 index 0000000..023b3d5 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/auth/OAuthHelper.kt @@ -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? = 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? = 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, "") +} diff --git a/app/src/main/kotlin/com/syncflow/ui/auth/OAuthRedirectActivity.kt b/app/src/main/kotlin/com/syncflow/ui/auth/OAuthRedirectActivity.kt new file mode 100644 index 0000000..0e0c54c --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/auth/OAuthRedirectActivity.kt @@ -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() + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt new file mode 100644 index 0000000..d62deb6 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserDialog.kt @@ -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" +} diff --git a/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt new file mode 100644 index 0000000..1104e0d --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/browser/RemoteBrowserViewModel.kt @@ -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 = listOf("/"), + val entries: List = 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") } + } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictScreen.kt b/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictScreen.kt new file mode 100644 index 0000000..377378e --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictScreen.kt @@ -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" +} diff --git a/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictViewModel.kt new file mode 100644 index 0000000..4fe21eb --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/conflict/ConflictViewModel.kt @@ -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("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) } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt new file mode 100644 index 0000000..847d788 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeScreen.kt @@ -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())) diff --git a/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..1b1f57a --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/home/HomeViewModel.kt @@ -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}") + } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt new file mode 100644 index 0000000..de77fd4 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/main/MainShell.kt @@ -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) + } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/navigation/NavGraph.kt b/app/src/main/kotlin/com/syncflow/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..7513d9e --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/navigation/NavGraph.kt @@ -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() }) + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt new file mode 100644 index 0000000..285333c --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailScreen.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt new file mode 100644 index 0000000..0bc61f9 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/pairdetail/PairDetailViewModel.kt @@ -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("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) } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..a8da50d --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsScreen.kt @@ -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(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" +} diff --git a/app/src/main/kotlin/com/syncflow/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..0877225 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/settings/SettingsViewModel.kt @@ -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) } + } +} diff --git a/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt b/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt new file mode 100644 index 0000000..82f1f95 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt b/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt new file mode 100644 index 0000000..d0b86ac --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt new file mode 100644 index 0000000..8575882 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/worker/BootReceiver.kt @@ -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() + } + } + } +} diff --git a/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt new file mode 100644 index 0000000..e96a6a4 --- /dev/null +++ b/app/src/main/kotlin/com/syncflow/worker/SyncWorker.kt @@ -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() + .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(intervalMinutes, TimeUnit.MINUTES) + .setInputData(workDataOf(KEY_PAIR_ID to pairId)) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .addTag("sync_$pairId") + .build() + } + } +} diff --git a/app/src/main/res/drawable/ic_provider_dropbox.xml b/app/src/main/res/drawable/ic_provider_dropbox.xml new file mode 100644 index 0000000..929bb6d --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_dropbox.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_googledrive.xml b/app/src/main/res/drawable/ic_provider_googledrive.xml new file mode 100644 index 0000000..c129624 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_googledrive.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_nextcloud.xml b/app/src/main/res/drawable/ic_provider_nextcloud.xml new file mode 100644 index 0000000..cbed636 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_nextcloud.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_onedrive.xml b/app/src/main/res/drawable/ic_provider_onedrive.xml new file mode 100644 index 0000000..1967fc8 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_onedrive.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_owncloud.xml b/app/src/main/res/drawable/ic_provider_owncloud.xml new file mode 100644 index 0000000..047724d --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_owncloud.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_sftp.xml b/app/src/main/res/drawable/ic_provider_sftp.xml new file mode 100644 index 0000000..478659d --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_sftp.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_sftpgo.xml b/app/src/main/res/drawable/ic_provider_sftpgo.xml new file mode 100644 index 0000000..b78addd --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_sftpgo.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_provider_webdav.xml b/app/src/main/res/drawable/ic_provider_webdav.xml new file mode 100644 index 0000000..5d3d899 --- /dev/null +++ b/app/src/main/res/drawable/ic_provider_webdav.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..40c40d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.xml b/app/src/main/res/mipmap-mdpi/ic_launcher.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-mdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-mdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml new file mode 100644 index 0000000..0028949 --- /dev/null +++ b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/raw/msal_config.json b/app/src/main/res/raw/msal_config.json new file mode 100644 index 0000000..651ce5f --- /dev/null +++ b/app/src/main/res/raw/msal_config.json @@ -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" + } + } + ] +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..cd6c0ad --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2196F3 + diff --git a/app/src/main/res/values/secrets.xml b/app/src/main/res/values/secrets.xml new file mode 100644 index 0000000..ab42122 --- /dev/null +++ b/app/src/main/res/values/secrets.xml @@ -0,0 +1,14 @@ + + + + + YOUR_DROPBOX_APP_KEY + + YOUR_GOOGLE_CLIENT_ID + + YOUR_ONEDRIVE_CLIENT_ID + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b87441d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + SyncFlow + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f33cabe --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..cb90978 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..03a8974 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..dc41c8a --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..6115950 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..337ae03 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4c9d457 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..92873b6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..400b976 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..7ea2340 --- /dev/null +++ b/gradlew @@ -0,0 +1,2 @@ +#!/bin/bash +exec /home/amir/gradle/gradle-8.6/bin/gradle "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..89d5363 --- /dev/null +++ b/settings.gradle.kts @@ -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")