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