Compare commits

...

23 Commits

Author SHA1 Message Date
amir c1b7221324 Make radio rows fully tappable (label + dot), not just the dot
Build & Release APK / build (push) Successful in 12m53s
RadioGroup rows only responded to taps on the ~144px radio dot; tapping the
label did nothing. Wrap each row in Modifier.selectable(role=RadioButton) with
RadioButton(onClick=null) so the whole row is one accessible tap target.
Verified on-device: tapping the 'Download only' label now selects it.
2026-06-05 10:25:21 +00:00
amir 556645226a Fix ARCHIVE delete (create _Deleted base) + full engine test matrix
Build & Release APK / build (push) Successful in 12m46s
Full-matrix on-device test (FullSyncEngineTest) drives the real SyncEngine
(in-memory Room + real local folder + live Nextcloud) across all directions,
all delete behaviors, updates, recursive/non-recursive, filters, conflicts,
and content integrity — 14 instrumented tests, all green on a Galaxy S23.

It caught a real bug: ARCHIVE delete moved files to _Deleted/ but never
created the _Deleted folder, so the MOVE failed for top-level files and they
were left in place. Now creates the _Deleted base before the move.
2026-06-05 10:21:51 +00:00
amir 1e5ae2c65f Add on-device Nextcloud integration test (real WebDAV round-trip)
Build & Release APK / build (push) Successful in 12m47s
Instrumented test driving the real NextcloudProvider over TLS: connect,
create dir, atomic upload (temp+MOVE), list+size, download+content, then the
backup guarantee — Upload-only + KEEP yields SKIP and the cloud copy is
verified still present; MIRROR yields DELETE_REMOTE and the real delete is
confirmed. Creds passed via instrumentation args (ncUrl/ncUser/ncPass), never
committed. Verified passing on a Galaxy S23 (Android 16) against live Nextcloud.
2026-06-05 09:54:02 +00:00
amir a0d759364e Security: guard against path traversal from hostile remotes
Build & Release APK / build (push) Successful in 12m42s
WebDAV already sanitizes server-supplied names, but SFTP passed entry.name
through unfiltered, and the engine had no central guard — a malicious or
compromised remote could return '../../x' and (on the JavaFile backend) write
outside the sync root.

- SyncEngine: isUnsafeSyncPath() rejects empty, absolute, and any '..'-segment
  path; every file is checked before any read/write/delete (covers all providers).
