Compare commits

...

13 Commits

Author SHA1 Message Date
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

Icon: white cloud body with two cyan/teal circular sync arcs (AutoSync
style), deep blue-to-teal gradient background.

Security review clean: no hardcoded credentials, cleartext blocked by
network_security_config, allowBackup=false, path traversal guards in
place on both server responses and local resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:45:43 +00:00
amir 8fdd22bc98 v1.0.25: multi-select files, unified notification, dark theme, icon redesign
- FilesScreen: long-press enters selection mode, bulk share/delete toolbar, BackHandler
- FilesViewModel: ShareMultiple action, isSelectionMode/selectedCount state, download-to-cache for open/share
- FileWatchService: recursive FileObserver per subdirectory; unified notification updated with sync result via WorkManager flow observation
- SyncWorker: silent flag suppresses notifications when triggered by watcher; emits KEY_RESULT_SUMMARY output data
- Passbolt-inspired dark theme (Red700/Red500 primary, near-black surface)
- App icon: circular AutoSync-style sync arrows (cyan gradient, deep navy background)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:22:43 +00:00
amir 146b8baf9a v1.0.24: harmonious icon, recursive file watching, download-then-open, security fixes
Icon: three identical parallel arcing arrows (same bezier curve, same blue-to-teal
gradient #64C8FF→#32EDBB, same arrowhead geometry) — visually cohesive and clearly
visible against the near-black background.

FileWatchService: FileObserver is now recursive — watchDirRecursive() creates an
observer for each subdirectory at startup, and adds new watchers when CREATE events
produce new directories. Fixes files added to subdirectories not being detected.

FilesViewModel: openFile/shareFile now fall back to download-then-open when the file
is absent locally. AccountRepository + ProviderFactory injected; downloads to
context.cacheDir/syncflow_open/ with isDownloading state. Path traversal guard added
(reject relativePath containing ".."). file_paths.xml gains cache-path entry.

WebDavProvider: path-traversal guard in parsePropfind — skip any server-returned
filename containing "..", "/" or "\". Replace android.util.Log with Timber so debug
logs are stripped from release builds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:37:16 +00:00
amir 08dc4f5bd4 v1.0.23: functional Files tab, background service persistence, startup indexer, curved icon
- FilesScreen: per-file context menu (Open, Share, Rename, Delete), rename dialog,
  delete confirmation, FileProvider-based open/share intents, Snackbar error feedback
- FilesViewModel: FileAction sealed class + SharedFlow; openFile, shareFile,
  deleteFile, renameFile with DB cleanup; resolveFile handles SAF primary: URIs
- FileWatchService: stopWithTask=false keeps watcher alive after app swipe-away;
  catchupScan on startup detects changes missed while service was not running;
  SyncFileStateDao injected; FileObserver used for real-path SAF URIs
- BootReceiver: handles MY_PACKAGE_REPLACED to restart service after app update
- file_paths.xml: added external-path so FileProvider can serve /storage/emulated/0 files
- ic_launcher_foreground: three curved stroke-based arrows (quadratic bezier, round caps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 23:25:58 +00:00
amir 422e8f0f0f feat: fix sync counters, polished activity rows, Files tab, new icon
- Fix SYNC_COMPLETED showing ↑0 ↓0 ✗0 when only deletions occurred: add ✕N
  for deleted files to the summary message (↑N ↓N ✕N ✗N format)
- Fix PairDetail Activity section showing raw "SYNC_STARTED" enum names and
  "remote" as a plain subtitle: replace dot-based EventRow with the same
  polished icon-bubble rows as the global Log tab
- Extract shared SyncEventRow composable + iconAndTint/label helpers to
  ui/shared/SyncEventRow.kt; both LogScreen and PairDetailScreen now use it
- Add Files tab (4th tab between Log and Accounts): folder browser showing
  all synced files per pair, grouped by subdirectory, with file-type icons,
  size, last-synced date, and a summary header (N files, total size)
- Add SyncFileStateDao.observeForPair() reactive Flow query for Files tab
- Completely redesign app icon: near-black radial gradient background with
  three bold directional arrows in an S-pattern (coral → silver → teal),
  each with gradient fills and tip-glow dots — entirely different from the
  typical circular sync-arrow style
- Bump version to 1.0.22 (build 23)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 22:05:28 +00:00
amir a7c5ed713a feat: fix notifications on Android 13+/16, add Log tab, fix ON_CHANGE detection
- Request POST_NOTIFICATIONS permission at runtime in MainActivity (primary fix
  for notifications never appearing on Android 13+ phones including Android 16)
- Register all 4 notification channels eagerly in SyncFlowApp.onCreate() instead
  of lazily inside workers
- Add FOREGROUND_SERVICE_SHORT_SERVICE permission + shortService foreground type
  for Android 16 foreground service compatibility
- Add global activity Log tab (new tab 2 in main nav) showing all sync events
  across all pairs, grouped by date with pair name, event icon, and file detail
- Fix FileWatchService ON_CHANGE detection: ContentObserver on SAF tree URIs only
  fires for SAF-API writes, not raw filesystem writes. Now resolves primary:/*
  tree URIs to /storage/emulated/0/* and uses FileObserver for reliable detection
- Bump version to 1.0.21 (build 22)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:34:48 +00:00
amir 739e6ece46 fix: implement findExistingAlgorithms in TofuHostKeyVerifier (sshj 0.38 API)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:11:30 +00:00
amir d70defe3e1 build: add missing gradle-wrapper.jar
Required by the standard gradlew launcher. Was absent because the original
gradlew bypassed the wrapper mechanism entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:08:00 +00:00
amir a4aca43fa7 build: fix gradlew and wrapper URL to work on any machine
gradlew was hardcoded to /home/amir/gradle/gradle-8.6/bin/gradle.
gradle-wrapper.properties used a local file:// URL.
Both now use the standard portable approach (HTTPS distribution URL)
so builds work in CI and on any dev machine without a local Gradle install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:07:24 +00:00
amir cfac742856 ci: add Gitea Actions workflow to build and attach APK on tag push
Triggers on v* tags — sets up Java 17 + Android SDK, builds a debug APK
(installable without a keystore), renames it SyncFlow-v<version>.apk, and
uploads it to the matching Gitea release via the API using the built-in token.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:51:37 +00:00
amir be3f46287a security: fix all review findings, bump to 1.0.19 (build 20)
CRITICAL
- SftpProvider: replace PromiscuousVerifier with TofuHostKeyVerifier
  (trust-on-first-use; stores SHA-256 fingerprints in EncryptedSharedPreferences;
  rejects key changes on subsequent connections)

HIGH
- GoogleDriveProvider: replace raw string interpolation with buildJsonObject
  in uploadFile, createDirectory, and moveFile to prevent JSON injection
- DropboxProvider: replace all raw JSON strings and Dropbox-API-Arg headers
  with buildJsonObject for the same reason
- OAuthHelper: add cryptographically random state parameter to Dropbox and
  OneDrive authorization URLs (stored alongside the PKCE verifier)
- OAuthRedirectActivity: validate returned state against stored value before
  exchanging the authorization code (CSRF protection)

MEDIUM
- WebDavProvider: block cross-host redirects in the manual redirect interceptor
  so Authorization headers are never forwarded to a different server
- AccountSetupScreen: set FLAG_SECURE on the window while credential fields
  are visible to prevent screenshots and screen-recording capture
- libs.versions.toml: security-crypto alpha06 → stable 1.0.0;
  biometric-ktx alpha05 → biometric 1.1.0 (stable, non-ktx artifact matches
  the BiometricManager/BiometricPrompt API actually used in MainActivity)
- CredentialStore: migrate to security-crypto 1.0.0 API (MasterKeys.getOrCreate
  + positional create() args); add saveHostKey/getHostFingerprint for SFTP TOFU

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:08:40 +00:00
amir 894c2ffe78 v1.0.18: fix ON_CHANGE never starting, improve version display
- Start FileWatchService from SyncFlowApp.onCreate() for any existing
  enabled ON_CHANGE pairs — previously the watcher only started on
  device boot or explicit pair toggle, so existing pairs after an
  app update never got watched
- About screen now shows "Version X.Y.Z (build N)" updating
  automatically from BuildConfig on every release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:11:47 +00:00
amir 59335dab13 v1.0.17: modern multi-color app icon with depth and detail
Redesigned launcher icon:
- Background: deep violet #2E1065 → purple #6D28D9 → navy #1E40AF
- Three concentric glow rings (white, layered alpha) for depth
- Upload arrow: neon cyan #67E8F9 → sky blue #38BDF8
- Download arrow: hot pink #F472B6 → coral #FB923C
- Double-layer center orb (frosted + solid white)
- 4 cardinal accent sparks (cyan/indigo/pink/emerald)
- 4 diagonal mini sparks (light cyan/peach/violet/green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:48:18 +00:00
40 changed files with 1701 additions and 228 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Build & Release APK
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- uses: android-actions/setup-android@v3
- name: Build debug APK
run: |
chmod +x gradlew
./gradlew assembleDebug --no-daemon
- name: Get version name
id: ver
run: echo "name=$(grep VERSION_NAME version.properties | cut -d= -f2)" >> $GITHUB_OUTPUT
- name: Rename APK
run: |
mkdir dist
cp app/build/outputs/apk/debug/app-debug.apk \
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
- name: Attach APK to release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.ver.outputs.name }}
run: |
RELEASE_ID=$(curl -sf \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/$RELEASE_ID/assets" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
echo "APK uploaded to release $TAG"
+6 -2
View File
@@ -8,6 +8,7 @@
<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.FOREGROUND_SERVICE_SHORT_SERVICE" />
<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" />
@@ -65,19 +66,22 @@
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- File watcher for ON_CHANGE sync pairs -->
<!-- stopWithTask=false keeps the service alive when the user swipes the app away -->
<service
android:name=".worker.FileWatchService"
android:foregroundServiceType="dataSync"
android:foregroundServiceType="dataSync|shortService"
android:stopWithTask="false"
android:exported="false" />
<!-- Required on API 29+ so WorkManager can start a typed foreground service -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:foregroundServiceType="dataSync|shortService"
tools:node="merge" />
<!-- Remove WorkManager's default initializer — app uses on-demand init via Configuration.Provider -->
@@ -1,9 +1,12 @@
package com.syncflow
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
@@ -49,10 +52,14 @@ class MainActivity : AppCompatActivity() {
private var isLocked by mutableStateOf(false)
private var showRetry by mutableStateOf(false)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* proceed regardless */ }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermissionIfNeeded()
setContent {
SyncFlowTheme {
Surface(modifier = Modifier.fillMaxSize()) {
@@ -127,6 +134,16 @@ class MainActivity : AppCompatActivity() {
BIOMETRIC_WEAK or DEVICE_CREDENTIAL
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun canAuthenticate(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
return BiometricManager.from(this).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
@@ -1,9 +1,18 @@
package com.syncflow
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import com.syncflow.worker.FileWatchService
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -11,10 +20,38 @@ import javax.inject.Inject
class SyncFlowApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var syncPairDao: SyncPairDao
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
createNotificationChannels()
// Start file watcher on every app launch for any existing ON_CHANGE pairs
CoroutineScope(Dispatchers.IO).launch {
val hasOnChange = syncPairDao.getEnabled().any { it.scheduleType == ScheduleType.ON_CHANGE }
if (hasOnChange) FileWatchService.start(this@SyncFlowApp)
}
}
private fun createNotificationChannels() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
listOf(
NotificationChannel("sync_progress", "Sync progress", NotificationManager.IMPORTANCE_LOW).apply {
description = "Shown while a sync is running"
},
NotificationChannel("sync_complete", "Sync complete", NotificationManager.IMPORTANCE_LOW).apply {
description = "Summary after each successful sync"
},
NotificationChannel("sync_alerts", "Sync errors", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Alerts for sync failures and conflicts"
},
NotificationChannel("sync_watching", "File watching", NotificationManager.IMPORTANCE_MIN).apply {
description = "Background service watching folders for changes"
setShowBadge(false)
},
).forEach { channel ->
if (nm.getNotificationChannel(channel.id) == null) nm.createNotificationChannel(channel)
}
}
override val workManagerConfiguration: Configuration
@@ -9,6 +9,9 @@ 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>>
@Query("SELECT * FROM sync_events ORDER BY timestamp DESC LIMIT :limit")
fun observeAll(limit: Int = 500): Flow<List<SyncEventEntity>>
@Insert
suspend fun insert(entity: SyncEventEntity): Long
@@ -2,9 +2,13 @@ package com.syncflow.data.db
import androidx.room.*
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncFileStateDao {
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId ORDER BY relativePath ASC")
fun observeForPair(pairId: Long): Flow<List<SyncFileStateEntity>>
@Query("SELECT * FROM sync_file_states WHERE syncPairId = :pairId")
suspend fun getForPair(pairId: Long): List<SyncFileStateEntity>
@@ -7,19 +7,20 @@ 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.data.security.CredentialStore
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() {
class ProviderFactory @Inject constructor(private val credentialStore: CredentialStore) {
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.SFTP -> SftpProvider(account, credentialStore)
ProviderType.NEXTCLOUD -> NextcloudProvider(account)
ProviderType.OWNCLOUD -> OwnCloudProvider(account)
ProviderType.SFTPGO -> WebDavProvider(account) // SFTPGo exposes WebDAV
@@ -18,9 +18,9 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
private val client = OkHttpClient()
private fun apiReq(url: String, bodyJson: String): Request =
private fun apiReq(url: String, argJson: JsonObject): Request =
Request.Builder().url(url)
.post(bodyJson.toRequestBody("application/json".toMediaType()))
.post(argJson.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.build()
@@ -33,7 +33,8 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
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}""")
val arg = buildJsonObject { put("path", path); put("recursive", false) }
val req = apiReq("https://api.dropboxapi.com/2/files/list_folder", arg)
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")
@@ -44,11 +45,15 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
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 arg = buildJsonObject {
put("path", remotePath.normalizeDropbox())
put("mode", "overwrite")
put("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()
.header("Dropbox-API-Arg", arg.toString()).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")
@@ -58,11 +63,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
val argHeader = """{"path":"${remotePath.normalizeDropbox()}"}"""
val arg = buildJsonObject { put("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()
.header("Dropbox-API-Arg", arg.toString()).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}")
var total = 0L
@@ -75,17 +80,20 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
override suspend fun deleteFile(remotePath: String): Result<Unit> = runCatching {
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", """{"path":"${remotePath.normalizeDropbox()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/delete_v2", arg)
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()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/create_folder_v2", arg)
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()}"}""")
val arg = buildJsonObject { put("path", remotePath.normalizeDropbox()) }
val req = apiReq("https://api.dropboxapi.com/2/files/get_metadata", arg)
client.newCall(req).execute().use { resp ->
val body = resp.body?.string() ?: throw Exception("HTTP ${resp.code}")
Json.parseToJsonElement(body).jsonObject.toRemoteFile()
@@ -93,8 +101,11 @@ class DropboxProvider(private val account: CloudAccount) : CloudProvider {
}
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()}"}""")
val arg = buildJsonObject {
put("from_path", fromPath.normalizeDropbox())
put("to_path", toPath.normalizeDropbox())
}
val req = apiReq("https://api.dropboxapi.com/2/files/move_v2", arg)
client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw Exception("HTTP ${resp.code}") }
}
@@ -44,9 +44,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
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())
// Multipart upload — use JSON builder to avoid injection via filenames with special chars
val metaPart = buildJsonObject {
put("name", name)
put("parents", buildJsonArray { add(parentId) })
}.toString().toRequestBody("application/json".toMediaType())
val dataPart = bytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = MultipartBody.Builder()
.setType(MultipartBody.FORM)
@@ -86,8 +88,11 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
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 body = buildJsonObject {
put("name", name)
put("mimeType", "application/vnd.google-apps.folder")
put("parents", buildJsonArray { add(parentId) })
}.toString().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}") }
}
@@ -102,7 +107,8 @@ class GoogleDriveProvider(private val account: CloudAccount) : CloudProvider {
override suspend fun moveFile(fromPath: String, toPath: String): Result<Unit> = runCatching {
val newName = toPath.substringAfterLast('/')
val body = """{"name":"$newName"}""".toRequestBody("application/json".toMediaType())
val body = buildJsonObject { put("name", newName) }.toString()
.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}") }
}
@@ -1,6 +1,7 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.providers.CloudProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.serialization.json.Json
@@ -8,13 +9,12 @@ 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 {
class SftpProvider(private val account: CloudAccount, private val credentialStore: CredentialStore) : CloudProvider {
private val creds = Json.parseToJsonElement(account.credentialJson).jsonObject
private val host = account.serverUrl ?: "localhost"
@@ -25,7 +25,7 @@ class SftpProvider(private val account: CloudAccount) : CloudProvider {
private fun <T> withSftp(block: (SFTPClient) -> T): T {
val ssh = SSHClient()
ssh.addHostKeyVerifier(PromiscuousVerifier()) // TODO: replace with proper key pinning
ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
ssh.connect(host, port)
try {
if (!privateKey.isNullOrBlank()) {
@@ -0,0 +1,35 @@
package com.syncflow.data.providers.sftp
import com.syncflow.data.security.CredentialStore
import net.schmizz.sshj.transport.verification.HostKeyVerifier
import java.security.MessageDigest
import java.security.PublicKey
/**
* Trust-On-First-Use SSH host key verifier.
*
* First connection to a host: fingerprint is stored in EncryptedSharedPreferences and accepted.
* Subsequent connections: stored fingerprint must match — mismatch aborts (possible MITM).
*/
class TofuHostKeyVerifier(private val credentialStore: CredentialStore) : HostKeyVerifier {
override fun verify(hostname: String, port: Int, key: PublicKey): Boolean {
val fingerprint = sha256Fingerprint(key)
val stored = credentialStore.getHostFingerprint(hostname, port)
return if (stored == null) {
credentialStore.saveHostKey(hostname, port, fingerprint)
true
} else {
stored == fingerprint
}
}
// Return empty list so sshj uses server preference order for key exchange.
// Our verify() will accept or reject whatever algorithm is negotiated.
override fun findExistingAlgorithms(hostname: String, port: Int): List<String> = emptyList()
private fun sha256Fingerprint(key: PublicKey): String {
val digest = MessageDigest.getInstance("SHA-256").digest(key.encoded)
return digest.joinToString(":") { "%02x".format(it) }
}
}
@@ -1,7 +1,7 @@
package com.syncflow.data.providers.webdav
import android.util.Log
import com.syncflow.data.providers.CloudProvider
import timber.log.Timber
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers
@@ -10,6 +10,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
@@ -38,9 +39,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
.header("Authorization", Credentials.basic(user, pass))
.build()
val resp = chain.proceed(req)
// follow redirect manually for WebDAV methods (OkHttp skips non-GET/HEAD redirects)
// Follow redirects for WebDAV methods (OkHttp skips non-GET/HEAD redirects).
// Only follow same-host redirects to prevent credential leakage to a different server.
if (resp.code in 301..308) {
val location = resp.header("Location") ?: return@addInterceptor resp
val redirectHost = location.toHttpUrlOrNull()?.host
if (redirectHost == null || redirectHost != req.url.host) {
return@addInterceptor resp
}
resp.close()
val redirectReq = req.newBuilder().url(location).build()
chain.proceed(redirectReq)
@@ -53,14 +59,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
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")
Timber.d("WebDAV testConnection %d url=%s", resp.code, baseUrl)
if (!resp.isSuccessful && resp.code != 207) {
val body = resp.body?.string()?.take(300) ?: ""
throw Exception("HTTP ${resp.code} ${resp.message}$body")
throw Exception("HTTP ${resp.code} ${resp.message}")
}
}
}
}.onFailure { e -> Log.e("SyncFlow/WebDAV", "testConnection failed: ${e::class.simpleName}: ${e.message}", e.cause) }
}.onFailure { e -> Timber.e(e, "WebDAV testConnection failed") }
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withContext(Dispatchers.IO) {
@@ -186,9 +191,14 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
"response" -> if (inResponse && href.isNotBlank()) {
val rawName = href.trimEnd('/').substringAfterLast('/')
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
// Guard against path-traversal sequences delivered by a malicious server
if (name.contains("..") || name.contains('/') || name.contains('\\')) {
inResponse = false
} else {
val relPath = "$parentPath/$name".replace("//", "/")
results.add(RemoteFile(relPath, name, isCollection, contentLength, lastModified, etag, contentType))
inResponse = false
}
}
}
}
@@ -3,7 +3,7 @@ package com.syncflow.data.security
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import androidx.security.crypto.MasterKeys
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,13 +12,12 @@ import javax.inject.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()
@Suppress("DEPRECATION")
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
context,
"syncflow_credentials",
masterKey,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
@@ -37,7 +36,7 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(credKey(accountId)).apply()
}
// ── PKCE verifiers (OAuth flow) ───────────────────────────────────────────
// ── PKCE verifiers and OAuth state (OAuth flow) ───────────────────────────
fun savePkceVerifier(provider: String, verifier: String) {
prefs.edit().putString(pkceKey(provider), verifier).apply()
@@ -49,8 +48,18 @@ class CredentialStore @Inject constructor(@ApplicationContext private val contex
prefs.edit().remove(pkceKey(provider)).apply()
}
// ── SFTP host key fingerprints (TOFU) ─────────────────────────────────────
fun saveHostKey(host: String, port: Int, fingerprint: String) {
prefs.edit().putString(hostKey(host, port), fingerprint).apply()
}
fun getHostFingerprint(host: String, port: Int): String? =
prefs.getString(hostKey(host, port), null)
// ── Key helpers ───────────────────────────────────────────────────────────
private fun credKey(accountId: Long) = "cred_$accountId"
private fun pkceKey(provider: String) = "pkce_$provider"
private fun hostKey(host: String, port: Int) = "sshhost_${host}_$port"
}
@@ -46,7 +46,7 @@ class SyncEngine @Inject constructor(
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)
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "${result.uploaded}${result.downloaded}${result.deleted}${result.failedFiles}", result.bytesTransferred)
result
} catch (e: Exception) {
Timber.e(e, "Sync failed for pair ${pair.id}")
@@ -2,6 +2,7 @@ package com.syncflow.ui.auth
import android.accounts.AccountManager
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
@@ -201,6 +202,15 @@ private fun CredentialContent(
) {
val provider = state.providerType ?: return
// Prevent screenshots and screen recording while credentials are visible
val activity = LocalContext.current as? Activity
DisposableEffect(Unit) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
Column(
modifier = modifier
.padding(horizontal = 20.dp)
@@ -38,7 +38,9 @@ private fun generateChallenge(verifier: String): String {
fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKey: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("dropbox", verifier)
credentialStore.savePkceVerifier("dropbox_state", state)
val challenge = generateChallenge(verifier)
val url = "https://www.dropbox.com/oauth2/authorize" +
"?client_id=$appKey" +
@@ -46,13 +48,16 @@ fun launchDropboxOAuth(context: Context, credentialStore: CredentialStore, appKe
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fdropbox" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256" +
"&token_access_type=offline"
"&token_access_type=offline" +
"&state=$state"
openCustomTab(context, url)
}
fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clientId: String) {
val verifier = generateVerifier()
val state = generateVerifier()
credentialStore.savePkceVerifier("onedrive", verifier)
credentialStore.savePkceVerifier("onedrive_state", state)
val challenge = generateChallenge(verifier)
val scopes = "Files.ReadWrite+User.Read+offline_access"
val url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
@@ -61,7 +66,8 @@ fun launchOneDriveOAuth(context: Context, credentialStore: CredentialStore, clie
"&redirect_uri=syncflow%3A%2F%2Foauth%2Fonedrive" +
"&scope=$scopes" +
"&code_challenge=$challenge" +
"&code_challenge_method=S256"
"&code_challenge_method=S256" +
"&state=$state"
openCustomTab(context, url)
}
@@ -28,11 +28,22 @@ class OAuthRedirectActivity : ComponentActivity() {
private fun handleIntent(intent: Intent) {
val uri = intent.data ?: run { finish(); return }
val code = uri.getQueryParameter("code") ?: run { finish(); return }
val returnedState = uri.getQueryParameter("state") ?: 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 }
}
// Validate state before doing anything with the code (CSRF protection)
val storedState = credentialStore.getPkceVerifier("${provider}_state")
if (storedState == null || returnedState != storedState) {
finish()
return
}
credentialStore.removePkceVerifier("${provider}_state")
val appKey = getString(com.syncflow.R.string.dropbox_app_key)
val odClientId = getString(com.syncflow.R.string.onedrive_client_id)
lifecycleScope.launch {
@@ -0,0 +1,522 @@
package com.syncflow.ui.files
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity
import kotlinx.coroutines.launch
import java.io.File
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun FilesScreen(
modifier: Modifier = Modifier,
vm: FilesViewModel = hiltViewModel(),
) {
val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
val selectedKeys by vm.selectedKeys.collectAsState()
val isSelectionMode = selectedKeys.isNotEmpty()
val selectedCount = selectedKeys.size
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var showDeleteSelectedDialog by remember { mutableStateOf(false) }
BackHandler(enabled = isSelectionMode) { vm.clearSelection() }
LaunchedEffect(Unit) {
vm.fileAction.collect { action ->
when (action) {
is FileAction.Open -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Open with").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
}
}
is FileAction.Share -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share via").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share file: ${e.message}")
}
}
is FileAction.ShareMultiple -> {
try {
val uris = action.files.map { file ->
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "*/*"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share files").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share: ${e.message}")
}
}
is FileAction.Error -> scope.launch {
snackbarHostState.showSnackbar(action.message)
}
}
}
}
if (showDeleteSelectedDialog) {
AlertDialog(
onDismissRequest = { showDeleteSelectedDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete $selectedCount file${if (selectedCount != 1) "s" else ""}?") },
text = { Text("Selected files will be removed from this device.") },
confirmButton = {
TextButton(onClick = {
vm.deleteSelected()
showDeleteSelectedDialog = false
}) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { showDeleteSelectedDialog = false }) { Text("Cancel") }
},
)
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
// Selection toolbar
if (isSelectionMode) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
tonalElevation = 3.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { vm.clearSelection() }) {
Icon(Icons.Default.Close, contentDescription = "Clear selection")
}
Text(
"$selectedCount selected",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { vm.shareSelected() }) {
Icon(Icons.Default.Share, contentDescription = "Share selected")
}
IconButton(onClick = { showDeleteSelectedDialog = true }) {
Icon(
Icons.Default.Delete, contentDescription = "Delete selected",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
HorizontalDivider()
}
// Pair tabs
if (pairs.size > 1 && !isSelectionMode) {
ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
divider = {},
) {
pairs.forEach { pair ->
Tab(
selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) },
text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
HorizontalDivider()
}
when {
pairs.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No sync pairs yet",
subtitle = "Create a sync pair to browse its files",
)
files.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No synced files yet",
subtitle = "Run a sync to populate this view",
)
else -> {
if (!isSelectionMode) {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
val grouped = files.groupBy { f ->
val idx = f.relativePath.indexOf('/')
if (idx < 0) "" else f.relativePath.substring(0, idx)
}
grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty() && !isSelectionMode) {
item(key = "dir_$dir") {
Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Folder, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(6.dp))
Text(
dir,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(
file = file,
isInSubDir = dir.isNotEmpty() && !isSelectionMode,
isSelectionMode = isSelectionMode,
isSelected = vm.fileKey(file) in selectedKeys,
vm = vm,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(start = if (dir.isNotEmpty() && !isSelectionMode) 38.dp else 16.dp),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
// Download progress
if (isDownloading) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Downloading for preview…", style = MaterialTheme.typography.bodySmall)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
@Composable
private fun FilesEmptyState(icon: ImageVector, title: String, subtitle: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
icon, contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileRow(
file: SyncFileStateEntity,
isInSubDir: Boolean,
isSelectionMode: Boolean,
isSelected: Boolean,
vm: FilesViewModel,
) {
val name = file.relativePath.substringAfterLast('/')
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
var menuExpanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
if (showRenameDialog) {
RenameDialog(
currentName = name,
onConfirm = { newName -> vm.renameFile(file, newName); showRenameDialog = false },
onDismiss = { showRenameDialog = false },
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete file?") },
text = { Text("\"$name\" will be removed from this device.") },
confirmButton = {
TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
},
)
}
Surface(
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (isSelectionMode) vm.toggleSelection(file) else menuExpanded = true
},
onLongClick = { vm.toggleSelection(file) },
),
) {
Row(
modifier = Modifier.padding(
start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp,
bottom = 10.dp,
end = 0.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
if (isSelectionMode) {
Checkbox(
checked = isSelected,
onCheckedChange = { vm.toggleSelection(file) },
modifier = Modifier.padding(horizontal = 4.dp),
)
} else {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (!isSelectionMode) {
Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = "File options",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Open") },
leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
onClick = { menuExpanded = false; vm.openFile(file) },
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, null) },
onClick = { menuExpanded = false; vm.shareFile(file) },
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, null) },
onClick = { menuExpanded = false; showRenameDialog = true },
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
)
}
}
}
}
}
}
@Composable
private fun RenameDialog(currentName: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var newName by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
title = { Text("Rename file") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed) else onDismiss()
},
enabled = newName.isNotBlank(),
) { Text("Rename") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun String.mimeType(): String {
val ext = substringAfterLast('.', "").lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
}
private fun fileIcon(name: String) = when {
name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf
name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) || name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) || name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) || name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile
}
private fun Long.toDisplaySize(): String = when {
this < 1_024 -> "$this B"
this < 1_048_576 -> "${"%.1f".format(this / 1_024.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,237 @@
package com.syncflow.ui.files
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.providers.ProviderFactory
import com.syncflow.data.repository.AccountRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.io.File
import javax.inject.Inject
sealed class FileAction {
data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction()
data class ShareMultiple(val files: List<File>) : FileAction()
data class Error(val message: String) : FileAction()
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FilesViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
private val fileStateDao: SyncFileStateDao,
private val accountRepository: AccountRepository,
private val providerFactory: ProviderFactory,
@ApplicationContext private val context: Context,
) : ViewModel() {
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
list.firstOrNull { it.id == id } ?: list.firstOrNull()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val files: StateFlow<List<SyncFileStateEntity>> = _selectedPairId
.flatMapLatest { id ->
if (id == null) pairs.map { it.firstOrNull()?.id }.filterNotNull()
.flatMapLatest { fileStateDao.observeForPair(it) }
else fileStateDao.observeForPair(id)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _fileAction = MutableSharedFlow<FileAction>()
val fileAction: SharedFlow<FileAction> = _fileAction
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
}
}
fun shareFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
viewModelScope.launch { _fileAction.emit(FileAction.Share(resolved)) }
} else {
downloadAndShare(file)
}
}
fun deleteFile(file: SyncFileStateEntity) {
viewModelScope.launch {
try {
val resolved = resolveFile(file, emitErrorIfMissing = false)
resolved?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Delete failed: ${file.relativePath}")
_fileAction.emit(FileAction.Error("Delete failed: ${e.message}"))
}
}
}
fun renameFile(file: SyncFileStateEntity, newName: String) {
viewModelScope.launch {
try {
val resolved = resolveFile(file) ?: return@launch
val parent = resolved.parentFile ?: return@launch
val dest = File(parent, newName)
if (!resolved.renameTo(dest)) {
_fileAction.emit(FileAction.Error("Rename failed"))
return@launch
}
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Rename failed: ${file.relativePath}")
_fileAction.emit(FileAction.Error("Rename failed: ${e.message}"))
}
}
}
fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value
fun toggleSelection(file: SyncFileStateEntity) {
val key = fileKey(file)
_selectedKeys.update { if (key in it) it - key else it + key }
}
fun clearSelection() { _selectedKeys.value = emptySet() }
fun deleteSelected() {
viewModelScope.launch {
val toDelete = files.value.filter { isSelected(it) }
toDelete.forEach { file ->
try {
resolveFile(file, emitErrorIfMissing = false)?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Bulk delete failed: ${file.relativePath}")
}
}
clearSelection()
}
}
fun shareSelected() {
viewModelScope.launch {
val toShare = files.value.filter { isSelected(it) }
val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) }
if (resolved.isEmpty()) {
_fileAction.emit(FileAction.Error("No local files available to share"))
return@launch
}
_fileAction.emit(FileAction.ShareMultiple(resolved))
}
}
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Open(cached))
}
}
}
private fun downloadAndShare(file: SyncFileStateEntity) {
viewModelScope.launch {
downloadToCache(file)?.let { cached ->
_fileAction.emit(FileAction.Share(cached))
}
}
}
private suspend fun downloadToCache(file: SyncFileStateEntity): File? {
val pair = selectedPair.value ?: run {
_fileAction.emit(FileAction.Error("No sync pair selected"))
return null
}
val account = accountRepository.getAccount(pair.accountId) ?: run {
_fileAction.emit(FileAction.Error("Cloud account not found"))
return null
}
val provider = providerFactory.create(account)
val fileName = file.relativePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
return try {
cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
}
cacheFile
} catch (e: Exception) {
Timber.e(e, "Download for preview failed: ${file.relativePath}")
cacheFile.delete()
_fileAction.emit(FileAction.Error("Download failed: ${e.message}"))
null
} finally {
_isDownloading.value = false
}
}
// ── Path resolution ───────────────────────────────────────────────────────
private fun resolveFile(file: SyncFileStateEntity, emitErrorIfMissing: Boolean = true): File? {
// Guard against path traversal from untrusted server responses
if (file.relativePath.contains("..")) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("Invalid file path")) }
return null
}
val pair = selectedPair.value ?: return null
val root = safTreeUriToRealPath(pair.localPath) ?: pair.localPath
// localPath is a content:// URI we couldn't resolve — File-based access won't work
if (root.startsWith("content://")) return null
val f = File(root, file.relativePath)
if (!f.exists()) {
if (emitErrorIfMissing) {
viewModelScope.launch { _fileAction.emit(FileAction.Error("File not found on device")) }
}
return null
}
return f
}
private fun safTreeUriToRealPath(uriString: String): String? {
if (!uriString.startsWith("content://")) return uriString
return try {
val treeUri = android.net.Uri.parse(uriString)
val docId = android.provider.DocumentsContract.getTreeDocumentId(treeUri)
if (docId.startsWith("primary:")) "/storage/emulated/0/${docId.removePrefix("primary:")}"
else null
} catch (e: Exception) { null }
}
}
@@ -0,0 +1,152 @@
package com.syncflow.ui.log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.domain.model.SyncEventType
import com.syncflow.ui.shared.iconAndTint
import com.syncflow.ui.shared.label
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun LogScreen(
modifier: Modifier = Modifier,
vm: LogViewModel = hiltViewModel(),
) {
val entries by vm.entries.collectAsState()
if (entries.isEmpty()) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text("No activity yet", style = MaterialTheme.typography.titleMedium)
Text(
"Sync events will appear here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
// Group entries by calendar date
val grouped = entries.groupBy { entry ->
entry.event.timestamp.atZone(ZoneId.systemDefault()).toLocalDate()
}
grouped.forEach { (date, dayEntries) ->
item(key = date.toString()) {
Text(
text = date.toRelativeLabel(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
}
items(dayEntries, key = { it.event.id }) { entry ->
LogEntryRow(entry)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 52.dp),
)
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
@Composable
private fun LogEntryRow(entry: LogEntry) {
val (icon, tint) = entry.event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = entry.event.timestamp.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
// Icon bubble
Surface(
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.12f),
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
entry.pairName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f, fill = false),
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = entry.event.eventType.label(),
style = MaterialTheme.typography.bodySmall,
)
val detail = entry.event.filePath ?: entry.event.message
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
private fun java.time.LocalDate.toRelativeLabel(): String {
val today = java.time.LocalDate.now()
return when {
this == today -> "Today"
this == today.minusDays(1) -> "Yesterday"
else -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(this)
}
}
@@ -0,0 +1,29 @@
package com.syncflow.ui.log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncEventEntity
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class LogEntry(val event: SyncEventEntity, val pairName: String)
@HiltViewModel
class LogViewModel @Inject constructor(
syncEventDao: SyncEventDao,
syncPairDao: SyncPairDao,
) : ViewModel() {
val entries = combine(
syncEventDao.observeAll(500),
syncPairDao.observeAll(),
) { events, pairs ->
val pairNames = pairs.associateBy({ it.id }, { it.name })
events.map { LogEntry(it, pairNames[it.syncPairId] ?: "Unknown") }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
@@ -19,9 +19,13 @@ 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.Folder
import androidx.compose.material.icons.filled.ManageAccounts
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.ManageAccounts
import androidx.compose.material.icons.outlined.NotificationsNone
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -31,7 +35,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.syncflow.R
import com.syncflow.ui.files.FilesScreen
import com.syncflow.ui.home.HomeScreen
import com.syncflow.ui.log.LogScreen
import com.syncflow.ui.settings.SettingsScreen
import kotlinx.coroutines.launch
@@ -42,7 +48,7 @@ fun MainShell(
onPairClick: (Long) -> Unit,
onAddAccount: () -> Unit,
) {
val pagerState = rememberPagerState(pageCount = { 2 })
val pagerState = rememberPagerState(pageCount = { 4 })
val scope = rememberCoroutineScope()
val currentPage = pagerState.currentPage
@@ -88,7 +94,29 @@ fun MainShell(
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
icon = {
Icon(
if (currentPage == 1) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
if (currentPage == 1) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone,
contentDescription = null,
)
},
label = { Text("Log") },
)
NavigationBarItem(
selected = currentPage == 2,
onClick = { scope.launch { pagerState.animateScrollToPage(2) } },
icon = {
Icon(
if (currentPage == 2) Icons.Filled.Folder else Icons.Outlined.FolderOpen,
contentDescription = null,
)
},
label = { Text("Files") },
)
NavigationBarItem(
selected = currentPage == 3,
onClick = { scope.launch { pagerState.animateScrollToPage(3) } },
icon = {
Icon(
if (currentPage == 3) Icons.Filled.ManageAccounts else Icons.Outlined.ManageAccounts,
contentDescription = null,
)
},
@@ -120,7 +148,9 @@ fun MainShell(
) { page ->
when (page) {
0 -> HomeScreen(onAddPair = onAddPair, onPairClick = onPairClick)
1 -> SettingsScreen(onAddAccount = onAddAccount)
1 -> LogScreen()
2 -> FilesScreen()
3 -> SettingsScreen(onAddAccount = onAddAccount)
}
}
}
@@ -17,14 +17,12 @@ 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.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncEventEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncEventType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -132,7 +130,7 @@ fun PairDetailScreen(
}
} else {
items(events, key = { it.id }) { event ->
EventRow(event)
SyncEventRow(event, showDivider = event != events.last())
}
}
}
@@ -231,56 +229,6 @@ private fun InfoRow(
}
}
@Composable
private fun EventRow(event: SyncEventEntity) {
val fmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val zone = ZoneId.systemDefault()
val dotColor = eventColor(event.eventType)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Colored dot indicator
Surface(
shape = RoundedCornerShape(50),
color = dotColor,
modifier = Modifier.size(8.dp),
) {}
Spacer(Modifier.width(10.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 eventColor(type: SyncEventType): Color = when (type) {
SyncEventType.SYNC_STARTED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_COMPLETED -> MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> MaterialTheme.colorScheme.primary
}
private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -137,11 +137,17 @@ fun SettingsScreen(
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"SyncFlow v${com.syncflow.BuildConfig.VERSION_NAME} — Free, no subscription.",
style = MaterialTheme.typography.bodySmall,
"SyncFlow",
style = MaterialTheme.typography.titleSmall,
)
Text(
"Open source. No ads. No tracking.",
"Version ${com.syncflow.BuildConfig.VERSION_NAME} (build ${com.syncflow.BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
"Free, no subscription. No ads. No tracking.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -0,0 +1,102 @@
package com.syncflow.ui.shared
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
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
@Composable
fun SyncEventRow(event: SyncEventEntity, showDivider: Boolean = true) {
val (icon, tint) = event.eventType.iconAndTint()
val timeFmt = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val timeStr = timeFmt.format(event.timestamp.atZone(ZoneId.systemDefault()))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
verticalAlignment = Alignment.Top,
) {
Surface(
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.12f),
modifier = Modifier.size(36.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(icon, contentDescription = null, Modifier.size(18.dp), tint = tint)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
event.eventType.label(),
style = MaterialTheme.typography.labelMedium,
)
Text(
timeStr,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val detail = event.filePath ?: event.message
if (detail != null) {
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
if (showDivider) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(start = 48.dp),
)
}
}
@Composable
fun SyncEventType.iconAndTint(): Pair<ImageVector, Color> = when (this) {
SyncEventType.SYNC_STARTED -> Icons.Default.Sync to MaterialTheme.colorScheme.secondary
SyncEventType.SYNC_COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
SyncEventType.SYNC_FAILED -> Icons.Default.Error to MaterialTheme.colorScheme.error
SyncEventType.FILE_UPLOADED -> Icons.Default.CloudUpload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DOWNLOADED -> Icons.Default.CloudDownload to MaterialTheme.colorScheme.secondary
SyncEventType.FILE_DELETED -> Icons.Default.DeleteForever to MaterialTheme.colorScheme.tertiary
SyncEventType.FILE_SKIPPED -> Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant
SyncEventType.CONFLICT_DETECTED -> Icons.Default.Warning to MaterialTheme.colorScheme.tertiary
SyncEventType.CONFLICT_RESOLVED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
}
fun SyncEventType.label(): String = when (this) {
SyncEventType.SYNC_STARTED -> "Sync started"
SyncEventType.SYNC_COMPLETED -> "Sync completed"
SyncEventType.SYNC_FAILED -> "Sync failed"
SyncEventType.FILE_UPLOADED -> "File uploaded"
SyncEventType.FILE_DOWNLOADED -> "File downloaded"
SyncEventType.FILE_DELETED -> "File deleted"
SyncEventType.FILE_SKIPPED -> "File skipped"
SyncEventType.CONFLICT_DETECTED -> "Conflict detected"
SyncEventType.CONFLICT_RESOLVED -> "Conflict resolved"
}
@@ -2,27 +2,28 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color
// Primary — indigo
val Indigo600 = Color(0xFF4F46E5)
val Indigo900 = Color(0xFF312E81)
val Indigo100 = Color(0xFFE0E7FF)
val Indigo50 = Color(0xFFEEF2FF)
// Primary — deep red (Passbolt-inspired)
val Red900 = Color(0xFF7F0000)
val Red700 = Color(0xFFB71C1C)
val Red500 = Color(0xFFEF5350)
val Red100 = Color(0xFFFFCDD2)
val Red50 = Color(0xFFFFEBEE)
// Secondary — teal
val Teal600 = Color(0xFF0D9488)
val Teal100 = Color(0xFFCCFBF1)
// Secondary — deep orange
val Orange700 = Color(0xFFE64A19)
val Orange100 = Color(0xFFFBE9E7)
// Tertiary — amber
val Amber500 = Color(0xFFF59E0B)
val Amber100 = Color(0xFFFEF3C7)
val Amber500 = Color(0xFFFFB300)
val Amber100 = Color(0xFFFFF8E1)
// Neutrals
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate600 = Color(0xFF475569)
val Slate900 = Color(0xFF0F172A)
val Gray50 = Color(0xFFF8F9FA)
val Gray100 = Color(0xFFF3F4F6)
val Gray200 = Color(0xFFE5E7EB)
val Gray600 = Color(0xFF6B7280)
val Gray900 = Color(0xFF111827)
// Semantic
val GreenSuccess = Color(0xFF16A34A)
val RedError = Color(0xFFDC2626)
val GreenSuccess = Color(0xFF2E7D32)
val RedError = Color(0xFFEF5350)
@@ -13,41 +13,43 @@ import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme(
primary = Indigo600,
primary = Red700,
onPrimary = Color.White,
primaryContainer = Indigo100,
onPrimaryContainer = Indigo900,
secondary = Teal600,
primaryContainer = Red50,
onPrimaryContainer = Red900,
secondary = Orange700,
onSecondary = Color.White,
secondaryContainer = Teal100,
secondaryContainer = Orange100,
tertiary = Amber500,
tertiaryContainer = Amber100,
background = Slate50,
background = Gray50,
surface = Color.White,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
surfaceVariant = Gray100,
onSurface = Gray900,
onSurfaceVariant = Gray600,
error = RedError,
errorContainer = Color(0xFFFEE2E2),
outline = Slate200,
errorContainer = Red50,
outline = Gray200,
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF818CF8),
onPrimary = Indigo900,
primaryContainer = Color(0xFF3730A3),
onPrimaryContainer = Indigo100,
secondary = Color(0xFF2DD4BF),
onSecondary = Color(0xFF003731),
secondaryContainer = Color(0xFF00504A),
primary = Red500,
onPrimary = Color.White,
primaryContainer = Red900,
onPrimaryContainer = Red100,
secondary = Color(0xFFFF7043),
onSecondary = Color.White,
secondaryContainer = Color(0xFF4E1500),
tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E),
background = Color(0xFF0F0F1A),
surface = Color(0xFF1A1A2E),
surfaceVariant = Color(0xFF252538),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
errorContainer = Color(0xFF7F1D1D),
outline = Color(0xFF334155),
tertiaryContainer = Color(0xFF3E2700),
background = Color(0xFF0F0F0F),
surface = Color(0xFF1C1C1C),
surfaceVariant = Color(0xFF2A2A2A),
onSurface = Color(0xFFEAEAEA),
onSurfaceVariant = Color(0xFF9E9E9E),
error = Color(0xFFFF5252),
errorContainer = Color(0xFF5C0000),
outline = Color(0xFF3D3D3D),
)
private val AppTypography = Typography(
@@ -18,7 +18,8 @@ class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncPairDao: SyncPairDao
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val validActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED)
if (intent.action !in validActions) return
val wm = WorkManager.getInstance(context)
val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch {
@@ -10,11 +10,15 @@ import android.os.FileObserver
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.ScheduleType
import dagger.hilt.android.AndroidEntryPoint
@@ -27,11 +31,13 @@ import javax.inject.Inject
class FileWatchService : Service() {
@Inject lateinit var syncPairDao: SyncPairDao
@Inject lateinit var fileStateDao: SyncFileStateDao
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
private val fileObservers = mutableMapOf<Long, FileObserver>()
// Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
@@ -81,39 +87,29 @@ class FileWatchService : Service() {
val localPath = pair.localPath
if (localPath.startsWith("content://")) {
val treeUri = Uri.parse(localPath)
val observer = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
contentResolver.registerContentObserver(treeUri, true, observer)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI for pair $pairId")
} else {
val dir = File(localPath)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $localPath")
return@forEach
}
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
// Try to resolve the SAF tree URI to a real filesystem path so we can use
// FileObserver. ContentObserver on a DocumentsProvider tree URI only fires
// when changes come through the SAF API, not for raw filesystem writes.
val realPath = safTreeUriToRealPath(localPath)
if (realPath != null) {
watchPath(realPath, pairId, pair.wifiOnly, pair.chargingOnly)
} else {
@Suppress("DEPRECATION")
object : FileObserver(localPath, mask) {
override fun onEvent(event: Int, path: String?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
// Fallback: register a ContentObserver for SAF paths that can't be resolved
val treeUri = Uri.parse(localPath)
val observer = object : ContentObserver(mainHandler) {
override fun onChange(selfChange: Boolean) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
override fun onChange(selfChange: Boolean, uri: Uri?) = onChangeDetected(pairId, pair.wifiOnly, pair.chargingOnly)
}
contentResolver.registerContentObserver(treeUri, true, observer)
contentObservers[pairId] = observer
Timber.d("FileWatchService: watching SAF URI (ContentObserver fallback) for pair $pairId")
}
observer.startWatching()
fileObservers[pairId] = observer
Timber.d("FileWatchService: watching filesystem path for pair $pairId: $localPath")
} else {
watchPath(localPath, pairId, pair.wifiOnly, pair.chargingOnly)
}
}
val count = fileObservers.size + contentObservers.size
val count = fileObservers.keys.size + contentObservers.size
updateNotification(count)
if (count == 0) {
@@ -122,6 +118,97 @@ class FileWatchService : Service() {
}
}
private fun safTreeUriToRealPath(uriString: String): String? {
return try {
val treeUri = Uri.parse(uriString)
val docId = DocumentsContract.getTreeDocumentId(treeUri)
// docId format is "primary:RelativePath" for primary internal storage
if (docId.startsWith("primary:")) {
val relative = docId.removePrefix("primary:")
"/storage/emulated/0/$relative"
} else {
null
}
} catch (e: Exception) {
Timber.w("FileWatchService: could not resolve SAF URI to real path: $e")
null
}
}
private fun watchPath(path: String, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
val dir = File(path)
if (!dir.exists()) {
Timber.w("FileWatchService: path does not exist for pair $pairId: $path")
return
}
fileObservers[pairId] = mutableListOf()
watchDirRecursive(dir, pairId, wifiOnly, chargingOnly)
Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)")
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
if (!dir.isDirectory) return
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
FileObserver.MOVED_FROM or FileObserver.MOVED_TO
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
object : FileObserver(dir, mask) {
override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
}
} else {
@Suppress("DEPRECATION")
object : FileObserver(dir.absolutePath, mask) {
override fun onEvent(event: Int, path: String?) {
if (event and FileObserver.CREATE != 0 && path != null) {
val created = File(dir, path)
if (created.isDirectory) watchDirRecursive(created, pairId, wifiOnly, chargingOnly)
}
onChangeDetected(pairId, wifiOnly, chargingOnly)
}
}
}
observer.startWatching()
fileObservers.getOrPut(pairId) { mutableListOf() }.add(observer)
// Recursively watch existing subdirectories
dir.listFiles()?.filter { it.isDirectory }?.forEach { sub ->
watchDirRecursive(sub, pairId, wifiOnly, chargingOnly)
}
}
private suspend fun catchupScan(pairId: Long, dir: File, wifiOnly: Boolean, chargingOnly: Boolean) {
val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath }
if (known.isEmpty()) return // Never synced — first sync will be triggered manually
val current = mutableMapOf<String, Long>()
dir.walk().filter { it.isFile }.forEach { f ->
current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified()
}
val hasNew = current.any { (rel, _) -> rel !in known }
val hasModified = current.any { (rel, mtime) ->
val s = known[rel]; s != null && s.localModifiedAt != null &&
s.localModifiedAt.toEpochMilli() != mtime
}
val hasDeleted = known.keys.any { rel -> rel !in current }
if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork(
"catchup_$pairId",
ExistingWorkPolicy.KEEP,
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly),
)
}
}
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
@@ -129,14 +216,39 @@ class FileWatchService : Service() {
val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
// Update notification while sync is in progress
updateNotificationDynamic("Syncing: ${pair.name}")
// Wait for completion and show result in the persistent notification
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY)
val watchCount = fileObservers.keys.size + contentObservers.size
val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}"
if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) {
updateNotificationDynamic("${pair.name}: $summary$watching")
} else {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null) // revert to default watching text
} catch (_: Exception) {
updateNotificationDynamic(null)
}
}
}
}
private fun clearWatchers() {
fileObservers.values.forEach { it.stopWatching() }
fileObservers.values.flatten().forEach { it.stopWatching() }
fileObservers.clear()
contentObservers.values.forEach { contentResolver.unregisterContentObserver(it) }
contentObservers.clear()
@@ -156,7 +268,7 @@ class FileWatchService : Service() {
}
}
private fun buildNotification(count: Int): Notification {
private fun buildNotification(count: Int, overrideText: String? = null): Notification {
val tapIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
@@ -165,7 +277,7 @@ class FileWatchService : Service() {
return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow")
.setContentText(
if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
else "Starting file watcher…"
)
.setSmallIcon(R.drawable.ic_sync)
@@ -179,4 +291,10 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count))
}
private fun updateNotificationDynamic(overrideText: String?) {
val count = fileObservers.keys.size + contentObservers.size
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText))
}
}
@@ -39,6 +39,8 @@ class SyncWorker @AssistedInject constructor(
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
val silent = inputData.getBoolean(KEY_SILENT, false)
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
@@ -47,7 +49,15 @@ class SyncWorker @AssistedInject constructor(
val provider = providerFactory.create(account)
val result = syncEngine.sync(domainPair, provider)
if (result.error != null && pair.notifyOnError) {
val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
if (result.downloaded > 0) add("${result.downloaded}")
if (result.deleted > 0) add("🗑${result.deleted}")
if (result.conflicts > 0) add("${result.conflicts}")
}
val resultSummary = if (lines.isEmpty()) "Up to date" else lines.joinToString(" ")
if (!silent && result.error != null && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -55,28 +65,28 @@ class SyncWorker @AssistedInject constructor(
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val lines = buildList {
} else if (!silent && pair.notifyOnComplete && result.error == null) {
val fullLines = buildList {
if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n")
val summary = if (fullLines.isEmpty()) "Up to date — nothing to sync" else fullLines.joinToString("\n")
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name}Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
title = "${pair.name}Synced",
text = if (fullLines.isEmpty()) summary else fullLines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success()
Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary))
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
if (!silent && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -146,19 +156,21 @@ class SyncWorker @AssistedInject constructor(
companion object {
const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
private const val CHANNEL_COMPLETE = "sync_complete"
private const val CHANNEL_ALERTS = "sync_alerts"
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean, silent: Boolean = false): 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))
.setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
@@ -1,9 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="linear"
android:angle="135"
android:startColor="#4338CA"
android:centerColor="#7C3AED"
android:endColor="#0891B2"/>
</shape>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Deep blue-to-teal gradient background matching reference icon -->
<path
android:pathData="M0,0 H108 V108 H0 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="0" android:startY="0"
android:endX="108" android:endY="108"
android:startColor="#1565C0"
android:endColor="#00897B"/>
</aapt:attr>
</path>
<!-- Subtle radial highlight in upper-right -->
<path
android:pathData="M108,0 A90,90 0 0,1 108,90 Z"
android:fillAlpha="0.18">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:gradientRadius="80"
android:centerX="85" android:centerY="23"
android:startColor="#80DEEA"
android:endColor="#00000000"/>
</aapt:attr>
</path>
</vector>
@@ -3,37 +3,68 @@
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24">
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Soft white glow ring behind arrows -->
<!-- Cloud body: white, centred at (54,56). Composed of three arc bumps. -->
<path
android:pathData="M12,12m-7.5,0a7.5,7.5 0 1,0 15,0a7.5,7.5 0 1,0 -15,0"
android:fillColor="#22FFFFFF"/>
android:pathData="
M 38,67
A 9,9 0 0,1 38,49
A 9,9 0 0,1 47,40.5
A 12,12 0 0,1 68,42
A 8,8 0 0,1 76,53
A 8,8 0 0,1 70,67
Z"
android:fillColor="#FFFFFF"/>
<!-- Upload arrow — white → sky blue -->
<!-- Cloud drop shadow -->
<path
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-8z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="8" android:startY="1"
android:endX="20" android:endY="15"
android:startColor="#FFFFFF"
android:endColor="#7DD3FC"/>
android:pathData="
M 38,69
A 9,9 0 0,1 38,51
A 9,9 0 0,1 47,42.5
A 12,12 0 0,1 68,44
A 8,8 0 0,1 76,55
A 8,8 0 0,1 70,69
Z"
android:fillColor="#000000"
android:fillAlpha="0.10"/>
<!-- Sync arc 1: lower half CW, cyan to teal -->
<path
android:pathData="M 49.14,81.57 A 28,28 0 1,1 49.14,26.43"
android:fillColor="#00000000"
android:strokeWidth="5.5"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="49.14" android:startY="81.57"
android:endX="49.14" android:endY="26.43"
android:startColor="#40C4FF"
android:endColor="#00BFA5"/>
</aapt:attr>
</path>
<!-- Arrowhead at end of arc 1 (near 260 deg) -->
<path android:pathData="M 42.5,30.5 L 49.14,26.43 L 46.0,34.5 Z"
android:fillColor="#00BFA5"/>
<!-- Download arrow — white → violet -->
<!-- Sync arc 2: upper half CW, teal to cyan -->
<path
android:pathData="M12,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">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="4" android:startY="8"
android:endX="16" android:endY="23"
android:startColor="#FFFFFF"
android:endColor="#C4B5FD"/>
android:pathData="M 58.86,26.43 A 28,28 0 1,1 58.86,81.57"
android:fillColor="#00000000"
android:strokeWidth="5.5"
android:strokeLineCap="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="58.86" android:startY="26.43"
android:endX="58.86" android:endY="81.57"
android:startColor="#00BFA5"
android:endColor="#40C4FF"/>
</aapt:attr>
</path>
<!-- Arrowhead at end of arc 2 (near 80 deg) -->
<path android:pathData="M 65.5,77.5 L 58.86,81.57 L 62.0,73.5 Z"
android:fillColor="#40C4FF"/>
</vector>
+2
View File
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_storage" path="." />
<external-files-path name="external_files" path="." />
<files-path name="internal_files" path="." />
<cache-path name="syncflow_cache" path="syncflow_open/" />
</paths>
+3 -3
View File
@@ -28,8 +28,8 @@ 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"
securityCrypto = "1.0.0"
biometric = "1.1.0"
junit = "4.13.2"
androidxTestExt = "1.2.1"
espresso = "3.6.1"
@@ -106,7 +106,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
biometric = { group = "androidx.biometric", name = "biometric-ktx", version.ref = "biometric" }
biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
# Logging
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file\:///home/amir/gradle/gradle-8.6/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+39 -2
View File
@@ -1,2 +1,39 @@
#!/bin/bash
exec /home/amir/gradle/gradle-8.6/bin/gradle "$@"
#!/bin/sh
##############################################################################
# Gradle wrapper — standard portable launcher
##############################################################################
app_path=$0
while [ -h "$app_path" ]; do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in
/*) app_path=$link ;;
*) app_path=${app_path%"${app_path##*/}"}$link ;;
esac
done
APP_HOME=$( cd "${app_path%"${app_path##*/}"}." && pwd -P ) || exit
APP_BASE_NAME=${0##*/}
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ]; then
JAVACMD=$JAVA_HOME/bin/java
else
JAVACMD=java
fi
MAX_FD=maximum
case "$( uname )" in
Darwin*) ;;
*)
MAX_FD=$( ulimit -H -n 2>/dev/null ) && ulimit -n "$MAX_FD" 2>/dev/null ;;
esac
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
"\"-Dorg.gradle.appname=$APP_BASE_NAME\"" \
-classpath "\"$CLASSPATH\"" \
org.gradle.wrapper.GradleWrapperMain '"$@"'
exec "$JAVACMD" "$@"
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.16
VERSION_CODE=17
VERSION_NAME=1.0.26
VERSION_CODE=27