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