- SftpProvider.listFiles: drop '.'/'..' and names containing path separators.
- PathSafetyTest covers traversal, backslash, absolute, and empty cases.
2026-06-05 02:54:21 +00:00
amir 160a3e5478 Direction-aware default for deletion behaviour (don't wipe backups)
Build & Release APK / build (push) Successful in 12m54s
The Add-Pair screen defaulted deleteBehavior to MIRROR for every direction,
so an Upload-only backup would delete cloud files when you deleted them on
the phone. Now the default follows the direction:
- Upload-only / Download-only -> KEEP (deleting locally leaves the cloud copy)
- Two-way -> MIRROR
All three options remain selectable; once the user explicitly picks one,
changing direction no longer overrides it, and editing a saved pair keeps
its stored choice. Adds RecommendedDeleteBehaviorTest.
2026-06-05 02:39:49 +00:00
amir 92cad9ca56 Add upload-only backup lifecycle tests
Build & Release APK / build (push) Successful in 12m49s
Characterizes the 'back up phone -> delete locally -> must stay in cloud'
scenario across the real multi-cycle engine state (upload saves null remote
metadata; next sync reconciles), asserting per delete behavior:
- KEEP  -> SKIP (cloud copy retained) — correct backup behavior
- ARCHIVE -> DELETE_REMOTE decision (engine moves to _Deleted/, preserved)
- MIRROR -> DELETE_REMOTE (cloud copy wiped) — footgun, and the current default
Also: upload-only never pulls a new remote file down; local edits still upload.
2026-06-05 02:36:44 +00:00
amir 62f9f015d6 Fix two stale SyncDecideTest cases (CI never ran tests before)
Build & Release APK / build (push) Successful in 12m53s
These contradicted deliberate later safety fixes in syncDecide:
- sub-second mtime delta is now SKIP (second-precision comparison was the
  fix for the FAT32/WebDAV phantom-change sync loops), not UPLOAD. Added a
  full-second-delta case to keep change-detection coverage.
- remote file with no state record now DOWNLOADs instead of DELETE_REMOTE:
  known==null can't be distinguished from a brand-new remote file, so the
  engine never deletes on ambiguity. Genuinely-deleted local files still
  have a state record and route to DELETE_REMOTE.

All 25 unit tests pass; assembleRelease builds and signs cleanly (compileSdk 35).
2026-06-05 02:32:16 +00:00
amir b973e58d9e Atomic transfers + signed-release CI
Build & Release APK / build (push) Failing after 11m43s
Sync engine / providers:
- LocalAccessor: replace createOutputStream with writeAtomically (temp
  sibling + rename/commit) for both JavaFile and SAF backends, so an
  interrupted download no longer truncates the destination file.
- SyncEngine: use writeAtomically for DOWNLOAD and propagate downloadFile
  failures via getOrThrow (was silently swallowed -> false success + state).
- WebDavProvider (covers Nextcloud/ownCloud): PUT to hidden temp then MOVE
  onto destination, so a failed upload can't leave a truncated remote file.
- SftpProvider: upload to temp then rename onto destination.

Build / CI:
- compileSdk 34 -> 35 (was below targetSdk 35).
- Release signing reads keystore from local.properties or env (CI), with a
  debug-key fallback so builds still succeed without secrets.
- Disable R8/minify for release (never exercised by CI; keeps signed release
  behaving like the debug builds in use today).
- CI: run unit tests on every push/PR, build assembleRelease (signed when
  KEYSTORE_BASE64 present), publish APK only on v* tags.
2026-06-05 02:15:23 +00:00
Amir dbd317624d Add app icon to README header 2026-06-04 01:27:47 +00:00
amir 25e4c6c4e3 releases/latest: update to v1.0.63
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:48:43 +00:00
amir c60eb8d27b v1.0.63: live sync progress counters, pause/resume, .gitignore fix
Build & Release APK / build (push) Has been cancelled
- SyncEngine: accepts onProgress callback — emits uploaded/downloaded/
  deleted/bytes counts atomically as each file completes
- SyncWorker: streams progress to WorkManager data so the UI can poll
  it live; reports per-run counters in the completion notification;
  adds pause/resume support
- HomeViewModel/PairDetailViewModel: subscribe to live WorkManager
  progress and surface it via SyncProgress state
- SyncPairEntity/SyncPairDao/SyncDatabase: persist last-run counters
  (uploaded, downloaded, deleted, bytesTransferred) in the DB with a
  Room migration (v3→v4)
- AppModule: provides WorkManager as an injectable singleton
- .gitignore: add .kotlin/ to exclude compiler session files

Security: no new issues — all logging via Timber (debug-only), DB
queries use Room parameterized API, file sharing via FileProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:07:25 +00:00
amir 21b7ffc7b3 v1.0.59: pause/resume sync
New PAUSED status. When a sync is running, the sync button becomes a
pause button (⏸). Tapping it cancels the WorkManager job and sets the
status to PAUSED (purple). The button then becomes a play button (▶) to
resume. Works in both the home screen card and the pair detail screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:45 +00:00
amir cb9fa1d3db v1.0.58: Files tab → dual-mode file explorer (Phone + Cloud)
Replace the synced-files list with a proper file explorer:
- Phone tab: browse all of internal storage with quick-access shortcuts
  (Camera, Downloads, Documents, Pictures, Music, Videos), breadcrumb
  navigation, search, tap folder to enter, tap file to open/share
- Cloud tab: browse connected cloud accounts, account switcher chips for
  multiple accounts, breadcrumb navigation, search, tap file to
  download+open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:46:35 +00:00
amir e59564ac07 v1.0.57: restore custom browser as primary local folder picker
Tap on local folder opens the custom browser again (not system picker).
The custom browser already shows the All files access banner if needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:24:35 +00:00
amir 0ba4fd7eb9 v1.0.56: allow root folder selection + MANAGE_EXTERNAL_STORAGE prompt
Remove root folder block from the browser — user can now select
/storage/emulated/0 exactly like Autosync. If MANAGE_EXTERNAL_STORAGE
is not granted a red banner appears with a direct "Grant" button that
opens the Android All files access settings screen. Root guard removed
from SyncEngine; individual file failures (e.g. root-level writes) are
already caught and logged per-file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:21:35 +00:00
amir 69d4257a18 v1.0.55: SAF system folder picker (same as Autosync)
Tapping the local folder field now opens Android's native folder picker
via ACTION_OPEN_DOCUMENT_TREE. The picked content:// URI gets persistent
read/write permission and is stored as-is; the existing Saf backend
handles all sync I/O through it. "Browse manually" link kept for the
raw-path custom browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:12:42 +00:00
amir 683169e8b7 v1.0.54: ARCHIVE delete behavior + storage root upload-only allowance
New delete behavior option: "Archive deleted" — when a file is deleted
from the phone in a TWO_WAY pair, it moves to _Deleted/<path> on remote
instead of being permanently deleted from the backup.

Also allows storage root (/storage/emulated/0) for UPLOAD_ONLY pairs so
whole-phone backup syncs work; only blocks root when sync direction would
write files locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:06:28 +00:00
amir 77d56ee6be v1.0.53: block storage-root sync paths
Android 11+ denies writes to /storage/emulated/0 directly. SyncEngine
now catches this early and returns FAILED with an actionable message
instead of silently logging PARTIAL. LocalBrowserDialog disables the
Select button at the storage root with an inline warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:56:45 +00:00
amir 99193af2c5 v1.0.52: fix Select button cut off on Android 16 edge-to-edge dialogs
Android 15+ enforces edge-to-edge on Dialog windows, making standard
Compose WindowInsets APIs return 0 inside dialogs. Fix: use ViewCompat
insets listener inside the Dialog to read actual system bar heights,
with 56dp minimum to guarantee full nav bar clearance. Spacer inside
the button Surface lets the elevated background extend behind the nav
bar. Also make the entire local folder field tappable (not just the
trailing icon) for better UX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:14:43 +00:00
amir 3c008ec8df v1.0.47: built-in folder browsers, icon crop fix, nav bar button fix
- Replace SAF document picker with custom LocalBrowserDialog (File API,
  quick-access shortcuts, breadcrumb nav, search, folder-only listing)
- Rewrite RemoteBrowserDialog as full-screen dialog with breadcrumbs,
  search, and new-folder creation; add navigateToBreadcrumb/createFolder
  to RemoteBrowserViewModel
- Fix Select button cut off by navigation bar in both browsers: wrap
  button in Column(navigationBarsPadding()) so the button sits above
  the nav bar rather than behind it
- Tighten icon foreground crop to remove excess black border

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:25 +00:00
amir c869f84a9d 1.0.40: fix icon sizing (adaptive XML) + semantic status colors
- Restore mipmap-anydpi-v26 adaptive icon XMLs so Android 8+ shows
  the icon at full size instead of scaling it to 66% safe zone
- Foreground at 108dp sizes (108-432px), background #050E05
- Status colors now semantic (not tied to app red/orange theme):
  SUCCESS=green, SYNCING=blue, FAILED=red, PARTIAL=orange, CONFLICT=amber

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:49:34 +00:00
amir c3be23417d 1.0.39: fix OOM on large-file uploads; use exact reference icon
- WebDavProvider: replace readBytes() with streaming RequestBody
  (Okio sink.writeAll) so large files (1+ GB) upload without
  allocating the full file in heap — fixes PARTIAL sync status
- App icon: replace vector XML with PNG mipmaps generated directly
  from the user-provided reference image at all densities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:51:23 +00:00
amir ae10ed0c82 Fix upload rollback and update app icon
Upload rollback fix: after uploading a file, do not store the remote
mtime/etag from the upload response PROPFIND. Nextcloud and other
WebDAV servers can change a file's Last-Modified or ETag after upload
(thumbnail generation, checksums, folder aggregation). Storing stale
metadata caused the next sync to see remoteChanged=true and download
the file back, reverting the upload. Leaving remoteAfterTransfer=null
forces the SKIP reconciliation pass to fill in remote state from the
directory listing, which is the same source all future syncs use.

Icon: update foreground to thick ribbons with 3D highlight stripes
(blue/green/red/orange, width 12 + highlight 5); update background
to dark space theme with star dots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:59:59 +00:00
63 changed files with 2393 additions and 733 deletions
+25 -3
View File
@@ -2,8 +2,10 @@ name: Build & Release APK
on:
push:
branches: ['**']
tags:
- 'v*'
pull_request:
jobs:
build:
@@ -20,10 +22,29 @@ jobs:
- uses: android-actions/setup-android@v3
- name: Build debug APK
- name: Run unit tests
run: |
chmod +x gradlew
./gradlew assembleDebug --no-daemon
./gradlew testDebugUnitTest --no-daemon
- name: Decode release keystore
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
echo "KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
echo "Release keystore decoded — building signed release."
else
echo "::warning::KEYSTORE_BASE64 secret not set — release APK will be debug-signed."
fi
- name: Build release APK
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew assembleRelease --no-daemon
- name: Get version name
id: ver
@@ -32,10 +53,11 @@ jobs:
- name: Rename APK
run: |
mkdir dist
cp app/build/outputs/apk/debug/app-debug.apk \
cp app/build/outputs/apk/release/app-release.apk \
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
- name: Attach APK to release
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
+1
View File
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
local.properties
.idea/
.DS_Store
+4
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" width="108" alt="SyncFlow">
</p>
# SyncFlow
Native Android file sync app — sync any folder to WebDAV, SFTP, Nextcloud, ownCloud, Google Drive, Dropbox, or OneDrive.
+23 -8
View File
@@ -18,9 +18,15 @@ val localProps = Properties().apply {
if (f.exists()) load(f.inputStream())
}
// Release signing is read from local.properties (local builds) or environment variables
// (CI). When no keystore is available the release build falls back to the debug key so the
// build still succeeds — it just isn't a distributable, properly-signed APK.
val keystorePath = (localProps["KEYSTORE_PATH"] as String?) ?: System.getenv("KEYSTORE_PATH")
val hasReleaseKeystore = keystorePath != null && file(keystorePath).exists()
android {
namespace = "com.syncflow"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "com.syncflow"
@@ -38,19 +44,28 @@ android {
signingConfigs {
create("release") {
storeFile = localProps["KEYSTORE_PATH"]?.toString()?.let { file(it) }
storePassword = localProps["KEYSTORE_PASSWORD"]?.toString()
keyAlias = localProps["KEY_ALIAS"]?.toString()
keyPassword = localProps["KEY_PASSWORD"]?.toString()
if (hasReleaseKeystore) {
storeFile = file(keystorePath!!)
storePassword = (localProps["KEYSTORE_PASSWORD"] as String?) ?: System.getenv("KEYSTORE_PASSWORD")
keyAlias = (localProps["KEY_ALIAS"] as String?) ?: System.getenv("KEY_ALIAS")
keyPassword = (localProps["KEY_PASSWORD"] as String?) ?: System.getenv("KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
// R8/minify has never been exercised by CI (it only built debug), so leave it off
// to keep the signed release behaving identically to the debug builds in use today.
// Re-enable with proper keep rules and an on-device smoke test if APK size matters.
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
@@ -0,0 +1,246 @@
package com.syncflow
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.syncflow.data.db.SyncDatabase
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.data.db.entities.toDomain
import com.syncflow.data.providers.nextcloud.NextcloudProvider
import com.syncflow.domain.model.*
import com.syncflow.domain.sync.SyncEngine
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.Instant
/**
* Full-matrix end-to-end test of the REAL SyncEngine against a live Nextcloud, on the device:
* in-memory Room DB, a real local folder per test, and the real NextcloudProvider over TLS.
* Covers every direction, every delete behavior, updates, nested/recursive, filters, and conflicts.
*
* Creds via instrumentation args: -e ncUrl ... -e ncUser ... -e ncPass ...
*/
@RunWith(AndroidJUnit4::class)
class FullSyncEngineTest {
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
private val args = InstrumentationRegistry.getArguments()
private val url get() = args.getString("ncUrl")
private val user get() = args.getString("ncUser")
private val pass get() = args.getString("ncPass")
private lateinit var db: SyncDatabase
private lateinit var engine: SyncEngine
private lateinit var provider: NextcloudProvider
private var accountId = 0L
private val remoteRoot = "SyncFlowFull_${System.currentTimeMillis()}"
private val localDirs = mutableListOf<File>()
@Before fun setUp() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
db = Room.inMemoryDatabaseBuilder(ctx, SyncDatabase::class.java).build()
val account = CloudAccount(0, "IT", user, ProviderType.NEXTCLOUD,
"""{"username":"$user","password":"$pass"}""", url, null)
accountId = db.cloudAccountDao().insert(
CloudAccountEntity(0, account.displayName, account.email, account.providerType,
account.credentialJson, account.serverUrl, account.port))
provider = NextcloudProvider(account)
engine = SyncEngine(db.syncPairDao(), db.syncFileStateDao(), db.syncConflictDao(), db.syncEventDao(), ctx)
provider.createDirectory(remoteRoot).getOrThrow()
}
@After fun tearDown() = runBlocking {
runCatching { provider.deleteFile(remoteRoot) }
localDirs.forEach { it.deleteRecursively() }
if (::db.isInitialized) db.close()
}
// ── helpers ──────────────────────────────────────────────────────────────
private suspend fun newPair(
name: String,
dir: SyncDirection = SyncDirection.TWO_WAY,
delete: DeleteBehavior = DeleteBehavior.MIRROR,
conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
recursive: Boolean = true,
excludeExtensions: String = "",
excludePatterns: String = "",
skipHidden: Boolean = false,
maxKb: Long = 0L,
): Triple<SyncPair, File, String> {
val local = File(ctx.cacheDir, "synctest_${name}_${System.currentTimeMillis()}").apply { mkdirs() }
localDirs += local
val remote = "$remoteRoot/$name"
provider.createDirectory(remote).getOrThrow()
val id = db.syncPairDao().insert(SyncPairEntity(
id = 0, name = name, localPath = local.absolutePath, remotePath = remote, accountId = accountId,
syncDirection = dir, conflictStrategy = conflict, deleteBehavior = delete, recursive = recursive,
scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 30, scheduleDailyTime = null, scheduleWeekdays = 0,
wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
excludePatterns = excludePatterns, includeExtensions = "", excludeExtensions = excludeExtensions,
skipHiddenFiles = skipHidden, minFileSizeKb = 0, maxFileSizeKb = maxKb,
notifyOnComplete = false, notifyOnError = false,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
))
return Triple(db.syncPairDao().getById(id)!!.toDomain(), local, remote)
}
private suspend fun sync(pair: SyncPair) = engine.sync(pair, provider)
private fun write(dir: File, rel: String, content: String) =
File(dir, rel).apply { parentFile?.mkdirs() }.writeText(content)
private suspend fun remoteNames(remote: String) =
provider.listFiles(remote).getOrThrow().map { it.name }
private suspend fun remoteText(path: String): String {
val out = ByteArrayOutputStream(); provider.downloadFile(path, out).getOrThrow(); return out.toString("UTF-8")
}
private suspend fun putRemote(remote: String, name: String, content: String) {
val b = content.toByteArray()
provider.uploadFile(ByteArrayInputStream(b), "$remote/$name", b.size.toLong()).getOrThrow()
}
// ── 1. Upload-only backup: delete on phone keeps the cloud copy (KEEP) ────
@Test fun uploadOnly_keep_localDeleteKeepsCloud() = runBlocking {
val (pair, local, remote) = newPair("ul_keep", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
write(local, "a.txt", "hello")
assertEquals(1, sync(pair).uploaded)
assertTrue("a.txt" in remoteNames(remote))
File(local, "a.txt").delete()
val r = sync(pair)
assertEquals("KEEP must not delete remotely", 0, r.deleted)
assertTrue("cloud copy must survive", "a.txt" in remoteNames(remote))
}
// ── 2. Upload-only + MIRROR: local delete removes the cloud copy ──────────
@Test fun uploadOnly_mirror_localDeleteRemovesCloud() = runBlocking {
val (pair, local, remote) = newPair("ul_mirror", SyncDirection.UPLOAD_ONLY, DeleteBehavior.MIRROR)
write(local, "a.txt", "hello"); assertEquals(1, sync(pair).uploaded)
File(local, "a.txt").delete()
assertEquals(1, sync(pair).deleted)
assertFalse("a.txt" in remoteNames(remote))
}
// ── 3. Upload-only + ARCHIVE: deleted file moved to _Deleted/ ─────────────
@Test fun uploadOnly_archive_movesToDeleted() = runBlocking {
val (pair, local, remote) = newPair("ul_archive", SyncDirection.UPLOAD_ONLY, DeleteBehavior.ARCHIVE)
write(local, "a.txt", "keepme"); sync(pair)
File(local, "a.txt").delete(); sync(pair)
assertFalse("a.txt" in remoteNames(remote))
assertTrue("archived copy expected", "a.txt" in remoteNames("$remote/_Deleted"))
}
// ── 4. Two-way initial sync: each side gets the other's files ─────────────
@Test fun twoWay_initial_mergesBothSides() = runBlocking {
val (pair, local, remote) = newPair("tw_init", SyncDirection.TWO_WAY)
write(local, "local.txt", "L")
putRemote(remote, "remote.txt", "R")
val r = sync(pair)
assertEquals(1, r.uploaded); assertEquals(1, r.downloaded)
assertTrue(File(local, "remote.txt").exists())
assertTrue("local.txt" in remoteNames(remote) && "remote.txt" in remoteNames(remote))
}
// ── 5. Two-way: a local edit propagates to the remote ─────────────────────
@Test fun twoWay_localEdit_updatesRemote() = runBlocking {
val (pair, local, remote) = newPair("tw_edit", SyncDirection.TWO_WAY)
write(local, "f.txt", "v1"); sync(pair)
Thread.sleep(1100) // cross the 1s mtime resolution
write(local, "f.txt", "v2-updated"); val r = sync(pair)
assertEquals(1, r.uploaded)
assertEquals("v2-updated", remoteText("$remote/f.txt"))
}
// ── 6. Two-way + MIRROR: deleting locally removes it remotely ─────────────
@Test fun twoWay_mirror_localDeletePropagates() = runBlocking {
val (pair, local, remote) = newPair("tw_mirror", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
write(local, "f.txt", "x"); sync(pair)
File(local, "f.txt").delete()
assertEquals(1, sync(pair).deleted)
assertFalse("f.txt" in remoteNames(remote))
}
// ── 7. Download-only: pulls remote, never uploads local-only files ────────
@Test fun downloadOnly_pullsRemoteIgnoresLocal() = runBlocking {
val (pair, local, remote) = newPair("dl_only", SyncDirection.DOWNLOAD_ONLY)
putRemote(remote, "cloud.txt", "from-cloud")
write(local, "phoneonly.txt", "P")
val r = sync(pair)
assertEquals(1, r.downloaded); assertEquals(0, r.uploaded)
assertEquals("from-cloud", File(local, "cloud.txt").readText())
assertFalse("local-only file must NOT upload", "phoneonly.txt" in remoteNames(remote))
}
// ── 8. Recursive: nested directory structure is preserved ─────────────────
@Test fun recursive_uploadsNestedTree() = runBlocking {
val (pair, local, remote) = newPair("rec_on", SyncDirection.UPLOAD_ONLY, recursive = true)
write(local, "sub/deep/n.txt", "nested")
assertEquals(1, sync(pair).uploaded)
assertTrue("n.txt" in remoteNames("$remote/sub/deep"))
}
// ── 9. recursive=false: subfolders are skipped ────────────────────────────
@Test fun nonRecursive_skipsSubfolders() = runBlocking {
val (pair, local, remote) = newPair("rec_off", SyncDirection.UPLOAD_ONLY, recursive = false)
write(local, "top.txt", "T")
write(local, "sub/deep.txt", "D")
assertEquals(1, sync(pair).uploaded)
assertTrue("top.txt" in remoteNames(remote))
assertTrue("subfolder must be skipped", remoteNames(remote).none { it == "sub" })
}
// ── 10. Filters: excluded extension + hidden file are not uploaded ─────────
@Test fun filters_excludeExtensionAndHidden() = runBlocking {
val (pair, local, remote) = newPair("filters", SyncDirection.UPLOAD_ONLY,
excludeExtensions = "tmp", skipHidden = true)
write(local, "keep.txt", "k")
write(local, "skip.tmp", "s")
write(local, ".hidden", "h")
sync(pair)
val names = remoteNames(remote)
assertTrue("keep.txt" in names)
assertFalse("skip.tmp" in names)
assertFalse(".hidden" in names)
}
// ── 11. Conflict: both sides changed (ASK) → conflict recorded, no clobber ─
@Test fun twoWay_bothChanged_recordsConflict() = runBlocking {
val (pair, local, remote) = newPair("conflict", SyncDirection.TWO_WAY, conflict = ConflictStrategy.ASK)
write(local, "c.txt", "base"); sync(pair) // upload
sync(pair) // reconcile: record remote baseline (etag/mtime)
Thread.sleep(1100)
write(local, "c.txt", "LOCAL-change") // change local
putRemote(remote, "c.txt", "REMOTE-change") // change remote out-of-band
val r = sync(pair)
assertEquals("a conflict must be detected", 1, r.conflicts)
// ASK must not silently overwrite either side
assertEquals("LOCAL-change", File(local, "c.txt").readText())
assertEquals("REMOTE-change", remoteText("$remote/c.txt"))
}
// ── 12. Conflict KEEP_NEWEST: newer local wins and uploads ────────────────
@Test fun twoWay_keepNewest_newerLocalWins() = runBlocking {
val (pair, local, remote) = newPair("newest", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_NEWEST)
write(local, "n.txt", "base"); sync(pair)
putRemote(remote, "n.txt", "remote-older")
Thread.sleep(1100)
write(local, "n.txt", "local-newer") // local is newer than remote
sync(pair)
assertEquals("newer local must win", "local-newer", remoteText("$remote/n.txt"))
}
// ── 13. Content integrity: binary-ish bytes round-trip exactly ────────────
@Test fun contentIntegrity_roundTrip() = runBlocking {
val (pair, local, remote) = newPair("integrity", SyncDirection.TWO_WAY)
val payload = (0..5000).joinToString("") { "Ω$it·" }
write(local, "big.txt", payload); sync(pair)
assertEquals(payload, remoteText("$remote/big.txt"))
}
}
@@ -0,0 +1,117 @@
package com.syncflow
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.data.providers.nextcloud.NextcloudProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.ProviderType
import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.sync.LocalFileInfo
import com.syncflow.domain.sync.SyncDecision
import com.syncflow.domain.sync.syncDecide
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.time.Instant
/**
* Real end-to-end test against a live Nextcloud, run ON the device. Exercises the actual
* NextcloudProvider (WebDAV over real TLS, including the atomic temp+MOVE upload) and proves
* the backup guarantee: with Upload-only + KEEP, "deleted on phone" leaves the cloud copy.
*
* Credentials are passed as instrumentation args (never committed):
* adb shell am instrument -w \
* -e ncUrl https://nextcloud.khodak.me -e ncUser syncflow-test -e ncPass <pw> \
* com.syncflow.test/androidx.test.runner.AndroidJUnitRunner
*/
@RunWith(AndroidJUnit4::class)
class NextcloudIntegrationTest {
private val args = InstrumentationRegistry.getArguments()
private val url = args.getString("ncUrl")
private val user = args.getString("ncUser")
private val pass = args.getString("ncPass")
private fun provider(): NextcloudProvider {
val account = CloudAccount(
id = 1L,
displayName = "IT",
email = user, // Nextcloud dav path uses this
providerType = ProviderType.NEXTCLOUD,
credentialJson = """{"username":"$user","password":"$pass"}""",
serverUrl = url,
port = null,
)
return NextcloudProvider(account)
}
@Test
fun fullBackupRoundTrip() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass instrumentation args required", url != null && user != null && pass != null)
val p = provider()
val dir = "SyncFlowITest_${System.currentTimeMillis()}"
val remoteFile = "$dir/hello.txt"
val content = "SyncFlow integration test — 0 to 100 — ${System.currentTimeMillis()}".toByteArray()
try {
// 1. Connect
assertTrue("testConnection failed", p.testConnection().isSuccess)
// 2. Create the backup folder
assertTrue("createDirectory failed", p.createDirectory(dir).isSuccess)
// 3. Upload (exercises atomic temp-file + MOVE)
val uploaded = p.uploadFile(ByteArrayInputStream(content), remoteFile, content.size.toLong())
assertTrue("upload failed: ${uploaded.exceptionOrNull()}", uploaded.isSuccess)
// 4. List — the file is on the cloud with the right size
val listed = p.listFiles(dir).getOrThrow()
val entry = listed.firstOrNull { it.name == "hello.txt" }
assertNotNull("uploaded file not found in listing", entry)
assertEquals("remote size mismatch", content.size.toLong(), entry!!.sizeBytes)
// 5. Download — bytes round-trip intact
val out = ByteArrayOutputStream()
assertTrue("download failed", p.downloadFile(remoteFile, out).isSuccess)
assertEquals("downloaded content mismatch", String(content), out.toString("UTF-8"))
// 6. THE backup guarantee. Phone copy deleted, state record exists, Upload-only + KEEP.
val known = SyncFileStateEntity(
syncPairId = 1L, relativePath = "hello.txt",
localModifiedAt = Instant.now(), localSizeBytes = content.size.toLong(), localHash = null,
remoteModifiedAt = entry.modifiedAt, remoteSizeBytes = entry.sizeBytes, remoteEtag = entry.etag,
lastSyncedAt = Instant.now(), syncedHash = null,
)
val keepDecision = syncDecide(
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.KEEP,
local = null, remote = entry, known = known, hasPriorSyncState = true,
)
assertEquals("KEEP must not delete the cloud copy", SyncDecision.SKIP, keepDecision)
// ...and the engine would do nothing, so the file is verifiably STILL on the cloud:
val stillThere = p.listFiles(dir).getOrThrow().any { it.name == "hello.txt" }
assertTrue("cloud copy must survive a local delete under KEEP", stillThere)
// 7. Contrast: MIRROR would delete it — prove the real DELETE works (also cleanup).
val mirrorDecision = syncDecide(
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.MIRROR,
local = null, remote = entry, known = known, hasPriorSyncState = true,
)
assertEquals(SyncDecision.DELETE_REMOTE, mirrorDecision)
assertTrue("deleteFile failed", p.deleteFile(remoteFile).isSuccess)
val goneAfterDelete = p.listFiles(dir).getOrThrow().none { it.name == "hello.txt" }
assertTrue("file should be gone after explicit remote delete", goneAfterDelete)
} finally {
runCatching { p.deleteFile(dir) } // best-effort cleanup of the test folder
}
}
}
@@ -15,20 +15,27 @@ import com.syncflow.data.db.entities.*
SyncConflictEntity::class,
SyncEventEntity::class,
],
version = 3,
version = 4,
exportSchema = true,
)
@TypeConverters(DbConverters::class)
abstract class SyncDatabase : RoomDatabase() {
companion object {
// Wipe file states: timestamps were stored as epoch-seconds, now epoch-millis.
// All previously saved states are wrong so we drop and re-learn on next sync.
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DELETE FROM sync_file_states")
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncUploaded INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDownloaded INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDeleted INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncBytesTransferred INTEGER NOT NULL DEFAULT 0")
}
}
}
abstract fun cloudAccountDao(): CloudAccountDao
abstract fun syncPairDao(): SyncPairDao
@@ -29,8 +29,8 @@ interface SyncPairDao {
@Delete
suspend fun delete(entity: SyncPairEntity)
@Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts WHERE id = :id")
suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int)
@Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts, lastSyncUploaded = :uploaded, lastSyncDownloaded = :downloaded, lastSyncDeleted = :deleted, lastSyncBytesTransferred = :bytes WHERE id = :id")
suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int, uploaded: Int, downloaded: Int, deleted: Int, bytes: Long)
@Query("UPDATE sync_pairs SET lastSyncResult = :status WHERE id = :id")
suspend fun updateStatus(id: Long, status: SyncStatus)
@@ -53,6 +53,11 @@ data class SyncPairEntity(
val lastSyncAt: Instant?,
val lastSyncResult: SyncStatus,
val pendingConflicts: Int,
// Last sync outcome counters (persist across pause/resume)
val lastSyncUploaded: Int = 0,
val lastSyncDownloaded: Int = 0,
val lastSyncDeleted: Int = 0,
val lastSyncBytesTransferred: Long = 0L,
)
fun SyncPairEntity.toDomain() = SyncPair(
@@ -43,17 +43,21 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
withSftp { sftp ->
sftp.ls(remotePath).map { entry ->
RemoteFile(
path = "$remotePath/${entry.name}".replace("//", "/"),
name = entry.name,
isDirectory = entry.isDirectory,
sizeBytes = entry.attributes.size,
modifiedAt = Instant.ofEpochSecond(entry.attributes.mtime.toLong()),
etag = null,
mimeType = null,
)
}
sftp.ls(remotePath)
// Drop "."/".." and any name with a path separator so a hostile server can't
// smuggle a traversal segment into a local/remote path.
.filter { it.name != "." && it.name != ".." && !it.name.contains('/') && !it.name.contains('\\') }
.map { entry ->
RemoteFile(
path = "$remotePath/${entry.name}".replace("//", "/"),
name = entry.name,
isDirectory = entry.isDirectory,
sizeBytes = entry.attributes.size,
modifiedAt = Instant.ofEpochSecond(entry.attributes.mtime.toLong()),
etag = null,
mimeType = null,
)
}
}
}
@@ -63,12 +67,25 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
sizeBytes: Long,
onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching {
// Upload to a hidden temp sibling, then rename onto the destination so an interrupted
// transfer never leaves a truncated file at the real path.
val dir = remotePath.substringBeforeLast('/', "")
val name = remotePath.substringAfterLast('/')
val tmpPath = if (dir.isEmpty()) ".$name.sfpart" else "$dir/.$name.sfpart"
withSftp { sftp ->
sftp.put(object : InMemorySourceFile() {
override fun getName() = remotePath.substringAfterLast('/')
override fun getName() = name
override fun getLength() = sizeBytes
override fun getInputStream() = localStream
}, remotePath)
}, tmpPath)
// SFTP rename fails if the target exists on servers without the POSIX-rename
// extension, so fall back to removing the destination first.
try {
sftp.rename(tmpPath, remotePath)
} catch (e: Exception) {
runCatching { sftp.rm(remotePath) }
sftp.rename(tmpPath, remotePath)
}
}
getFileMetadata(remotePath).getOrThrow()
}
@@ -13,6 +13,8 @@ import okhttp3.*
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import java.io.InputStream
@@ -84,17 +86,37 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
onProgress: (Long) -> Unit,
): Result<RemoteFile> = runCatching {
withContext(Dispatchers.IO) {
val bytes = localStream.readBytes()
val body = bytes.toRequestBody("application/octet-stream".toMediaType())
val req = Request.Builder().url(url(remotePath)).put(body).build()
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = sizeBytes
override fun writeTo(sink: BufferedSink) {
localStream.source().use { source -> sink.writeAll(source) }
}
}
// Upload to a hidden temp sibling first, then MOVE it onto the destination. A
// failed PUT leaves the real file untouched instead of overwriting it with a
// truncated body; the MOVE is a server-side atomic-ish swap.
val tmpPath = tempPathFor(remotePath)
val req = Request.Builder().url(url(tmpPath)).put(body).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
}
onProgress(bytes.size.toLong())
moveFile(tmpPath, remotePath).getOrElse { e ->
runCatching { deleteFile(tmpPath) }
throw e
}
onProgress(sizeBytes)
getFileMetadata(remotePath).getOrThrow()
}
}
private fun tempPathFor(remotePath: String): String {
val dir = remotePath.substringBeforeLast('/', "")
val name = remotePath.substringAfterLast('/')
val tmp = ".$name.sfpart"
return if (dir.isEmpty()) tmp else "$dir/$tmp"
}
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).get().build()
@@ -22,7 +22,7 @@ object AppModule {
fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase =
Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db")
.fallbackToDestructiveMigrationFrom(1)
.addMigrations(SyncDatabase.MIGRATION_2_3)
.addMigrations(SyncDatabase.MIGRATION_2_3, SyncDatabase.MIGRATION_3_4)
.build()
@Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao()
@@ -58,6 +58,7 @@ enum class ConflictStrategy(val label: String) {
enum class DeleteBehavior(val label: String, val description: String) {
MIRROR("Mirror deletions", "Delete on target when deleted on source"),
KEEP("Keep deleted files", "Never delete — only add/update"),
ARCHIVE("Archive deleted", "Move files deleted from phone to _Deleted/ folder on remote"),
}
enum class ScheduleType(val label: String) {
@@ -69,5 +70,5 @@ enum class ScheduleType(val label: String) {
}
enum class SyncStatus {
IDLE, SYNCING, SUCCESS, PARTIAL, FAILED, CONFLICT,
IDLE, SYNCING, PAUSED, SUCCESS, PARTIAL, FAILED, CONFLICT,
}
@@ -5,6 +5,8 @@ import android.net.Uri
import android.provider.DocumentsContract
import com.syncflow.domain.model.SyncPair
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@@ -14,10 +16,17 @@ sealed class LocalAccessor {
abstract fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo>
abstract fun openInputStream(relativePath: String): InputStream?
abstract fun createOutputStream(relativePath: String): OutputStream?
abstract fun delete(relativePath: String): Boolean
abstract fun lastModifiedMs(relativePath: String): Long
/**
* Write [relativePath] atomically: stream into a temp sibling first, then swap it into
* place only after [write] completes without throwing. An interrupted transfer (network
* drop, process death) leaves the existing destination untouched instead of truncating it.
* On failure the temp is removed and the exception is rethrown.
*/
abstract suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit)
// ── java.io.File backend (regular /storage/... paths) ────────────────────
class JavaFile(private val root: File) : LocalAccessor() {
@@ -48,10 +57,30 @@ sealed class LocalAccessor {
override fun openInputStream(relativePath: String): InputStream =
File(root, relativePath).inputStream()
override fun createOutputStream(relativePath: String): OutputStream {
override suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit) {
val dest = File(root, relativePath)
dest.parentFile?.mkdirs()
return dest.outputStream()
val tmp = File(dest.parentFile, ".${dest.name}.sfpart")
try {
FileOutputStream(tmp).use { os ->
write(os)
os.flush()
os.fd.sync() // durably persist bytes before the rename swaps the file in
}
} catch (e: Throwable) {
tmp.delete()
throw e
}
// Same-directory rename is atomic on POSIX/Android and replaces the destination.
if (!tmp.renameTo(dest)) {
try {
tmp.copyTo(dest, overwrite = true)
} catch (e: Throwable) {
tmp.delete()
throw e
}
tmp.delete()
}
}
override fun delete(relativePath: String): Boolean = File(root, relativePath).delete()
@@ -131,7 +160,7 @@ sealed class LocalAccessor {
return resolver.openInputStream(docUri)
}
override fun createOutputStream(relativePath: String): OutputStream? {
override suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit) {
val parts = relativePath.replace('\\', '/').split('/')
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
@@ -141,7 +170,7 @@ sealed class LocalAccessor {
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
val newDir = DocumentsContract.createDocument(
resolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, parts[i]
) ?: return null
) ?: throw IOException("Cannot create directory ${parts[i]} for $relativePath")
DocumentsContract.getDocumentId(newDir)
}
}
@@ -149,19 +178,47 @@ sealed class LocalAccessor {
val fileName = parts.last()
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
val tmpName = ".$fileName.sfpart"
// Delete existing to allow overwrite
findChildId(childrenUri, fileName)?.let { existingId ->
DocumentsContract.deleteDocument(
resolver,
DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
)
// Clear any leftover temp document from a previously interrupted write.
findChildId(childrenUri, tmpName)?.let { staleId ->
runCatching {
DocumentsContract.deleteDocument(
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, staleId)
)
}
}
val newUri = DocumentsContract.createDocument(
resolver, parentUri, "application/octet-stream", fileName
) ?: return null
return resolver.openOutputStream(newUri)
val tmpUri = DocumentsContract.createDocument(
resolver, parentUri, "application/octet-stream", tmpName
) ?: throw IOException("Cannot create temp document for $relativePath")
try {
(resolver.openOutputStream(tmpUri)
?: throw IOException("Cannot open temp stream for $relativePath")).use { os ->
write(os)
os.flush()
}
} catch (e: Throwable) {
runCatching { DocumentsContract.deleteDocument(resolver, tmpUri) }
throw e
}
// Commit: remove the existing destination, then rename the fully-written temp into
// place. If interrupted between the two steps the temp still holds the complete data
// (recoverable by hand), which is strictly safer than truncating the destination.
findChildId(childrenUri, fileName)?.let { existingId ->
DocumentsContract.deleteDocument(
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
)
}
val renamed = DocumentsContract.renameDocument(resolver, tmpUri, fileName)
if (renamed == null) {
runCatching { DocumentsContract.deleteDocument(resolver, tmpUri) }
throw IOException("Cannot finalize $relativePath")
}
// Drop the stale cache entry so the next read re-resolves the new document id.
docIdCache.remove(relativePath)
}
override fun delete(relativePath: String): Boolean {
@@ -24,6 +24,8 @@ import kotlinx.coroutines.sync.withPermit
import timber.log.Timber
import java.io.File
import java.time.Instant
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
class SyncEngine @Inject constructor(
@@ -33,24 +35,28 @@ class SyncEngine @Inject constructor(
private val eventDao: SyncEventDao,
@ApplicationContext private val context: Context,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
suspend fun sync(
pair: SyncPair,
provider: CloudProvider,
onProgress: (suspend (uploaded: Int, downloaded: Int, deleted: Int, bytesTransferred: Long) -> Unit)? = null,
): SyncResult {
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
return try {
val result = performSync(pair, provider)
val result = performSync(pair, provider, onProgress = onProgress)
val finalStatus = when {
result.failedFiles > 0 && result.conflicts > 0 -> SyncStatus.CONFLICT
result.failedFiles > 0 -> SyncStatus.PARTIAL
result.conflicts > 0 -> SyncStatus.CONFLICT
else -> SyncStatus.SUCCESS
}
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts, result.uploaded, result.downloaded, result.deleted, 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}")
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0, 0, 0, 0, 0L)
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, e.message, 0)
SyncResult(failedFiles = 1, error = e)
}
@@ -66,6 +72,7 @@ class SyncEngine @Inject constructor(
pair: SyncPair,
provider: CloudProvider,
isRetry: Boolean = false,
onProgress: (suspend (Int, Int, Int, Long) -> Unit)? = null,
): SyncResult {
val accessor = makeAccessor(pair.localPath)
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
@@ -81,12 +88,16 @@ class SyncEngine @Inject constructor(
knownStates.keys.none { it in localFiles }) {
Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
fileStateDao.deleteForPair(pair.id)
return performSync(pair, provider, isRetry = true)
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
val uploadedAtomic = AtomicInteger(0)
val downloadedAtomic = AtomicInteger(0)
val deletedAtomic = AtomicInteger(0)
val bytesAtomic = AtomicLong(0L)
// Each async block returns its outcome; no shared mutable state across coroutines.
data class FileOutcome(
@@ -100,6 +111,14 @@ class SyncEngine @Inject constructor(
allPaths.map { rel ->
async {
semaphore.withPermit {
// Defense-in-depth against a malicious/compromised remote returning a
// path that escapes the sync root (e.g. "../../evil"). Skip rather than
// write outside pair.localPath / pair.remotePath.
if (isUnsafeSyncPath(rel)) {
Timber.w("SyncEngine: skipping unsafe path for pair ${pair.id}: $rel")
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, "unsafe path", 0)
return@withPermit FileOutcome(skipped = 1)
}
val local = localFiles[rel]
val remote = remoteFiles[rel]
val known = knownStates[rel]
@@ -107,11 +126,10 @@ class SyncEngine @Inject constructor(
when (decision) {
SyncDecision.UPLOAD -> {
var uploadedRemoteFile: RemoteFile? = null
val bytes = runCatching {
ensureRemoteDirs(provider, pair.remotePath, rel)
accessor.openInputStream(rel)?.use { stream ->
uploadedRemoteFile = provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
provider.uploadFile(stream, "${pair.remotePath}/$rel", local!!.sizeBytes) { }.getOrThrow()
}
local!!.sizeBytes
}.getOrElse { e ->
@@ -120,13 +138,20 @@ class SyncEngine @Inject constructor(
return@withPermit FileOutcome(failed = 1)
}
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
val up = uploadedAtomic.incrementAndGet()
bytesAtomic.addAndGet(bytes)
onProgress?.invoke(up, downloadedAtomic.get(), deletedAtomic.get(), bytesAtomic.get())
// Don't store remote metadata from upload response — the server (Nextcloud etc.)
// may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
// null forces the SKIP reconciliation on the next sync to fill it in from the
// directory listing, which is the same source all future syncs will use.
FileOutcome(uploaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = uploadedRemoteFile))
newState = buildState(pair.id, rel, local!!, remoteAfterTransfer = null))
}
SyncDecision.DOWNLOAD -> {
val bytes = runCatching {
accessor.createOutputStream(rel)?.use { stream ->
provider.downloadFile("${pair.remotePath}/$rel", stream) { }
accessor.writeAtomically(rel) { stream ->
provider.downloadFile("${pair.remotePath}/$rel", stream) { }.getOrThrow()
}
remote!!.sizeBytes
}.getOrElse { e ->
@@ -139,6 +164,9 @@ class SyncEngine @Inject constructor(
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
?: System.currentTimeMillis()
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
val down = downloadedAtomic.incrementAndGet()
bytesAtomic.addAndGet(bytes)
onProgress?.invoke(uploadedAtomic.get(), down, deletedAtomic.get(), bytesAtomic.get())
FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
@@ -150,13 +178,31 @@ class SyncEngine @Inject constructor(
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
val del = deletedAtomic.incrementAndGet()
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
FileOutcome(deleted = 1)
}
SyncDecision.DELETE_REMOTE -> {
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) {
val archivePath = "${pair.remotePath}/_Deleted/$rel"
runCatching {
// Create the _Deleted base itself first — ensureRemoteDirs only
// makes sub-parents of rel, so for a top-level file the MOVE
// would otherwise fail with a missing-parent error.
provider.createDirectory("${pair.remotePath}/_Deleted")
ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel)
provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
}.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "archived", 0)
} else {
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
}
val del = deletedAtomic.incrementAndGet()
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
FileOutcome(deleted = 1)
}
SyncDecision.CONFLICT -> {
@@ -329,6 +375,19 @@ internal fun syncDecide(
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
/**
* True if a relative sync path is unsafe to act on — empty, absolute, or containing a ".."
* segment that would let a hostile remote escape the sync root via path traversal. Applied to
* every path before any file operation as defense-in-depth (WebDAV already filters names at the
* parser; SFTP and any future provider are covered here).
*/
internal fun isUnsafeSyncPath(rel: String): Boolean {
if (rel.isBlank()) return true
val normalized = rel.replace('\\', '/')
if (normalized.startsWith("/")) return true
return normalized.split('/').any { it == ".." }
}
data class SyncResult(
val uploaded: Int = 0,
val downloaded: Int = 0,
@@ -1,10 +1,12 @@
package com.syncflow.ui.addpair
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.selection.selectable
import androidx.compose.ui.semantics.Role
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@@ -22,6 +24,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.*
import com.syncflow.ui.browser.LocalBrowserDialog
import com.syncflow.ui.browser.RemoteBrowserDialog
import java.time.DayOfWeek
@@ -33,17 +36,29 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
var showLocalBrowser by remember { mutableStateOf(false) }
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
uri?.let {
val safLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
it,
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = it.toString()) }
vm.update { copy(localPath = uri.toString()) }
}
}
if (showLocalBrowser) {
LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" },
onSelect = { path ->
vm.update { copy(localPath = path) }
showLocalBrowser = false
},
onDismiss = { showLocalBrowser = false },
)
}
if (showRemoteBrowser && s.selectedAccountId != -1L) {
RemoteBrowserDialog(
accountId = s.selectedAccountId,
@@ -108,18 +123,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp))
// Local folder
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = {
IconButton(onClick = { dirPicker.launch(null) }) {
Icon(Icons.Default.FolderOpen, "Browse")
}
},
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
label = { Text("Local folder") },
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Tap to choose folder…") },
)
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
// Remote folder
OutlinedTextField(
@@ -151,7 +165,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
label = "Direction",
options = SyncDirection.entries,
selected = s.syncDirection,
onSelect = { vm.update { copy(syncDirection = it) } },
onSelect = { vm.setDirection(it) },
itemLabel = { "${it.label}${it.description}" },
)
Spacer(Modifier.height(8.dp))
@@ -167,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
label = "Deletion behaviour",
options = DeleteBehavior.entries,
selected = s.deleteBehavior,
onSelect = { vm.update { copy(deleteBehavior = it) } },
onSelect = { vm.setDeleteBehavior(it) },
itemLabel = { "${it.label}${it.description}" },
)
}
@@ -370,10 +384,17 @@ private fun <T> RadioGroup(
}
options.forEach { option ->
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = option == selected,
role = Role.RadioButton,
onClick = { onSelect(option) },
),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = option == selected, onClick = { onSelect(option) })
// onClick = null: the whole row handles selection (bigger tap target + a11y).
RadioButton(selected = option == selected, onClick = null)
Text(itemLabel(option), style = MaterialTheme.typography.bodyMedium)
}
}
@@ -28,7 +28,10 @@ data class AddPairUiState(
// ── Sync type ────────────────────────────────────────────────────────────
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
val deleteBehavior: DeleteBehavior = recommendedDeleteBehavior(SyncDirection.TWO_WAY),
// True once the user explicitly picks a deletion behaviour, so changing direction stops
// auto-overriding their choice.
val deleteBehaviorTouched: Boolean = false,
val recursive: Boolean = true,
// ── Schedule ─────────────────────────────────────────────────────────────
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
@@ -56,6 +59,16 @@ data class AddPairUiState(
val done: Boolean = false,
)
/**
* Safe default deletion behaviour for a given direction. One-way backups must NOT propagate a
* local deletion to the cloud (the whole point of a backup), so they default to KEEP; two-way
* sync defaults to MIRROR. The user can always override — all three options stay selectable.
*/
internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior = when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.DOWNLOAD_ONLY -> DeleteBehavior.KEEP
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
}
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
@@ -93,6 +106,7 @@ class AddPairViewModel @Inject constructor(
syncDirection = pair.syncDirection,
conflictStrategy = pair.conflictStrategy,
deleteBehavior = pair.deleteBehavior,
deleteBehaviorTouched = true, // preserve the saved choice when editing
recursive = pair.recursive,
scheduleType = pair.scheduleType,
intervalMinutes = pair.scheduleIntervalMinutes,
@@ -119,6 +133,18 @@ class AddPairViewModel @Inject constructor(
fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform)
/** Changing direction re-applies the safe deletion default unless the user already chose one. */
fun setDirection(direction: SyncDirection) = _state.update { s ->
s.copy(
syncDirection = direction,
deleteBehavior = if (s.deleteBehaviorTouched) s.deleteBehavior else recommendedDeleteBehavior(direction),
)
}
fun setDeleteBehavior(behavior: DeleteBehavior) = _state.update {
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
}
fun save() {
val s = _state.value
val errors = buildList {
@@ -0,0 +1,391 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private data class LocalEntry(val file: File, val childCount: Int)
private val STORAGE_ROOT = File("/storage/emulated/0")
private data class Shortcut(val label: String, val icon: ImageVector, val path: String)
private val SHORTCUTS = listOf(
Shortcut("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
Shortcut("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
Shortcut("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
Shortcut("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
Shortcut("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
Shortcut("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalBrowserDialog(
initialPath: String = STORAGE_ROOT.absolutePath,
onSelect: (path: String) -> Unit,
onDismiss: () -> Unit,
) {
var currentPath by remember { mutableStateOf(File(initialPath.ifBlank { STORAGE_ROOT.absolutePath }).let { if (it.isDirectory) it else STORAGE_ROOT }) }
var pathStack by remember { mutableStateOf(listOf(currentPath)) }
var entries by remember { mutableStateOf<List<LocalEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
fun loadDir(dir: File) {
isLoading = true
entries = emptyList()
scope.launch {
val result = withContext(Dispatchers.IO) {
dir.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.sortedBy { it.name.lowercase() }
?.map { f -> LocalEntry(f, f.listFiles()?.count { it.isDirectory } ?: 0) }
?: emptyList()
}
entries = result
isLoading = false
}
}
fun navigateTo(dir: File) {
currentPath = dir
pathStack = pathStack + dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
fun navigateUp(): Boolean {
if (pathStack.size <= 1) return false
val newStack = pathStack.dropLast(1)
pathStack = newStack
currentPath = newStack.last()
searchQuery = ""
searchActive = false
loadDir(currentPath)
return true
}
fun navigateToBreadcrumb(dir: File) {
val idx = pathStack.indexOfLast { it.absolutePath == dir.absolutePath }
pathStack = if (idx >= 0) pathStack.take(idx + 1) else listOf(dir)
currentPath = dir
searchQuery = ""
searchActive = false
loadDir(dir)
}
LaunchedEffect(Unit) { loadDir(currentPath) }
// Build breadcrumb segments relative to storage root
val relParts = currentPath.absolutePath
.removePrefix(STORAGE_ROOT.absolutePath)
.trimStart('/')
.split('/')
.filter { it.isNotEmpty() }
// Auto-scroll breadcrumbs to end
LaunchedEffect(currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
}
val filtered = if (searchQuery.isBlank()) entries
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" }
val context = LocalContext.current
val hasAllFilesAccess = remember {
Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
val view = LocalView.current
val density = LocalDensity.current
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(56.dp) }
DisposableEffect(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
with(density) {
topInset = bars.top.toDp()
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
}
insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
navigationIcon = {
IconButton(onClick = { if (!navigateUp()) onDismiss() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search folders…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Local Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
// ── All-files-access banner ──────────────────────────────────
if (!hasAllFilesAccess) {
Surface(color = MaterialTheme.colorScheme.errorContainer) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Warning, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onErrorContainer)
Text(
"Grant \"All files access\" to browse and sync all folders",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.fromParts("package", context.packageName, null))
else Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
context.startActivity(intent)
},
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) { Text("Grant", style = MaterialTheme.typography.labelSmall) }
}
}
}
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(label = "📱 Storage", isLast = relParts.isEmpty(),
onClick = { navigateToBreadcrumb(STORAGE_ROOT) })
}
itemsIndexed(relParts) { idx, part ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val partPath = STORAGE_ROOT.absolutePath + "/" + relParts.take(idx + 1).joinToString("/")
BreadcrumbChip(label = part, isLast = idx == relParts.lastIndex,
onClick = { navigateToBreadcrumb(File(partPath)) })
}
}
}
HorizontalDivider()
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
if (currentPath.absolutePath == STORAGE_ROOT.absolutePath && searchQuery.isBlank()) {
item {
Text("Quick access", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 6.dp))
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 8.dp)) {
items(SHORTCUTS.filter { File(it.path).isDirectory }) { sc ->
ShortcutChip(sc, onClick = { navigateTo(File(sc.path)) })
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
Text("All folders", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
}
}
if (filtered.isEmpty()) {
item {
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text(if (searchQuery.isBlank()) "No subfolders" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
items(filtered, key = { it.file.absolutePath }) { entry ->
LocalFolderItem(entry = entry, onClick = { navigateTo(entry.file) })
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(currentPath.absolutePath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text("Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.height(bottomInset))
}
}
}
}
}
}
@Composable
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
if (isLast) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1)
}
} else {
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1)
}
}
}
@Composable
private fun ShortcutChip(sc: Shortcut, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(72.dp).width(80.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(sc.icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.height(4.dp))
Text(sc.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
}
}
}
@Composable
private fun LocalFolderItem(entry: LocalEntry, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF1B5E20).copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Folder, null, Modifier.size(26.dp), tint = Color(0xFF2E7D32))
}
Column(modifier = Modifier.weight(1f)) {
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
if (entry.childCount > 0) {
Text("${entry.childCount} subfolder${if (entry.childCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@@ -1,22 +1,40 @@
package com.syncflow.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -30,79 +48,205 @@ fun RemoteBrowserDialog(
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
val state by vm.state.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll breadcrumbs to end when path changes
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
LaunchedEffect(state.currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segments.size)) }
searchQuery = ""
searchActive = false
}
val filtered = if (searchQuery.isBlank()) state.entries
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = state.currentPath.trimEnd('/').substringAfterLast('/').ifBlank { "Root" }
if (showNewFolderDialog) {
NewFolderDialog(
onConfirm = { name ->
vm.createFolder(name)
showNewFolderDialog = false
},
onDismiss = { showNewFolderDialog = false },
)
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.85f),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
) {
Column {
// Title bar
val view = LocalView.current
val density = LocalDensity.current
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(56.dp) }
DisposableEffect(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
with(density) {
topInset = bars.top.toDp()
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
}
insets
}
ViewCompat.requestApplyInsets(view)
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
// ── Top bar ──────────────────────────────────────────────────
TopAppBar(
title = {
Column {
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
Text(
state.currentPath,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
Icon(Icons.Default.ArrowBack, null)
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
},
title = {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text("Search in folder…") },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {}),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
)
} else {
Text("Choose Folder", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
// Select current folder
TextButton(onClick = { onSelect(state.currentPath) }) {
Text("Select here")
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
}
IconButton(onClick = { showNewFolderDialog = true }) {
Icon(Icons.Default.CreateNewFolder, "New Folder")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
// ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip(
label = "",
isLast = segments.isEmpty(),
onClick = { vm.navigateToBreadcrumb("/") },
)
}
itemsIndexed(segments) { idx, seg ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val segPath = "/" + segments.take(idx + 1).joinToString("/")
BreadcrumbChip(
label = seg,
isLast = idx == segments.lastIndex,
onClick = { vm.navigateToBreadcrumb(segPath) },
)
}
}
}
HorizontalDivider()
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
Text(state.error!!, color = MaterialTheme.colorScheme.error)
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
// ── Content ──────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.error != null -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = vm::retry) {
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Retry")
}
}
filtered.isEmpty() && searchQuery.isBlank() -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("This folder is empty", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("You can still select it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
}
filtered.isEmpty() -> Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Default.SearchOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(filtered, key = { it.path }) { entry ->
FolderItem(
file = entry,
onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
},
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
}
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.entries, key = { it.path }) { entry ->
BrowserEntry(
file = entry,
onClick = {
if (entry.isDirectory) vm.navigateTo(entry.path)
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
},
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
}
// ── Select button ────────────────────────────────────────────
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
Button(
onClick = { onSelect(state.currentPath) },
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
"Select \"$currentFolderName\"",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
}
Spacer(Modifier.height(bottomInset))
}
}
}
@@ -111,44 +255,119 @@ fun RemoteBrowserDialog(
}
@Composable
private fun BrowserEntry(
file: RemoteFile,
onClick: () -> Unit,
onSelectFolder: (() -> Unit)?,
) {
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
if (isLast) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Text(
label,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 1,
)
}
} else {
TextButton(
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
}
}
}
@Composable
private fun FolderItem(file: RemoteFile, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(14.dp))
// Colored icon badge
Box(
modifier = Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (file.isDirectory) Color(0xFF1B5E20).copy(alpha = 0.12f)
else Color(0xFF0D47A1).copy(alpha = 0.10f)
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
contentDescription = null,
modifier = Modifier.size(26.dp),
tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
file.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal,
)
if (!file.isDirectory) {
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
file.sizeBytes.formatBytes(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (onSelectFolder != null) {
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
} else {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
if (file.isDirectory) {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@Composable
private fun NewFolderDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.CreateNewFolder, null) },
title = { Text("New Folder") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Folder name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onConfirm(name.trim()) }),
)
},
confirmButton = {
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun Long.formatBytes(): String = when {
this < 1024 -> "${this}B"
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)}KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)}MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)}GB"
this < 1024 -> "${this} B"
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
}
@@ -57,6 +57,27 @@ class RemoteBrowserViewModel @Inject constructor(
return true
}
fun navigateToBreadcrumb(path: String) {
val stack = _state.value.pathStack
val idx = stack.lastIndexOf(path)
val newStack = if (idx >= 0) stack.take(idx + 1) else listOf(path)
loadJob?.cancel()
_state.update { it.copy(currentPath = path, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
loadJob = loadPath(_state.value.accountId, path)
}
fun createFolder(name: String) {
val s = _state.value
val newPath = if (s.currentPath.trimEnd('/') == "") "/$name" else "${s.currentPath.trimEnd('/')}/$name"
viewModelScope.launch {
val account = accountRepository.getAccount(s.accountId) ?: return@launch
val provider = runCatching { providerFactory.create(account) }.getOrElse { return@launch }
provider.createDirectory(newPath)
.onSuccess { retry() }
.onFailure { e -> _state.update { it.copy(error = "Could not create folder: ${e.message}") } }
}
}
fun retry() {
val s = _state.value
if (s.accountId == -1L) return
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,9 @@ class FilesViewModel @Inject constructor(
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val accounts = accountRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
@@ -158,6 +161,31 @@ class FilesViewModel @Inject constructor(
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
fun openCloudFile(accountId: Long, remotePath: String) {
viewModelScope.launch {
val account = accountRepository.getAccount(accountId) ?: run {
_fileAction.emit(FileAction.Error("Account not found"))
return@launch
}
val provider = providerFactory.create(account)
val fileName = remotePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
try {
cacheFile.outputStream().use { out ->
provider.downloadFile(remotePath, out) { }.getOrThrow()
}
_fileAction.emit(FileAction.Open(cacheFile))
} catch (e: Exception) {
Timber.e(e, "Cloud open failed: $remotePath")
_fileAction.emit(FileAction.Error("Cannot open: ${e.message}"))
} finally {
_isDownloading.value = false
}
}
}
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -38,6 +39,7 @@ fun HomeScreen(
vm: HomeViewModel = hiltViewModel(),
) {
val pairs by vm.syncPairs.collectAsState()
val progressMap by vm.syncProgressMap.collectAsState()
if (pairs.isEmpty()) {
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
@@ -50,9 +52,11 @@ fun HomeScreen(
items(pairs, key = { it.id }) { pair ->
SyncPairCard(
pair = pair,
progress = progressMap[pair.id],
onClick = { onPairClick(pair.id) },
onSync = { vm.triggerSync(pair) },
onToggle = { vm.toggleEnabled(pair) },
onPause = { vm.pauseSync(pair) },
)
}
item { Spacer(Modifier.height(80.dp)) }
@@ -63,9 +67,11 @@ fun HomeScreen(
@Composable
private fun SyncPairCard(
pair: SyncPairEntity,
progress: SyncProgress? = null,
onClick: () -> Unit,
onSync: () -> Unit,
onToggle: () -> Unit,
onPause: () -> Unit = {},
) {
val accentColor = pair.lastSyncResult.accentColor
@@ -170,13 +176,57 @@ private fun SyncPairCard(
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation",
)
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.Sync, "Sync now",
modifier = Modifier.size(18.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
},
)
when (pair.lastSyncResult) {
SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
}
SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp), enabled = pair.isEnabled) {
Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp))
}
else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = syncRotation * 0f })
}
}
}
val displayProgress = when {
pair.lastSyncResult == SyncStatus.SYNCING -> progress
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
else -> null
}
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
Text(
"Starting…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
} else if (displayProgress != null) {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (displayProgress.uploaded > 0) {
Icon(Icons.Default.ArrowUpward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary)
Text("${displayProgress.uploaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
}
if (displayProgress.downloaded > 0) {
Icon(Icons.Default.ArrowDownward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.secondary)
Text("${displayProgress.downloaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
}
if (displayProgress.deleted > 0) {
Icon(Icons.Default.DeleteOutline, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.error)
Text("${displayProgress.deleted}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
}
if (displayProgress.bytesTransferred > 0) {
Text(
"· ${displayProgress.bytesTransferred.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -189,6 +239,7 @@ private fun StatusPill(status: SyncStatus) {
val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
@@ -245,14 +296,22 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
}
}
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"
}
private val SyncStatus.accentColor: Color
@Composable get() = when (this) {
SyncStatus.SUCCESS -> MaterialTheme.colorScheme.primary
SyncStatus.SYNCING -> MaterialTheme.colorScheme.secondary
SyncStatus.FAILED -> MaterialTheme.colorScheme.error
SyncStatus.CONFLICT,
SyncStatus.PARTIAL -> MaterialTheme.colorScheme.tertiary
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
SyncStatus.SUCCESS -> Color(0xFF2E7D32)
SyncStatus.SYNCING -> Color(0xFF1565C0)
SyncStatus.PAUSED -> Color(0xFF6A1B9A)
SyncStatus.FAILED -> Color(0xFFC62828)
SyncStatus.PARTIAL -> Color(0xFFE65100)
SyncStatus.CONFLICT -> Color(0xFFF9A825)
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
}
private fun String.toDisplayPath(): String {
@@ -4,15 +4,20 @@ import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -27,11 +32,33 @@ class HomeViewModel @Inject constructor(
val syncPairs = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncProgressMap: kotlinx.coroutines.flow.StateFlow<Map<Long, SyncProgress>> =
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map { infos ->
infos
.mapNotNull { info ->
val tag = info.tags.firstOrNull { it.startsWith("sync_") } ?: return@mapNotNull null
val pairId = tag.removePrefix("sync_").toLongOrNull() ?: return@mapNotNull null
val up = info.progress.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0)
val down = info.progress.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0)
val del = info.progress.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0)
val bytes = info.progress.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L)
if (up > 0 || down > 0 || del > 0) pairId to SyncProgress(up, down, del, bytes) else null
}
.toMap()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
fun triggerSync(pair: SyncPairEntity) {
val req = SyncWorker.buildOneTimeRequest(pair.id, wifiOnly = false, chargingOnly = false)
workManager.enqueue(req)
}
fun pauseSync(pair: SyncPairEntity) {
workManager.cancelAllWorkByTag("sync_${pair.id}")
viewModelScope.launch { syncPairDao.updateStatus(pair.id, SyncStatus.PAUSED) }
}
fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch {
val nowEnabled = !pair.isEnabled
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration
import java.time.Instant
@@ -40,6 +41,7 @@ fun PairDetailScreen(
val pair by vm.pair.collectAsState()
val events by vm.events.collectAsState()
val conflictCount by vm.unresolvedConflicts.collectAsState()
val syncProgress by vm.syncProgress.collectAsState()
var showDelete by remember { mutableStateOf(false) }
if (showDelete) {
@@ -66,7 +68,17 @@ fun PairDetailScreen(
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
when (pair?.lastSyncResult) {
SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) {
Icon(Icons.Default.Pause, "Pause sync")
}
SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }, enabled = pair?.isEnabled == true) {
Icon(Icons.Default.PlayArrow, "Resume sync")
}
else -> IconButton(onClick = { vm.syncNow() }) {
Icon(Icons.Default.Sync, "Sync now")
}
}
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
},
)
@@ -78,7 +90,7 @@ fun PairDetailScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
pair?.let { p -> StatusBanner(p) }
pair?.let { p -> StatusBanner(p, syncProgress) }
}
item {
@@ -138,10 +150,11 @@ fun PairDetailScreen(
}
@Composable
private fun StatusBanner(pair: SyncPairEntity) {
private fun StatusBanner(pair: SyncPairEntity, progress: SyncProgress? = null) {
val (icon, label, containerColor) = when (pair.lastSyncResult) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.PAUSED -> Triple(Icons.Default.Pause, "Paused — tap ▶ to resume", MaterialTheme.colorScheme.surfaceVariant)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
@@ -170,8 +183,39 @@ private fun StatusBanner(pair: SyncPairEntity) {
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.titleMedium)
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
val displayProgress = when {
pair.lastSyncResult == SyncStatus.SYNCING -> progress
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
else -> null
}
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
Text("Starting…", style = MaterialTheme.typography.bodySmall)
} else if (displayProgress != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (displayProgress.uploaded > 0) {
Icon(Icons.Default.ArrowUpward, null, Modifier.size(12.dp))
Text("${displayProgress.uploaded} up", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.downloaded > 0) {
Icon(Icons.Default.ArrowDownward, null, Modifier.size(12.dp))
Text("${displayProgress.downloaded} down", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.deleted > 0) {
Icon(Icons.Default.DeleteOutline, null, Modifier.size(12.dp))
Text("${displayProgress.deleted} del", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.bytesTransferred > 0) {
Text("· ${displayProgress.bytesTransferred.toDisplaySize()}", style = MaterialTheme.typography.bodySmall)
}
}
} else {
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
}
}
}
}
@@ -229,6 +273,13 @@ private fun InfoRow(
}
}
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"
}
private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -3,13 +3,17 @@ package com.syncflow.ui.pairdetail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import com.syncflow.ui.shared.SyncProgress
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -34,11 +38,30 @@ class PairDetailViewModel @Inject constructor(
val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val syncProgress = workManager.getWorkInfosByTagFlow("sync_$pairId")
.map { infos ->
infos.firstOrNull { it.state == WorkInfo.State.RUNNING }?.progress?.let { data ->
SyncProgress(
uploaded = data.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0),
downloaded = data.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0),
deleted = data.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0),
bytesTransferred = data.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L),
).takeIf { it.uploaded > 0 || it.downloaded > 0 || it.deleted > 0 }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun syncNow() {
val p = pair.value ?: return
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false))
}
fun pauseSync() {
val p = pair.value ?: return
workManager.cancelAllWorkByTag("sync_${p.id}")
viewModelScope.launch { syncPairDao.updateStatus(p.id, SyncStatus.PAUSED) }
}
fun delete() {
viewModelScope.launch {
pair.value?.let { syncPairDao.delete(it) }
@@ -0,0 +1,3 @@
package com.syncflow.ui.shared
data class SyncProgress(val uploaded: Int, val downloaded: Int, val deleted: Int, val bytesTransferred: Long)
@@ -47,7 +47,14 @@ class SyncWorker @AssistedInject constructor(
return try {
val domainPair = pair.toDomain()
val provider = providerFactory.create(account)
val result = syncEngine.sync(domainPair, provider)
val result = syncEngine.sync(domainPair, provider) { up, down, del, bytes ->
setProgress(workDataOf(
KEY_PROGRESS_UPLOADED to up,
KEY_PROGRESS_DOWNLOADED to down,
KEY_PROGRESS_DELETED to del,
KEY_PROGRESS_BYTES to bytes,
))
}
val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
@@ -158,6 +165,10 @@ class SyncWorker @AssistedInject constructor(
const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
const val KEY_PROGRESS_UPLOADED = "prog_up"
const val KEY_PROGRESS_DOWNLOADED = "prog_down"
const val KEY_PROGRESS_DELETED = "prog_del"
const val KEY_PROGRESS_BYTES = "prog_bytes"
private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Pure black background -->
<path android:pathData="M0,0 H108 V108 H0 Z"
android:fillColor="#000000"/>
</vector>
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
Four thick arcs arranged as an interlocked pinwheel.
Each arc sweeps ~210 degrees, rounded caps, radius 18 from center (54,54).
Draw order creates natural over/under at the four crossing points:
blue under green, green under red, red under orange, orange under blue (re-draw blue tip).
Arc endpoints computed at radius 18, sweep 210 deg clockwise:
start angle end angle start point end point
270 (top) 120 (54, 36) (45, 70)
0 (right) 210 (72, 54) (39, 45)
90 (bot) 300 (54, 72) (63, 38)
180 (left) 390=30 (36, 54) (69, 63)
-->
<!-- Blue — starts at top, sweeps clockwise to lower-left -->
<path
android:strokeColor="#2979FF"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 1,1 45,70"/>
<!-- Green — starts at bottom, sweeps clockwise to upper-right -->
<path
android:strokeColor="#00C853"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,72 A 18,18 0 1,1 63,38"/>
<!-- Red — starts at right, sweeps clockwise to lower-left -->
<path
android:strokeColor="#E53935"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 72,54 A 18,18 0 1,1 39,45"/>
<!-- Orange — starts at left, sweeps clockwise to upper-right -->
<path
android:strokeColor="#FF6D00"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 36,54 A 18,18 0 1,1 69,63"/>
<!-- Re-draw blue start cap on top so it goes OVER orange end -->
<path
android:strokeColor="#2979FF"
android:strokeWidth="8.5"
android:fillColor="#00000000"
android:strokeLineCap="round"
android:pathData="M 54,36 A 18,18 0 0,1 62,37.5"/>
<!-- White sync circle at center -->
<path
android:fillColor="#000000"
android:pathData="M 45,54 A 9,9 0 1,0 63,54 A 9,9 0 1,0 45,54 Z"/>
<!-- Sync ring -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:fillColor="#00000000"
android:pathData="M 46.5,54 A 7.5,7.5 0 1,0 61.5,54 A 7.5,7.5 0 1,0 46.5,54 Z"/>
<!-- Top arrow head (pointing up) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,46.5 L 57,50.5 L 51,50.5 Z"/>
<!-- Bottom arrow head (pointing down) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M 54,61.5 L 51,57.5 L 57,57.5 Z"/>
</vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2196F3</color>
<color name="ic_launcher_background">#050E05</color>
</resources>
@@ -0,0 +1,36 @@
package com.syncflow.domain.sync
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Path-traversal guard: a hostile/compromised remote must not be able to make the engine read
* or write outside the sync root via "..", absolute, or separator-smuggled paths.
*/
class PathSafetyTest {
@Test fun `normal relative paths are allowed`() {
assertFalse(isUnsafeSyncPath("photo.jpg"))
assertFalse(isUnsafeSyncPath("sub/dir/photo.jpg"))
assertFalse(isUnsafeSyncPath("a.b..c/file.txt")) // ".." only inside a name, not a segment
}
@Test fun `parent-dir traversal is rejected`() {
assertTrue(isUnsafeSyncPath(".."))
assertTrue(isUnsafeSyncPath("../evil"))
assertTrue(isUnsafeSyncPath("a/../../etc/passwd"))
assertTrue(isUnsafeSyncPath("sub/../../escape"))
}
@Test fun `backslash traversal is rejected`() {
assertTrue(isUnsafeSyncPath("..\\evil"))
assertTrue(isUnsafeSyncPath("a\\..\\..\\escape"))
}
@Test fun `absolute and empty paths are rejected`() {
assertTrue(isUnsafeSyncPath("/etc/passwd"))
assertTrue(isUnsafeSyncPath(""))
assertTrue(isUnsafeSyncPath(" "))
}
}
@@ -91,10 +91,19 @@ class SyncDecideTest {
decide(local(ts), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `1ms difference detected as local change`() {
@Test fun `sub-second mtime difference treated as unchanged`() {
// Second-precision comparison is intentional: FAT32 has 2s mtime resolution and WebDAV
// 1s, so sub-second deltas are phantom changes that caused rewrite loops. A 1ms diff
// within the same second must NOT be treated as a change.
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.SKIP,
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `mtime change of a full second detected as local change`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.UPLOAD,
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
decide(local(ts + 1000), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `epoch-second stored value differs from millis comparison`() {
@@ -127,12 +136,14 @@ class SyncDecideTest {
assertEquals(SyncDecision.SKIP,
decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY))
// ── local deleted, no state record (uploaded in broken version) ──────────
// ── remote exists, no state record: never delete on ambiguity ────────────
@Test fun `local deleted no known state but pair has prior history deletes remote`() =
// hasPriorState=true means the pair has been synced before; file has no state
// because it was uploaded when getFileMetadata was broken. Should still mirror deletion.
assertEquals(SyncDecision.DELETE_REMOTE,
@Test fun `remote exists with no state record downloads rather than deleting`() =
// known=null can mean a brand-new remote file OR one whose state was lost. The engine
// cannot tell them apart, so it downloads rather than risk deleting a real file —
// worst case is a re-downloaded file, never a lost one. A file the user genuinely
// deleted locally still has its state record, which routes to DELETE_REMOTE.
assertEquals(SyncDecision.DOWNLOAD,
decide(null, remote(), known = null, delete = DeleteBehavior.MIRROR, hasPriorState = true))
@Test fun `initial sync remote only no prior state downloads`() =
@@ -0,0 +1,117 @@
package com.syncflow.domain.sync
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.RemoteFile
import com.syncflow.domain.model.SyncDirection
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
/**
* End-to-end decision lifecycle for the backup scenario:
*
* "I back up my phone to the cloud, then I delete the file on the phone.
* It must stay in the cloud."
*
* These walk the exact multi-cycle state the SyncEngine produces:
* - a successful UPLOAD saves state with the local mtime known and remote metadata null
* (SyncEngine.buildState(..., remoteAfterTransfer = null)),
* - the next sync sees both sides present and unchanged, returns SKIP, and the SKIP branch
* reconciles the record by filling in the remote metadata,
* - then the local file is deleted and we assert what happens to the cloud copy.
*
* The decision is driven entirely by deleteBehavior, so each terminal case is asserted for
* KEEP, MIRROR, and ARCHIVE.
*/
class UploadBackupLifecycleTest {
private val T0 = 1_716_393_136_000L // exact second boundary
private fun local(ms: Long = T0, size: Long = 100L) =
LocalFileInfo("photo.jpg", size, ms)
private fun remote(ms: Long = T0, etag: String? = "etag1", size: Long = 100L) =
RemoteFile("backup/photo.jpg", "photo.jpg", false, size, Instant.ofEpochMilli(ms), etag, null)
/** Mirrors SyncEngine.buildState right after a successful UPLOAD: remote metadata still null. */
private fun stateAfterUpload(ms: Long = T0) = SyncFileStateEntity(
syncPairId = 1L, relativePath = "photo.jpg",
localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null,
remoteModifiedAt = null, remoteSizeBytes = 0L, remoteEtag = null,
lastSyncedAt = Instant.now(), syncedHash = null,
)
/** Mirrors the record after the next sync's SKIP reconciliation fills in remote metadata. */
private fun stateReconciled(ms: Long = T0, etag: String? = "etag1") = SyncFileStateEntity(
syncPairId = 1L, relativePath = "photo.jpg",
localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null,
remoteModifiedAt = Instant.ofEpochMilli(ms), remoteSizeBytes = 100L, remoteEtag = etag,
lastSyncedAt = Instant.now(), syncedHash = null,
)
private fun decide(
local: LocalFileInfo?,
remote: RemoteFile?,
known: SyncFileStateEntity?,
delete: DeleteBehavior,
) = syncDecide(
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, delete,
local, remote, known, hasPriorSyncState = known != null,
)
// ── Cycle 1: first backup uploads the file ───────────────────────────────
@Test fun `cycle 1 - first backup uploads regardless of delete behavior`() {
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.KEEP))
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.MIRROR))
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.ARCHIVE))
}
// ── Cycle 2: file present on both sides, unchanged -> SKIP (no deletion) ──
@Test fun `cycle 2 - unchanged file skips right after upload (remote metadata still null)`() {
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.KEEP))
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.MIRROR))
}
@Test fun `cycle 2 - unchanged file skips once state is reconciled`() {
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.KEEP))
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.MIRROR))
}
// ── Cycle 3: deleted on the phone — THE scenario ─────────────────────────
@Test fun `KEEP - deleting on phone leaves the cloud copy (correct backup behavior)`() {
// Reconciled steady state:
assertEquals(SyncDecision.SKIP, decide(null, remote(), stateReconciled(), DeleteBehavior.KEEP))
// And even if deletion happens before the reconcile pass ran:
assertEquals(SyncDecision.SKIP, decide(null, remote(), stateAfterUpload(), DeleteBehavior.KEEP))
}
@Test fun `MIRROR - deleting on phone DELETES the cloud copy (wrong for a backup)`() {
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.MIRROR))
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateAfterUpload(), DeleteBehavior.MIRROR))
}
@Test fun `ARCHIVE - deleting on phone moves the cloud copy to _Deleted (preserved)`() {
// syncDecide returns DELETE_REMOTE; the engine's DELETE_REMOTE branch MOVEs the file to
// <remote>/_Deleted/ instead of removing it when deleteBehavior == ARCHIVE.
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.ARCHIVE))
}
// ── After deletion: a brand-new remote file is NOT pulled down (upload-only) ─
@Test fun `KEEP - a new remote file never comes down to the phone (upload-only)`() {
// Remote-only, no state record: in upload-only this must SKIP, not DOWNLOAD.
assertEquals(SyncDecision.SKIP, decide(null, remote(), null, DeleteBehavior.KEEP))
}
// ── Re-adding / changing a file after a KEEP deletion still uploads ───────
@Test fun `KEEP - modifying the file locally still uploads the change`() {
val newer = local(T0 + 5_000)
assertEquals(SyncDecision.UPLOAD, decide(newer, remote(), stateReconciled(), DeleteBehavior.KEEP))
}
}
@@ -0,0 +1,23 @@
package com.syncflow.ui.addpair
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.SyncDirection
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The Add-Pair screen's default deletion behaviour must never wipe a backup. One-way directions
* default to KEEP so deleting a file on the phone leaves the cloud copy intact; two-way defaults
* to MIRROR. (The user can still override to any of the three options.)
*/
class RecommendedDeleteBehaviorTest {
@Test fun `upload-only defaults to KEEP so backups are never deleted`() =
assertEquals(DeleteBehavior.KEEP, recommendedDeleteBehavior(SyncDirection.UPLOAD_ONLY))
@Test fun `download-only defaults to KEEP`() =
assertEquals(DeleteBehavior.KEEP, recommendedDeleteBehavior(SyncDirection.DOWNLOAD_ONLY))
@Test fun `two-way defaults to MIRROR`() =
assertEquals(DeleteBehavior.MIRROR, recommendedDeleteBehavior(SyncDirection.TWO_WAY))
}
Binary file not shown.
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.37
VERSION_CODE=38
VERSION_NAME=1.0.63
VERSION_CODE=64