Compare commits

...

40 Commits

Author SHA1 Message Date
amir a348c43c66 v1.0.65: chunked upload for large files (>100MB) on Nextcloud
Build & Release APK / build (push) Successful in 12m58s
Big-file testing found single-PUT uploads 413 above the server's per-request
cap (Apache LimitRequestBody / PHP post_max_size / proxy limits). NextcloudProvider
now uploads files >chunkSize (100MB) via the dav/uploads chunked API: MKCOL a
session, PUT N chunks, then MOVE .file onto the destination (atomic assemble).
Bypasses any per-request cap so multi-GB files back up. Verified byte-exact
(multi-chunk) against live Nextcloud. SFTP already streams; single-PUT path
unchanged for <=100MB.
2026-06-05 15:45:47 +00:00
amir f90d84e1fc v1.0.64: signed release (atomic transfers, backup-safe defaults, security + encoding fixes, full test suite)
Build & Release APK / build (push) Failing after 13m47s
2026-06-05 15:17:03 +00:00
amir 10007eb4fb Add interruption/atomicity, SFTP, and scheduling tests
Build & Release APK / build (push) Successful in 12m50s
- Interruption: failed mid-write leaves original intact (no truncation, no temp
  leftover); a sync that drops after N files resumes cleanly on the next sync
  with all content byte-intact (real network-drop simulation).
- SFTP: live round-trip test against an SFTP server (connect/upload-atomic/
  list/download/overwrite/special-name/delete); skips if endpoint unreachable.
- Scheduling: WorkManager request builders map Wi-Fi-only -> UNMETERED,
  charging-only -> requiresCharging, interval, input data, and tags correctly.
2026-06-05 15:16:10 +00:00
amir 29b5d555b8 Add edge-case + stress test battery (14 tests)
Build & Release APK / build (push) Successful in 12m54s
Empty files, 20MB large file (byte-intact round-trip), 8-level deep nesting,
unicode folder names, 200-char filenames, no-extension files, idempotency/loop
guard (repeated syncs upload nothing), bulk update/delete/download (10 each),
KEEP_BOTH conflict, min-size + include-extension filters, whole-folder wipe.
All green on a Galaxy S23 against live Nextcloud.
2026-06-05 14:54:38 +00:00
amir 369e260158 Add 100-file volume test (subfolders + non-ASCII, 0 failures, no re-sync loop)
Build & Release APK / build (push) Successful in 12m53s
Verifies the engine handles 100+ files in one sync without failures and that a
follow-up sync is a clean no-op (no phantom re-uploads at volume).
2026-06-05 14:45:49 +00:00
amir 1ecae2c690 Fix WebDAV upload of non-ASCII/special filenames (URL + MOVE header encoding)
Build & Release APK / build (push) Successful in 12m50s
Volume test (100 files) surfaced it: files with non-ASCII names (e.g. 'naïve
café.txt') failed to upload — url() built a raw string, so the MOVE Destination
header carried non-ASCII chars that OkHttp rejects. Now url() percent-encodes
each path segment via HttpUrl.addPathSegments (also covers '&', spaces, CJK).
Regression test specialAndNonAsciiNames_upload added.
2026-06-05 14:38:52 +00:00
amir 39aa2f7dfd Add source-available license (no redistribution / publishing)
Build & Release APK / build (push) Successful in 13m11s
Code is publicly viewable and forkable for personal use, but redistribution,
publishing (any app store/release), and commercial use are prohibited — all
publishing rights reserved to the copyright holder. Combined with the private
release signing key, this keeps the app exclusively the owner's to publish.
2026-06-05 10:48:04 +00:00
amir 402d0447a0 Merge: atomic transfers, signed-release CI, backup-safe defaults, security hardening, full test suite
Build & Release APK / build (push) Successful in 12m54s
- Atomic local/WebDAV/SFTP transfers (no truncation on interrupted sync)
- Direction-aware delete default (Upload-only => KEEP; backups not wiped)
- Path-traversal guard against hostile remotes
- ARCHIVE delete fix (create _Deleted base)
- CI: run tests on every push, signed release on tags
- 40 JVM tests + 14 on-device Nextcloud integration tests
2026-06-05 10:25:32 +00:00
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
amir 897b685c70 Fix perpetual sync loop and wrong delete decisions
Three bugs fixed:

1. catchupScan used raw dir.walk() with no filters, causing hidden/excluded
   files to appear as "new" every startup and trigger a catchup sync.
   Fixed by using LocalAccessor.walkFiles(pair) which applies the same
   filters and uses the same mtime source (SAF cursor) as SyncEngine.

2. catchupScan compared localModifiedAt.toEpochMilli() vs File.lastModified()
   (millisecond precision) while SyncEngine uses second precision. Every file
   appeared "modified" after a successful sync. Fixed by using epochSecond.

3. syncDecide() treated !localExists && remoteExists && known==null as
   "user deleted local copy → delete remote" even on files that were never
   synced. Fixed to treat unknown remote files as new (download them), which
   is safe because a genuinely-deleted file will always have a known state
   record from the previous sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 15:13:43 +00:00
amir 4b20697bb1 v1.0.32: fix manual sync loop via WorkManager tag monitor
Root cause: manual sync triggered from the UI had no cooldown set in
FileWatchService, so file writes during any manual sync fired FileObserver
→ debounce → another sync → loop.

Fix: startSyncMonitor() subscribes to getWorkInfosByTagFlow("sync_$pairId")
and watches ALL sync work for each pair — manual, catchup, onchange — via
the tag that SyncWorker.buildOneTimeRequest() always adds.
  - When any sync is RUNNING or ENQUEUED: cooldown extended to now+120s
  - When sync transitions from running to finished: 60s settle cooldown
  - Monitor job stored in syncMonitorJobs map and cancelled in clearWatchers()

This means no matter what triggers a sync, FileObserver events from the
resulting file writes are always suppressed until the folder settles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:11:58 +00:00
amir 66d28761a8 v1.0.31: fix remaining sync loop triggers + icon redesign
Three additional fixes found via live device logs:

1. Startup race window: FileObserver fires immediately after
   startWatching() before catchupScan coroutine runs, starting a 5s
   debounce with cooldown=0. Fixed by setting a 15s startup cooldown
   in watchPath() BEFORE calling watchDirRecursive.

2. Stale debounce bypass: debounce job started with cooldown=0 fires
   5s later even after catchupScan has already set cooldown and started
   a catchup sync. Fixed by re-checking cooldown after the 5s delay
   and aborting if already active.

3. Debounce not cancelled by catchupScan: if a debounce was queued
   before catchupScan ran, catchupScan would enqueue a catchup sync
   AND the old debounce would fire 5s later enqueuing a second sync.
   Fixed by cancelling pending debounce in catchupScan before enqueue.

Icon: four thick arcs (blue/red/green/orange) in a 4-way pinwheel
with over/under ordering. White sync-arrow circle at center.
Pure black background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:06:19 +00:00
amir ec478531da v1.0.30: fix sync loop root causes + icon redesign
Three root causes found via live logcat on device:

1. concurrent refresh() race: onStartCommand received twice causes two
   refresh() coroutines to run in parallel, doubling FileObserver and
   catchupScan registrations. Fixed with Mutex.withLock on refresh().

2. catchupScan no cooldown: catchup syncs write files but never set
   syncCooldownUntil, so every written file immediately re-triggers
   onChangeDetected. Fixed by setting cooldown before enqueue and
   watching work completion same as onChangeDetected does.

3. CancellationException caught silently: exception handler
   catch(_: Exception) was catching CancellationException and resetting
   cooldown to 0L, re-opening the loop. Fixed by rethrowing
   CancellationException and setting 60s cooldown on other errors.

Icon: interlocked rings (blue/red/green/orange) with sync arrow at
center, pure black background — matches reference image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:48:18 +00:00
amir 5ade80a334 v1.0.29: fix sync loop, stale-state auto-heal, icon redesign
- SyncEngine: self-healing stale folder state detection (isRetry) wipes
  orphaned SyncFileStateEntity records when localPath changes without a
  pair re-save — prevents repeated DELETE_REMOTE on 32 old files
- SyncEngine: second-precision mtime comparison (/ 1000 / .epochSecond)
  eliminates phantom localChanged=true from FAT32/WebDAV precision mismatch
- FileWatchService: syncCooldownUntil map suppresses FileObserver events
  for 120s after sync starts and 60s after it finishes, breaking the
  download→FileObserver→sync→download feedback loop
- Icon: three bold teardrop shapes (teal/red/amber) rotated 0/120/240°
  on dark charcoal background with white cloud at intersection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:51:59 +00:00
amir 34fb06a673 v1.0.28: fix sync rewrite/delete loop, Avast-inspired icon
Sync loop root-cause fixes (three independent bugs):

1. Folder change clears stale file states (AddPairViewModel): when
   localPath or remotePath changes on an existing pair, all
   SyncFileStateEntity records are wiped. Previously those stale records
   caused every sync to attempt DELETE_REMOTE on the old folder's files
   and to treat all new-folder files as changed — causing both the
   "deleting 32 files" loop and rewrites on every run.

2. Download stores null localModifiedAt (SyncEngine): SAF document
   cursors can return a stale mtime immediately after a write. Storing
   null forces the SKIP reconciliation pass on the next sync to read
   the actual walkFiles cursor value, breaking the download->changed->
   download loop caused by mtime inconsistency.

3. Second-precision mtime comparison (syncDecide): WebDAV RFC-1123 has
   1-second precision; FAT32 has 2-second precision. Comparing at
   millisecond level caused phantom "changed" detections after syncing
   to/from these systems. Now uses epochSecond for both local and remote.

Icon: three bold teal/red/yellow teardrop streaks (Avast palette) flying
into a white cloud centre, on dark charcoal background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 04:18:13 +00:00
amir dc2a0b2c68 v1.0.27: knot-inspired icon, fix media-not-found on photo open
Icon: two thick tube-style arcs with 3D glossy highlights.
  Arc 1 (left side): coral #E8665A to orange #E8A040
  Arc 2 (right side): steel blue #4A7FD4 to deep purple #7B5EA7
  Arrowheads: orange and purple. Background: dark purple-black.
  Inspired by the braided knot color palette.

Fix "media not found" when opening photos:
  - Intent now sets ClipData alongside FLAG_GRANT_READ_URI_PERMISSION
    so the permission correctly propagates through the system chooser
    to whichever app the user picks.
  - openFile() and downloadToCache() both call MediaScannerConnection
    so newly synced/downloaded files appear in gallery MediaStore index
    before the viewer launches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 03:08:44 +00:00
amir 742f634084 v1.0.26: fix multi-selection reactivity, redesign icon, security review
Fix multi-selection: selectedKeys exposed as StateFlow, collected in
FilesScreen so checkboxes and highlights update correctly on every tap.
fileKey() made public so UI can check membership without ViewModel calls.

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 02:22:43 +00:00
70 changed files with 3224 additions and 715 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
+40
View File
@@ -0,0 +1,40 @@
SyncFlow Source-Available License
Copyright (c) 2026 Amir Khodak. All rights reserved.
This is NOT an OSI-approved open-source license. The source code is made
publicly viewable ("source-available"), but the rights granted are limited as
described below. Where this license is silent, all rights are reserved.
1. DEFINITIONS
"Software" means the SyncFlow source code, assets, and documentation in this
repository. "You" means anyone other than the copyright holder.
2. WHAT YOU MAY DO
a. View, read, and study the Software.
b. Clone or fork the repository for your own private, personal,
non-commercial use and experimentation.
c. Build the Software from source and run it on devices you personally own.
d. Submit contributions (pull requests) back to this repository; by doing so
you license your contribution to the copyright holder under these terms.
3. WHAT YOU MAY NOT DO (without the copyright holder's prior written permission)
a. Redistribute, publish, or make available the Software or any derivative
work — in source or binary/APK form — to any third party or app store
(including but not limited to Google Play, F-Droid, Amazon Appstore,
Gitea/GitHub releases, or any website).
b. Use the Software, in whole or in part, for any commercial purpose.
c. Sell, sublicense, rent, or offer the Software as a service.
d. Use the names, app identity ("SyncFlow"), package identifier
("com.syncflow"), logos, or signing keys of the original work.
e. Remove or alter this license or the copyright notice.
4. RESERVED RIGHTS
All publishing and distribution rights are reserved exclusively to the
copyright holder. Only the copyright holder may publish official builds.
5. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
DAMAGES, OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
To request permission for anything in section 3, contact the copyright holder.
+11
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.
@@ -36,3 +40,10 @@ Native Android file sync app — sync any folder to WebDAV, SFTP, Nextcloud, own
- Android 8.0+ (API 26)
- Storage permission (or SAF picker) for local folder access
## License
SyncFlow is **source-available, not open-source** — see [LICENSE](LICENSE).
You may read, study, and fork it for personal, non-commercial use, but
**redistributing or publishing the app (source or APK) is not permitted**.
Only the copyright holder publishes official, signed builds.
+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,465 @@
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.CloudProvider
import com.syncflow.data.providers.nextcloud.NextcloudProvider
import com.syncflow.domain.sync.LocalAccessor
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 = "",
includeExtensions: String = "",
excludePatterns: String = "",
skipHidden: Boolean = false,
minKb: Long = 0L,
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 = includeExtensions, excludeExtensions = excludeExtensions,
skipHiddenFiles = skipHidden, minFileSizeKb = minKb, 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"))
}
// ── 13b. Special & non-ASCII filenames upload (WebDAV URL/header encoding) ─
@Test fun specialAndNonAsciiNames_upload() = runBlocking {
val (pair, local, remote) = newPair("special", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
write(local, "naïve café.txt", "accents") // non-ASCII (broke MOVE Destination header)
write(local, "a&b (1).txt", "ampersand") // & ( ) space
write(local, "日本語.txt", "cjk") // multibyte unicode
write(local, "my photo.txt", "space")
val r = sync(pair)
assertEquals("all special-name files must upload", 4, r.uploaded)
assertEquals(0, r.failedFiles)
val names = remoteNames(remote)
assertTrue("naïve café.txt" in names)
assertTrue("a&b (1).txt" in names)
assertTrue("日本語.txt" in names)
assertTrue("my photo.txt" in names)
}
// ── 13c. Volume: 100+ files (incl. subfolders & non-ASCII) upload, 0 fails ─
@Test fun volume_hundredFiles_allUploadNoFailures() = runBlocking {
val (pair, local, remote) = newPair("vol100", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
repeat(100) { i -> write(local, "f_%03d.txt".format(i), "payload $i ".repeat(30)) }
write(local, "sub/nested_a.txt", "n1")
write(local, "sub/deep/nested_b.txt", "n2")
write(local, "naïve café.txt", "accented")
val r = sync(pair)
assertEquals("no file may fail under volume", 0, r.failedFiles)
assertEquals("all 103 files upload", 103, r.uploaded)
assertEquals("100 flat files present on cloud", 100, remoteNames(remote).count { it.startsWith("f_") })
assertTrue("non-ASCII name present too", "naïve café.txt" in remoteNames(remote))
// re-sync is a clean no-op (no phantom re-uploads / loops at volume)
val r2 = sync(pair)
assertEquals(0, r2.uploaded); assertEquals(0, r2.deleted); assertEquals(0, r2.failedFiles)
}
// ── 14. 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"))
}
// ══ EDGE CASES & STRESS ═══════════════════════════════════════════════════
private fun writeBytes(dir: File, rel: String, bytes: ByteArray) =
File(dir, rel).apply { parentFile?.mkdirs() }.writeBytes(bytes)
// 15. Empty (0-byte) file uploads correctly
@Test fun emptyFile_uploads() = runBlocking {
val (pair, local, remote) = newPair("empty", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
write(local, "zero.txt", "")
val r = sync(pair)
assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
assertEquals(0L, provider.listFiles(remote).getOrThrow().first { it.name == "zero.txt" }.sizeBytes)
}
// 16. Large file (20 MB) uploads + downloads byte-intact (OOM / streaming guard)
@Test fun largeFile_intactRoundTrip() = runBlocking {
val (pair, local, remote) = newPair("large", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
val size = 20 * 1024 * 1024
val bytes = ByteArray(size).also { java.util.Random(42).nextBytes(it) }
writeBytes(local, "big.bin", bytes)
val r = sync(pair)
assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
assertEquals(size.toLong(), provider.listFiles(remote).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
val out = ByteArrayOutputStream(size); provider.downloadFile("$remote/big.bin", out).getOrThrow()
val dl = out.toByteArray()
assertEquals(size, dl.size)
assertArrayEquals(bytes.copyOfRange(0, 4096), dl.copyOfRange(0, 4096))
assertArrayEquals(bytes.copyOfRange(size - 4096, size), dl.copyOfRange(size - 4096, size))
}
// 17. Deeply nested path (8 levels) is created + uploaded
@Test fun deepNesting_uploads() = runBlocking {
val (pair, local, remote) = newPair("deep", SyncDirection.UPLOAD_ONLY, recursive = true)
write(local, "a/b/c/d/e/f/g/deep.txt", "deep")
assertEquals(0, sync(pair).failedFiles)
assertTrue("deep.txt" in remoteNames("$remote/a/b/c/d/e/f/g"))
}
// 18. Unicode FOLDER names (not just files) are created + encoded
@Test fun unicodeFolderNames_upload() = runBlocking {
val (pair, local, remote) = newPair("ufolder", SyncDirection.UPLOAD_ONLY, recursive = true)
write(local, "Фото/café/x.txt", "u")
assertEquals(0, sync(pair).failedFiles)
assertTrue("x.txt" in remoteNames("$remote/Фото/café"))
}
// 19. Very long filename (200 chars)
@Test fun veryLongFilename_uploads() = runBlocking {
val (pair, local, remote) = newPair("longname", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
val name = "L".repeat(200) + ".txt"
write(local, name, "x")
assertEquals(0, sync(pair).failedFiles)
assertTrue(name in remoteNames(remote))
}
// 20. File with no extension
@Test fun noExtensionFile_uploads() = runBlocking {
val (pair, local, remote) = newPair("noext", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
write(local, "README", "x")
assertEquals(1, sync(pair).uploaded)
assertTrue("README" in remoteNames(remote))
}
// 21. Idempotency / loop guard — repeated syncs do NOT re-upload anything
@Test fun idempotent_repeatedSyncsNoPhantomUploads() = runBlocking {
val (pair, local, remote) = newPair("idem", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
repeat(10) { i -> write(local, "x_$i.txt", "v$i") }
assertEquals(10, sync(pair).uploaded)
repeat(4) {
val r = sync(pair)
assertEquals("sync must be idempotent (no re-upload loop)", 0, r.uploaded)
assertEquals(0, r.deleted); assertEquals(0, r.failedFiles)
}
}
// 22. Bulk update — modifying many files re-uploads exactly those
@Test fun bulkUpdate_reuploadsChanged() = runBlocking {
val (pair, local, remote) = newPair("bulkupd", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
repeat(10) { i -> write(local, "u_$i.txt", "v1") }; sync(pair)
Thread.sleep(1100)
repeat(10) { i -> write(local, "u_$i.txt", "v2-updated-content") }
assertEquals(10, sync(pair).uploaded)
assertEquals("v2-updated-content", remoteText("$remote/u_0.txt"))
}
// 23. Bulk delete (MIRROR two-way) propagates all deletions
@Test fun mirror_bulkDeletePropagates() = runBlocking {
val (pair, local, remote) = newPair("bulkdel", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
repeat(10) { i -> write(local, "d_$i.txt", "x") }; sync(pair)
repeat(10) { i -> File(local, "d_$i.txt").delete() }
assertEquals(10, sync(pair).deleted)
assertEquals(0, remoteNames(remote).count { it.startsWith("d_") })
}
// 24. Bulk download (download-only) pulls all remote files
@Test fun downloadOnly_bulkPull() = runBlocking {
val (pair, local, remote) = newPair("bulkdl", SyncDirection.DOWNLOAD_ONLY)
repeat(10) { i -> putRemote(remote, "r_$i.txt", "cloud$i") }
assertEquals(10, sync(pair).downloaded)
assertEquals(10, local.listFiles()!!.count { it.name.startsWith("r_") })
}
// 25. KEEP_BOTH conflict strategy records a conflict (no silent clobber)
@Test fun twoWay_keepBoth_recordsConflict() = runBlocking {
val (pair, local, remote) = newPair("keepboth", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_BOTH)
write(local, "c.txt", "base"); sync(pair); sync(pair) // baseline + reconcile
Thread.sleep(1100)
write(local, "c.txt", "LOCAL"); putRemote(remote, "c.txt", "REMOTE")
assertEquals(1, sync(pair).conflicts)
assertEquals("LOCAL", File(local, "c.txt").readText())
assertEquals("REMOTE", remoteText("$remote/c.txt"))
}
// 26. Min-size filter skips tiny files
@Test fun filters_minSizeSkipsTiny() = runBlocking {
val (pair, local, remote) = newPair("minsize", SyncDirection.UPLOAD_ONLY, minKb = 1)
write(local, "tiny.txt", "x") // < 1 KB
write(local, "big.txt", "A".repeat(2048)) // ~2 KB
sync(pair)
val n = remoteNames(remote)
assertFalse("tiny.txt" in n); assertTrue("big.txt" in n)
}
// 27. Include-extension filter uploads only matching files
@Test fun filters_includeExtensionOnly() = runBlocking {
val (pair, local, remote) = newPair("incl", SyncDirection.UPLOAD_ONLY, includeExtensions = "jpg")
write(local, "keep.jpg", "x"); write(local, "skip.txt", "y")
sync(pair)
val n = remoteNames(remote)
assertTrue("keep.jpg" in n); assertFalse("skip.txt" in n)
}
// 28. Whole-folder wipe locally (MIRROR) removes all remote copies
@Test fun mirror_emptyLocalWipesRemote() = runBlocking {
val (pair, local, remote) = newPair("wipe", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
repeat(5) { i -> write(local, "w_$i.txt", "x") }; sync(pair)
local.listFiles()!!.forEach { it.delete() }
assertEquals(5, sync(pair).deleted)
assertEquals(0, remoteNames(remote).count { it.startsWith("w_") })
}
// ══ INTERRUPTION / ATOMICITY ══════════════════════════════════════════════
// 29. A write that fails mid-stream must leave the existing file intact (no truncation)
@Test fun atomicWrite_failedWriteLeavesOriginalIntact() = runBlocking {
val dir = File(ctx.cacheDir, "atomic_${System.currentTimeMillis()}").apply { mkdirs() }
localDirs += dir
File(dir, "f.txt").writeText("ORIGINAL-GOOD-CONTENT")
val accessor = LocalAccessor.JavaFile(dir)
val outcome = runCatching {
accessor.writeAtomically("f.txt") { os ->
os.write("PARTIAL-GARBAGE".toByteArray()); os.flush()
throw java.io.IOException("simulated network drop mid-download")
}
}
assertTrue("the failed write must propagate", outcome.isFailure)
assertEquals("original must be untouched after a failed write", "ORIGINAL-GOOD-CONTENT", File(dir, "f.txt").readText())
assertTrue("no leftover .sfpart temp", dir.listFiles()!!.none { it.name.endsWith(".sfpart") })
}
// 30. A sync interrupted partway (provider fails after N files) loses nothing and the
// next sync completes the rest with all content intact.
@Test fun interruptedSync_resumesCleanlyNoCorruption() = runBlocking {
val (pair, local, remote) = newPair("interrupt", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
repeat(10) { i -> write(local, "i_$i.txt", "content-$i-".repeat(50)) }
// Provider that simulates a connection drop after 4 successful uploads.
val flaky = object : CloudProvider by provider {
private val n = java.util.concurrent.atomic.AtomicInteger(0)
override suspend fun uploadFile(localStream: java.io.InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> =
if (n.incrementAndGet() > 4) Result.failure(java.io.IOException("connection dropped"))
else provider.uploadFile(localStream, remotePath, sizeBytes, onProgress)
}
val r1 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), flaky)
assertTrue("some files should fail on the dropped sync", r1.failedFiles > 0)
// Re-sync with the healthy provider completes the rest.
val r2 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), provider)
assertEquals("re-sync must complete with no failures", 0, r2.failedFiles)
assertEquals("all 10 files end up on the cloud", 10, remoteNames(remote).count { it.startsWith("i_") })
assertEquals("content intact (no truncation)", "content-0-".repeat(50), remoteText("$remote/i_0.txt"))
}
}
@@ -0,0 +1,141 @@
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.assertArrayEquals
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
}
}
@Test
fun chunkedUpload_assemblesLargeFileByteExact() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
// Tiny chunk size exercises multi-chunk assembly without needing a multi-GB file.
val account = CloudAccount(
id = 1, displayName = "IT", email = user, providerType = ProviderType.NEXTCLOUD,
credentialJson = """{"username":"$user","password":"$pass"}""", serverUrl = url, port = null,
)
val p = NextcloudProvider(account, chunkSize = 1L * 1024 * 1024) // 1 MB chunks
val dir = "SyncFlowChunk_${System.currentTimeMillis()}"
try {
p.createDirectory(dir).getOrThrow()
val payload = ByteArray(5 * 1024 * 1024 + 7).also { java.util.Random(7).nextBytes(it) } // ~5 MB -> 6 chunks
val up = p.uploadFile(ByteArrayInputStream(payload), "$dir/big.bin", payload.size.toLong())
assertTrue("chunked upload failed: ${up.exceptionOrNull()}", up.isSuccess)
assertEquals(payload.size.toLong(), p.listFiles(dir).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
val out = ByteArrayOutputStream(); p.downloadFile("$dir/big.bin", out).getOrThrow()
assertArrayEquals("chunk-assembled content must equal the original bytes", payload, out.toByteArray())
} finally {
runCatching { p.deleteFile(dir) }
}
}
}
@@ -0,0 +1,47 @@
package com.syncflow
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.NetworkType
import com.syncflow.worker.SyncWorker
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit
/**
* Scheduling/constraint mapping for WorkManager-backed syncs. Verifies the request builders
* translate pair settings into the right constraints (Wi-Fi-only, charging-only), interval, input
* data, and tags — the deterministic part of scheduling (without waiting for the OS to fire it).
*/
@RunWith(AndroidJUnit4::class)
class SchedulingTest {
@Test fun periodic_wifiOnly_chargingOnly_intervalAndData() {
val req = SyncWorker.buildPeriodicRequest(pairId = 42L, intervalMinutes = 30, wifiOnly = true, chargingOnly = true)
val ws = req.workSpec
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
assertTrue("charging constraint", ws.constraints.requiresCharging())
assertEquals(TimeUnit.MINUTES.toMillis(30), ws.intervalDuration)
assertEquals(42L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
assertTrue("sync_42" in req.tags)
}
@Test fun periodic_anyNetwork_noCharging() {
val req = SyncWorker.buildPeriodicRequest(pairId = 7L, intervalMinutes = 60, wifiOnly = false, chargingOnly = false)
val c = req.workSpec.constraints
assertEquals(NetworkType.CONNECTED, c.requiredNetworkType)
assertFalse(c.requiresCharging())
}
@Test fun oneTime_constraintsDataAndTag() {
val req = SyncWorker.buildOneTimeRequest(pairId = 9L, wifiOnly = true, chargingOnly = false, silent = true)
val ws = req.workSpec
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
assertFalse(ws.constraints.requiresCharging())
assertEquals(9L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
assertTrue(ws.input.getBoolean(SyncWorker.KEY_SILENT, false))
assertTrue("sync_9" in req.tags)
}
}
@@ -0,0 +1,69 @@
package com.syncflow
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.syncflow.data.providers.sftp.SftpProvider
import com.syncflow.data.security.CredentialStore
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.ProviderType
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
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
/**
* Live SFTP test (the other major provider code path: sshj). Runs against a throwaway SFTP
* server. Skips unless -e sftpHost/sftpPort/sftpUser/sftpPass are provided.
*/
@RunWith(AndroidJUnit4::class)
class SftpProviderTest {
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
private val args = InstrumentationRegistry.getArguments()
private fun provider() = SftpProvider(
CloudAccount(
id = 1, displayName = "sftp", email = null, providerType = ProviderType.SFTP,
credentialJson = """{"username":"${args.getString("sftpUser")}","password":"${args.getString("sftpPass")}"}""",
serverUrl = args.getString("sftpHost"), port = args.getString("sftpPort")?.toInt(),
),
CredentialStore(ctx),
)
@Test fun sftpFullRoundTrip() = runBlocking {
assumeTrue("sftp* args required", args.getString("sftpHost") != null)
val p = provider()
val dir = "upload/it_${System.currentTimeMillis()}"
// Skip (don't fail) if the endpoint isn't reachable from the test runner's network —
// e.g. a phone on an isolated VLAN that only reaches services via the reverse proxy.
assumeTrue("SFTP endpoint not reachable from this device's network", p.testConnection().isSuccess)
assertTrue("mkdir", p.createDirectory(dir).isSuccess)
// upload (atomic temp + rename), list, download
val body = "sftp round-trip ✓".toByteArray()
assertTrue("upload", p.uploadFile(ByteArrayInputStream(body), "$dir/f.txt", body.size.toLong()).isSuccess)
assertTrue("f.txt" in p.listFiles(dir).getOrThrow().map { it.name })
val out = ByteArrayOutputStream(); p.downloadFile("$dir/f.txt", out).getOrThrow()
assertEquals("sftp round-trip ✓", out.toString("UTF-8"))
// atomic overwrite (temp + rename over existing)
val v2 = "updated-content".toByteArray()
assertTrue(p.uploadFile(ByteArrayInputStream(v2), "$dir/f.txt", v2.size.toLong()).isSuccess)
val out2 = ByteArrayOutputStream(); p.downloadFile("$dir/f.txt", out2).getOrThrow()
assertEquals("updated-content", out2.toString("UTF-8"))
// special / non-ASCII name (SFTP handles UTF-8 natively, no URL encoding)
val special = "café & rapport (1).txt"
assertTrue(p.uploadFile(ByteArrayInputStream("x".toByteArray()), "$dir/$special", 1).isSuccess)
assertTrue(special in p.listFiles(dir).getOrThrow().map { it.name })
// delete
assertTrue(p.deleteFile("$dir/f.txt").isSuccess)
assertTrue("f.txt" !in p.listFiles(dir).getOrThrow().map { it.name })
}
}
@@ -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(
@@ -2,13 +2,111 @@ package com.syncflow.data.providers.nextcloud
import com.syncflow.data.providers.webdav.WebDavProvider
import com.syncflow.domain.model.CloudAccount
import com.syncflow.domain.model.RemoteFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.IOException
import java.io.InputStream
/**
* Nextcloud WebDAV provider. Endpoint is /remote.php/dav/files/<username>/.
*
* Large files are uploaded via Nextcloud's chunked-upload API (/remote.php/dav/uploads/<user>/)
* so they bypass per-request size caps (Apache LimitRequestBody, PHP post_max_size, proxy body
* limits) that otherwise 413 a single multi-GB PUT. The assembly MOVE is the atomic commit, so
* the destination only appears once every chunk is in — no temp-file dance needed for this path.
*
* @param chunkSize bytes per chunk; files at or below this use the parent's single-PUT path.
*/
class NextcloudProvider(
account: CloudAccount,
private val chunkSize: Long = 100L * 1024 * 1024,
) : WebDavProvider(account) {
class NextcloudProvider(account: CloudAccount) : WebDavProvider(account) {
// Nextcloud WebDAV endpoint is /remote.php/dav/files/<username>/
override val baseUrl: String
get() {
val server = account.serverUrl?.trimEnd('/') ?: ""
val email = account.email ?: "user"
return "$server/remote.php/dav/files/$email"
}
private val uploadsBase: String
get() {
val server = account.serverUrl?.trimEnd('/') ?: ""
val email = account.email ?: "user"
return "$server/remote.php/dav/uploads/$email"
}
override suspend fun uploadFile(
localStream: InputStream,
remotePath: String,
sizeBytes: Long,
onProgress: (Long) -> Unit,
): Result<RemoteFile> {
if (sizeBytes <= chunkSize) {
return super.uploadFile(localStream, remotePath, sizeBytes, onProgress)
}
return runCatching {
withContext(Dispatchers.IO) {
val uploadId = "syncflow-${System.currentTimeMillis()}-${(0..999_999).random()}"
val dir = "$uploadsBase/$uploadId"
mkcol(dir)
try {
var index = 1
var sent = 0L
while (sent < sizeBytes) {
val len = minOf(chunkSize, sizeBytes - sent)
putChunk("$dir/%05d".format(index), localStream, len)
sent += len
index++
onProgress(sent)
}
// Assemble: MOVE the virtual .file onto the destination (atomic commit).
val move = Request.Builder().url("$dir/.file")
.method("MOVE", null)
.header("Destination", url(remotePath))
.header("Overwrite", "T")
.header("OC-Total-Length", sizeBytes.toString())
.build()
client.newCall(move).execute().use { resp ->
if (!resp.isSuccessful) throw IOException("Chunk assembly MOVE HTTP ${resp.code}")
}
} catch (e: Throwable) {
runCatching { client.newCall(Request.Builder().url(dir).delete().build()).execute().close() }
throw e
}
getFileMetadata(remotePath).getOrThrow()
}
}
}
private fun mkcol(url: String) {
client.newCall(Request.Builder().url(url).method("MKCOL", null).build()).execute().use {
if (!it.isSuccessful && it.code != 405) throw IOException("MKCOL upload session HTTP ${it.code}")
}
}
private fun putChunk(url: String, stream: InputStream, len: Long) {
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun contentLength() = len
override fun writeTo(sink: BufferedSink) {
var remaining = len
val buf = ByteArray(64 * 1024)
while (remaining > 0) {
val n = stream.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
if (n < 0) break
sink.write(buf, 0, n)
remaining -= n
}
}
}
client.newCall(Request.Builder().url(url).put(body).build()).execute().use {
if (!it.isSuccessful) throw IOException("Chunk PUT HTTP ${it.code}")
}
}
}
@@ -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()
@@ -159,7 +181,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
}
}
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
// Build a properly percent-encoded URL. addPathSegments encodes each segment (spaces,
// ampersands, and — critically — non-ASCII like "café"), which keeps OkHttp from rejecting
// non-ASCII in the WebDAV MOVE "Destination" header and avoids malformed request URLs.
protected fun url(path: String): String {
val base = baseUrl.toHttpUrlOrNull() ?: return "$baseUrl/${path.trimStart('/')}"
return base.newBuilder().addPathSegments(path.trimStart('/')).build().toString()
}
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>()
@@ -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)
}
@@ -62,16 +68,36 @@ class SyncEngine @Inject constructor(
else
LocalAccessor.JavaFile(File(localPath))
private suspend fun performSync(pair: SyncPair, provider: CloudProvider): SyncResult {
private suspend fun performSync(
pair: SyncPair,
provider: CloudProvider,
isRetry: Boolean = false,
onProgress: (suspend (Int, Int, Int, Long) -> Unit)? = null,
): SyncResult {
val accessor = makeAccessor(pair.localPath)
val knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
// Self-healing: if every known-state path is absent from the current local scan but
// the local folder does have files, the localPath was changed without clearing state.
// The stale records would cause every old file to look like "DELETE_REMOTE" and every
// new file to re-upload indefinitely. Wipe and retry once as a fresh initial sync.
if (!isRetry && knownStates.isNotEmpty() && localFiles.isNotEmpty() &&
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, 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(
@@ -85,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]
@@ -92,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 ->
@@ -105,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 ->
@@ -124,20 +164,45 @@ 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), remoteAfterTransfer = remote))
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
remoteAfterTransfer = remote,
storeLocalMtime = false))
}
SyncDecision.DELETE_LOCAL -> {
accessor.delete(rel)
val deleted = accessor.delete(rel)
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 -> {
provider.deleteFile("${pair.remotePath}/$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 -> {
@@ -203,10 +268,13 @@ class SyncEngine @Inject constructor(
rel: String,
local: LocalFileInfo?,
remoteAfterTransfer: RemoteFile?,
storeLocalMtime: Boolean = true,
) = SyncFileStateEntity(
syncPairId = pairId,
relativePath = rel,
localModifiedAt = local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) },
// When storeLocalMtime=false, leave localModifiedAt null so the SKIP reconciliation
// pass on the next sync reads it from the walkFiles cursor (avoids SAF stale-mtime loops).
localModifiedAt = if (storeLocalMtime) local?.lastModifiedMs?.let { Instant.ofEpochMilli(it) } else null,
localSizeBytes = local?.sizeBytes ?: 0L,
localHash = null,
remoteModifiedAt = remoteAfterTransfer?.modifiedAt,
@@ -236,12 +304,16 @@ internal fun syncDecide(
// Treat null known timestamps as "not yet recorded" — don't treat as changed.
// The SKIP reconciliation pass will fill them in on the next sync.
// Use second-precision for both sides: FAT32 has 2-second mtime resolution, WebDAV
// RFC-1123 has 1-second resolution, so millisecond comparison causes phantom "changed"
// detections and rewrite loops after a fresh download/upload.
val localChanged = known == null ||
(localExists && known.localModifiedAt != null &&
local!!.lastModifiedMs != known.localModifiedAt.toEpochMilli())
local!!.lastModifiedMs / 1000 != known.localModifiedAt.epochSecond)
val remoteChanged = known == null ||
(remoteExists && known.remoteModifiedAt != null &&
(remote!!.etag != known.remoteEtag || remote.modifiedAt != known.remoteModifiedAt))
(remote!!.etag != known.remoteEtag ||
remote.modifiedAt.epochSecond != known.remoteModifiedAt.epochSecond))
return when {
!localExists && !remoteExists -> SyncDecision.SKIP
@@ -259,21 +331,15 @@ internal fun syncDecide(
}
!localExists && remoteExists -> when {
known == null -> if (!hasPriorSyncState) {
// Initial sync: no history at all — remote files are new, download them.
known == null -> {
// No state record: could be a new remote file OR a file whose state was lost.
// Downloading is always safer than deleting — if the user deleted the local
// copy intentionally, the state record will still exist (known != null) and
// the else-branch below correctly deletes the remote copy.
when (direction) {
SyncDirection.DOWNLOAD_ONLY, SyncDirection.TWO_WAY -> SyncDecision.DOWNLOAD
else -> SyncDecision.SKIP
}
} else {
// Pair has been synced before but this file has no state record
// (e.g. uploaded before state-tracking was fixed). Treat the same
// as a known remote-deletion: apply mirror/keep behavior.
when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
direction == SyncDirection.UPLOAD_ONLY || direction == SyncDirection.TWO_WAY -> SyncDecision.DELETE_REMOTE
else -> SyncDecision.SKIP
}
}
else -> when {
deleteBehavior == DeleteBehavior.KEEP -> SyncDecision.SKIP
@@ -309,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)
}
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.CloudAccountDao
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
@@ -27,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,
@@ -55,9 +59,20 @@ 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,
private val fileStateDao: SyncFileStateDao,
private val accountDao: CloudAccountDao,
@ApplicationContext private val context: Context,
savedState: SavedStateHandle,
@@ -91,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,
@@ -117,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 {
@@ -148,7 +176,20 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
if (editPairId == null) syncPairDao.insert(entity) else syncPairDao.update(entity)
if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
syncPairDao.update(entity)
// If local or remote folder changed, old file-state records no longer
// correspond to any real path — wipe them so the next sync starts fresh
// instead of trying to delete/re-upload stale paths.
if (existing != null &&
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
) {
fileStateDao.deleteForPair(editPairId)
}
}
}
.onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
@@ -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
@@ -1,28 +1,45 @@
package com.syncflow.ui.files
import android.content.ClipData
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.activity.compose.BackHandler
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.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.platform.LocalContext
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.unit.dp
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.ui.browser.RemoteBrowserViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.withContext
import java.io.File
private val STORAGE_ROOT = File("/storage/emulated/0")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -30,387 +47,631 @@ fun FilesScreen(
modifier: Modifier = Modifier,
vm: FilesViewModel = hiltViewModel(),
) {
val pairs by vm.pairs.collectAsState()
val selectedPair by vm.selectedPair.collectAsState()
val files by vm.files.collectAsState()
val isDownloading by vm.isDownloading.collectAsState()
var activeTab by remember { mutableStateOf(0) }
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val accounts by vm.accounts.collectAsState()
var selectedAccountId by remember { mutableStateOf(-1L) }
LaunchedEffect(accounts) {
if (selectedAccountId == -1L && accounts.isNotEmpty()) selectedAccountId = accounts.first().id
}
val cloudLabel = accounts.find { it.id == selectedAccountId }?.displayName
?: accounts.firstOrNull()?.displayName
?: "Cloud"
LaunchedEffect(Unit) {
vm.fileAction.collect { action ->
when (action) {
is FileAction.Open -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType())
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Open with").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot open file: ${e.message}")
}
is FileAction.Open -> try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", action.file)
context.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, action.file.name.mimeType())
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
} catch (e: Exception) {
scope.launch { snackbarHostState.showSnackbar("Cannot open: ${e.message}") }
}
is FileAction.Share -> {
try {
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", action.file
)
val intent = Intent(Intent.ACTION_SEND).apply {
is FileAction.Share -> try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", action.file)
context.startActivity(
Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
type = action.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(
Intent.createChooser(intent, "Share via").apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Cannot share file: ${e.message}")
}
}, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
)
} catch (e: Exception) {
scope.launch { snackbarHostState.showSnackbar("Cannot share: ${e.message}") }
}
is FileAction.Error -> scope.launch {
snackbarHostState.showSnackbar(action.message)
is FileAction.ShareMultiple -> try {
val uris = action.files.map { FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", it) }
context.startActivity(
Intent.createChooser(Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "*/*"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}, "Share files").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
)
} catch (e: Exception) {
scope.launch { snackbarHostState.showSnackbar("Cannot share: ${e.message}") }
}
is FileAction.Error -> scope.launch { snackbarHostState.showSnackbar(action.message) }
}
}
}
Box(modifier = modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
if (pairs.size > 1) {
ScrollableTabRow(
selectedTabIndex = pairs.indexOfFirst { it.id == selectedPair?.id }.coerceAtLeast(0),
edgePadding = 16.dp,
containerColor = MaterialTheme.colorScheme.surface,
divider = {},
// ── Phone / Cloud toggle ──────────────────────────────────────────
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
SegmentedButton(
selected = activeTab == 0,
onClick = { activeTab = 0 },
shape = SegmentedButtonDefaults.itemShape(0, 2),
icon = { SegmentedButtonDefaults.Icon(active = activeTab == 0, activeContent = { Icon(Icons.Default.PhoneAndroid, null, Modifier.size(16.dp)) }) },
) {
pairs.forEach { pair ->
Tab(
selected = pair.id == selectedPair?.id,
onClick = { vm.selectPair(pair.id) },
text = { Text(pair.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
Text("Phone")
}
SegmentedButton(
selected = activeTab == 1,
onClick = { activeTab = 1 },
shape = SegmentedButtonDefaults.itemShape(1, 2),
icon = { SegmentedButtonDefaults.Icon(active = activeTab == 1, activeContent = { Icon(Icons.Default.Cloud, null, Modifier.size(16.dp)) }) },
) {
Text(cloudLabel, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
}
}
HorizontalDivider()
when (activeTab) {
0 -> LocalExplorer(modifier = Modifier.weight(1f))
1 -> CloudExplorer(
vm = vm,
selectedAccountId = selectedAccountId,
onAccountSelect = { selectedAccountId = it },
modifier = Modifier.weight(1f),
)
}
}
val isDownloading by vm.isDownloading.collectAsState()
if (isDownloading) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Opening file…", style = MaterialTheme.typography.bodySmall)
}
}
}
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
}
// ── Local file explorer ───────────────────────────────────────────────────────
private data class LocalEntry(val file: File, val isDir: Boolean, val childCount: Int = 0, val sizeBytes: Long = 0L)
@Composable
private fun LocalExplorer(modifier: Modifier = Modifier) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var currentPath by remember { mutableStateOf(STORAGE_ROOT) }
var pathStack by remember { mutableStateOf(listOf(STORAGE_ROOT)) }
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 snackbarHostState = remember { SnackbarHostState() }
fun loadDir(dir: File) {
isLoading = true
entries = emptyList()
scope.launch {
val result = withContext(Dispatchers.IO) {
(dir.listFiles() ?: emptyArray())
.filter { !it.name.startsWith(".") }
.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
.map { f ->
if (f.isDirectory) LocalEntry(f, true, f.listFiles()?.size ?: 0)
else LocalEntry(f, false, sizeBytes = f.length())
}
}
entries = result
isLoading = false
}
}
fun navigate(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
}
LaunchedEffect(Unit) { loadDir(currentPath) }
BackHandler(enabled = pathStack.size > 1) { navigateUp() }
val relParts = currentPath.absolutePath
.removePrefix(STORAGE_ROOT.absolutePath).trimStart('/').split('/').filter { it.isNotEmpty() }
LaunchedEffect(currentPath) {
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
searchQuery = ""; searchActive = false
}
val filtered = if (searchQuery.isBlank()) entries
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
// ── Breadcrumbs / search bar ──────────────────────────────────────
Surface(tonalElevation = 1.dp) {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = searchQuery, onValueChange = { searchQuery = it },
modifier = Modifier.weight(1f).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,
),
)
IconButton(onClick = { searchActive = false; searchQuery = "" }) {
Icon(Icons.Default.Close, null)
}
}
} else {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item {
BreadcrumbChip("📱 Storage", relParts.isEmpty()) {
pathStack = listOf(STORAGE_ROOT); currentPath = STORAGE_ROOT; loadDir(STORAGE_ROOT)
}
}
itemsIndexed(relParts) { idx, part ->
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
val partFile = File(STORAGE_ROOT, relParts.take(idx + 1).joinToString("/"))
BreadcrumbChip(part, idx == relParts.lastIndex) {
val i = pathStack.indexOfLast { it.absolutePath == partFile.absolutePath }
pathStack = if (i >= 0) pathStack.take(i + 1) else listOf(partFile)
currentPath = partFile; loadDir(partFile)
}
}
item {
Spacer(Modifier.width(4.dp))
IconButton(onClick = { searchActive = true }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Search, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
HorizontalDivider()
// ── Content ───────────────────────────────────────────────────────
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
filtered.isEmpty() -> Box(Modifier.fillMaxSize(), 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()) "Empty folder" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
HorizontalDivider()
}
when {
pairs.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No sync pairs yet",
subtitle = "Create a sync pair to browse its files",
)
files.isEmpty() -> FilesEmptyState(
icon = Icons.Default.FolderOpen,
title = "No synced files yet",
subtitle = "Run a sync to populate this view",
)
else -> {
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${files.size} file${if (files.size != 1) "s" else ""}",
style = MaterialTheme.typography.labelMedium,
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,
)
Text(
files.sumOf { it.localSizeBytes }.toDisplaySize(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
val grouped = files.groupBy { f ->
val idx = f.relativePath.indexOf('/')
if (idx < 0) "" else f.relativePath.substring(0, idx)
}
grouped.forEach { (dir, dirFiles) ->
if (dir.isNotEmpty()) {
item(key = "dir_$dir") {
Row(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Folder, contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(6.dp))
Text(
dir,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
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)) {
val shortcuts = listOf(
Triple("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
Triple("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
Triple("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
Triple("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
Triple("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
Triple("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
)
items(shortcuts.filter { File(it.third).isDirectory }) { (label, icon, path) ->
Surface(onClick = { navigate(File(path)) }, 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(icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
Spacer(Modifier.height(4.dp))
Text(label, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
}
}
}
}
items(dirFiles, key = { "${it.syncPairId}_${it.relativePath}" }) { file ->
FileRow(file = file, isInSubDir = dir.isNotEmpty(), vm = vm)
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f),
modifier = Modifier.padding(
start = if (dir.isNotEmpty()) 38.dp else 16.dp
),
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text("All files", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
}
item { Spacer(Modifier.height(80.dp)) }
}
items(filtered, key = { it.file.absolutePath }) { entry ->
LocalFileItem(
entry = entry,
onClick = {
if (entry.isDir) navigate(entry.file)
else {
try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", entry.file)
context.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, entry.file.name.mimeType())
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
})
} catch (e: Exception) {
scope.launch { snackbarHostState.showSnackbar("No app can open this file") }
}
}
},
onShare = {
try {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", entry.file)
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
type = entry.file.name.mimeType()
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) })
} catch (_: Exception) {}
},
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
if (isDownloading) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter))
}
}
// ── Cloud file explorer ───────────────────────────────────────────────────────
@Composable
private fun CloudExplorer(
vm: FilesViewModel,
selectedAccountId: Long,
onAccountSelect: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
val accounts by vm.accounts.collectAsState()
val cloudVm: RemoteBrowserViewModel = hiltViewModel(key = "cloud_explorer")
val state by cloudVm.state.collectAsState()
val breadcrumbState = rememberLazyListState()
val scope = rememberCoroutineScope()
var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
LaunchedEffect(selectedAccountId) {
if (selectedAccountId != -1L) cloudVm.init(selectedAccountId, "/")
}
LaunchedEffect(state.currentPath) {
val segs = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segs.size)) }
searchQuery = ""; searchActive = false
}
BackHandler(enabled = state.pathStack.size > 1) { cloudVm.navigateUp() }
if (accounts.isEmpty()) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
Spacer(Modifier.height(12.dp))
Text("No cloud accounts", style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Add an account in Settings", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
}
}
return
}
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
val filtered = if (searchQuery.isBlank()) state.entries
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
Column(modifier = modifier) {
// ── Account chips ─────────────────────────────────────────────────────
if (accounts.size > 1) {
LazyRow(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 4.dp,
modifier = Modifier.fillMaxWidth(),
items(accounts) { acct ->
FilterChip(
selected = acct.id == selectedAccountId,
onClick = { onAccountSelect(acct.id); cloudVm.init(acct.id, "/") },
label = { Text(acct.displayName, maxLines = 1) },
leadingIcon = { Icon(Icons.Default.Cloud, null, Modifier.size(14.dp)) },
)
}
}
HorizontalDivider()
}
// ── Breadcrumbs / search ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) {
if (searchActive) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
Text("Downloading for preview…", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = searchQuery, onValueChange = { searchQuery = it },
modifier = Modifier.weight(1f).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,
),
)
IconButton(onClick = { searchActive = false; searchQuery = "" }) { Icon(Icons.Default.Close, null) }
}
} else {
LazyRow(
state = breadcrumbState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
item { BreadcrumbChip("☁ Root", segments.isEmpty()) { cloudVm.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(seg, idx == segments.lastIndex) { cloudVm.navigateToBreadcrumb(segPath) }
}
item {
Spacer(Modifier.width(4.dp))
IconButton(onClick = { searchActive = true }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Search, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
HorizontalDivider()
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
// ── 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 = cloudVm::retry) {
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Retry")
}
}
filtered.isEmpty() -> Box(Modifier.fillMaxSize(), 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()) "Empty folder" else "No results for \"$searchQuery\"",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(filtered, key = { it.path }) { entry ->
CloudFileItem(
file = entry,
onClick = {
if (entry.isDirectory) cloudVm.navigateTo(entry.path)
else vm.openCloudFile(selectedAccountId, entry.path)
},
)
}
item { Spacer(Modifier.height(80.dp)) }
}
}
}
}
}
// ── Shared UI components ──────────────────────────────────────────────────────
@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 FilesEmptyState(icon: ImageVector, title: String, subtitle: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
private fun LocalFileItem(entry: LocalEntry, onClick: () -> Unit, onShare: () -> Unit) {
var menuExpanded by remember { mutableStateOf(false) }
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(
if (entry.isDir) Color(0xFF1B5E20).copy(alpha = 0.12f) else Color(0xFF0D47A1).copy(alpha = 0.10f)
),
contentAlignment = Alignment.Center,
) {
Icon(
icon, contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f),
)
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
imageVector = if (entry.isDir) Icons.Default.Folder else fileIcon(entry.file.name),
contentDescription = null, modifier = Modifier.size(26.dp),
tint = if (entry.isDir) Color(0xFF2E7D32) else Color(0xFF1565C0),
)
}
}
}
@Composable
private fun FileRow(file: SyncFileStateEntity, isInSubDir: Boolean, vm: FilesViewModel) {
val name = file.relativePath.substringAfterLast('/')
val timeFmt = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
val syncedAt = file.lastSyncedAt.atZone(ZoneId.systemDefault()).let { timeFmt.format(it) }
var menuExpanded by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
if (showRenameDialog) {
RenameDialog(
currentName = name,
onConfirm = { newName ->
vm.renameFile(file, newName)
showRenameDialog = false
},
onDismiss = { showRenameDialog = false },
)
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
icon = { Icon(Icons.Default.Delete, contentDescription = null) },
title = { Text("Delete file?") },
text = { Text("\"$name\" will be removed from this device.") },
confirmButton = {
TextButton(onClick = { vm.deleteFile(file); showDeleteDialog = false }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
},
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = if (isInSubDir) 22.dp else 0.dp,
top = 10.dp,
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
fileIcon(name), contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
fontWeight = if (entry.isDir) FontWeight.Medium else FontWeight.Normal)
Text(
"Synced $syncedAt · ${file.localSizeBytes.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
if (entry.isDir) "${entry.childCount} item${if (entry.childCount != 1) "s" else ""}"
else entry.sizeBytes.toDisplaySize(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.MoreVert, contentDescription = "File options",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(
text = { Text("Open") },
leadingIcon = { Icon(Icons.Default.OpenInNew, contentDescription = null) },
onClick = { menuExpanded = false; vm.openFile(file) },
)
DropdownMenuItem(
text = { Text("Share") },
leadingIcon = { Icon(Icons.Default.Share, contentDescription = null) },
onClick = { menuExpanded = false; vm.shareFile(file) },
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menuExpanded = false; showRenameDialog = true },
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(
Icons.Default.Delete, contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { menuExpanded = false; showDeleteDialog = true },
)
if (entry.isDir) {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
} else {
Box {
IconButton(onClick = { menuExpanded = true }, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.MoreVert, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
DropdownMenuItem(text = { Text("Open") }, leadingIcon = { Icon(Icons.Default.OpenInNew, null) },
onClick = { menuExpanded = false; onClick() })
DropdownMenuItem(text = { Text("Share") }, leadingIcon = { Icon(Icons.Default.Share, null) },
onClick = { menuExpanded = false; onShare() })
}
}
}
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
@Composable
private fun RenameDialog(
currentName: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Edit, contentDescription = null) },
title = { Text("Rename file") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("New name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
private fun CloudFileItem(file: com.syncflow.domain.model.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),
) {
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 fileIcon(file.name),
contentDescription = null, modifier = Modifier.size(26.dp),
tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0),
)
},
confirmButton = {
TextButton(
onClick = {
val trimmed = newName.trim()
if (trimmed.isNotBlank() && trimmed != currentName) onConfirm(trimmed)
else onDismiss()
},
enabled = newName.isNotBlank(),
) { Text("Rename") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
Column(modifier = Modifier.weight(1f)) {
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.toDisplaySize(), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (file.isDirectory) {
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
} else {
Icon(Icons.Default.FileDownload, null, Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
}
}
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun String.mimeType(): String {
val ext = substringAfterLast('.', "").lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
}
private fun fileIcon(name: String) = when {
private fun fileIcon(name: String): ImageVector = when {
name.endsWith(".pdf", ignoreCase = true) -> Icons.Default.PictureAsPdf
name.endsWith(".jpg", ignoreCase = true) ||
name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) ||
name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) ||
name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) ||
name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) ||
name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) ||
name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
name.endsWith(".jpg", ignoreCase = true) || name.endsWith(".jpeg", ignoreCase = true) ||
name.endsWith(".png", ignoreCase = true) || name.endsWith(".gif", ignoreCase = true) ||
name.endsWith(".webp", ignoreCase = true) -> Icons.Default.Image
name.endsWith(".mp4", ignoreCase = true) || name.endsWith(".mkv", ignoreCase = true) ||
name.endsWith(".mov", ignoreCase = true) -> Icons.Default.VideoFile
name.endsWith(".mp3", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) ||
name.endsWith(".ogg", ignoreCase = true) -> Icons.Default.AudioFile
name.endsWith(".zip", ignoreCase = true) || name.endsWith(".tar", ignoreCase = true) ||
name.endsWith(".gz", ignoreCase = true) -> Icons.Default.FolderZip
name.endsWith(".txt", ignoreCase = true) || name.endsWith(".md", ignoreCase = true) -> Icons.Default.TextSnippet
else -> Icons.Default.InsertDriveFile
}
@@ -1,6 +1,7 @@
package com.syncflow.ui.files
import android.content.Context
import android.media.MediaScannerConnection
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syncflow.data.db.SyncFileStateDao
@@ -14,6 +15,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -21,6 +23,7 @@ import javax.inject.Inject
sealed class FileAction {
data class Open(val file: File) : FileAction()
data class Share(val file: File) : FileAction()
data class ShareMultiple(val files: List<File>) : FileAction()
data class Error(val message: String) : FileAction()
}
@@ -37,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 ->
@@ -57,11 +63,20 @@ class FilesViewModel @Inject constructor(
private val _isDownloading = MutableStateFlow(false)
val isDownloading: StateFlow<Boolean> = _isDownloading
private val _selectedKeys = MutableStateFlow<Set<String>>(emptySet())
val selectedKeys: StateFlow<Set<String>> = _selectedKeys
val isSelectionMode: StateFlow<Boolean> = _selectedKeys.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val selectedCount: StateFlow<Int> = _selectedKeys.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
fun selectPair(id: Long) { _selectedPairId.value = id }
fun openFile(file: SyncFileStateEntity) {
val resolved = resolveFile(file, emitErrorIfMissing = false)
if (resolved != null) {
// Ensure MediaStore knows about this file so gallery apps can open it
MediaScannerConnection.scanFile(context, arrayOf(resolved.absolutePath), null, null)
viewModelScope.launch { _fileAction.emit(FileAction.Open(resolved)) }
} else {
downloadAndOpen(file)
@@ -108,6 +123,69 @@ class FilesViewModel @Inject constructor(
}
}
fun isSelected(file: SyncFileStateEntity): Boolean = fileKey(file) in _selectedKeys.value
fun toggleSelection(file: SyncFileStateEntity) {
val key = fileKey(file)
_selectedKeys.update { if (key in it) it - key else it + key }
}
fun clearSelection() { _selectedKeys.value = emptySet() }
fun deleteSelected() {
viewModelScope.launch {
val toDelete = files.value.filter { isSelected(it) }
toDelete.forEach { file ->
try {
resolveFile(file, emitErrorIfMissing = false)?.delete()
fileStateDao.delete(file.syncPairId, file.relativePath)
} catch (e: Exception) {
Timber.e(e, "Bulk delete failed: ${file.relativePath}")
}
}
clearSelection()
}
}
fun shareSelected() {
viewModelScope.launch {
val toShare = files.value.filter { isSelected(it) }
val resolved = toShare.mapNotNull { resolveFile(it, emitErrorIfMissing = false) }
if (resolved.isEmpty()) {
_fileAction.emit(FileAction.Error("No local files available to share"))
return@launch
}
_fileAction.emit(FileAction.ShareMultiple(resolved))
}
}
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
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) {
@@ -145,6 +223,7 @@ class FilesViewModel @Inject constructor(
cacheFile.outputStream().use { out ->
provider.downloadFile("${pair.remotePath}/${file.relativePath}", out) { }.getOrThrow()
}
MediaScannerConnection.scanFile(context, arrayOf(cacheFile.absolutePath), null, null)
cacheFile
} catch (e: Exception) {
Timber.e(e, "Download for preview failed: ${file.relativePath}")
@@ -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)
@@ -2,27 +2,28 @@ package com.syncflow.ui.theme
import androidx.compose.ui.graphics.Color
// Primary — indigo
val Indigo600 = Color(0xFF4F46E5)
val Indigo900 = Color(0xFF312E81)
val Indigo100 = Color(0xFFE0E7FF)
val Indigo50 = Color(0xFFEEF2FF)
// Primary — deep red (Passbolt-inspired)
val Red900 = Color(0xFF7F0000)
val Red700 = Color(0xFFB71C1C)
val Red500 = Color(0xFFEF5350)
val Red100 = Color(0xFFFFCDD2)
val Red50 = Color(0xFFFFEBEE)
// Secondary — teal
val Teal600 = Color(0xFF0D9488)
val Teal100 = Color(0xFFCCFBF1)
// Secondary — deep orange
val Orange700 = Color(0xFFE64A19)
val Orange100 = Color(0xFFFBE9E7)
// Tertiary — amber
val Amber500 = Color(0xFFF59E0B)
val Amber100 = Color(0xFFFEF3C7)
val Amber500 = Color(0xFFFFB300)
val Amber100 = Color(0xFFFFF8E1)
// Neutrals
val Slate50 = Color(0xFFF8FAFC)
val Slate100 = Color(0xFFF1F5F9)
val Slate200 = Color(0xFFE2E8F0)
val Slate600 = Color(0xFF475569)
val Slate900 = Color(0xFF0F172A)
val Gray50 = Color(0xFFF8F9FA)
val Gray100 = Color(0xFFF3F4F6)
val Gray200 = Color(0xFFE5E7EB)
val Gray600 = Color(0xFF6B7280)
val Gray900 = Color(0xFF111827)
// Semantic
val GreenSuccess = Color(0xFF16A34A)
val RedError = Color(0xFFDC2626)
val GreenSuccess = Color(0xFF2E7D32)
val RedError = Color(0xFFEF5350)
@@ -13,41 +13,43 @@ import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
private val LightColors = lightColorScheme(
primary = Indigo600,
primary = Red700,
onPrimary = Color.White,
primaryContainer = Indigo100,
onPrimaryContainer = Indigo900,
secondary = Teal600,
primaryContainer = Red50,
onPrimaryContainer = Red900,
secondary = Orange700,
onSecondary = Color.White,
secondaryContainer = Teal100,
secondaryContainer = Orange100,
tertiary = Amber500,
tertiaryContainer = Amber100,
background = Slate50,
background = Gray50,
surface = Color.White,
surfaceVariant = Slate100,
onSurfaceVariant = Slate600,
surfaceVariant = Gray100,
onSurface = Gray900,
onSurfaceVariant = Gray600,
error = RedError,
errorContainer = Color(0xFFFEE2E2),
outline = Slate200,
errorContainer = Red50,
outline = Gray200,
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF818CF8),
onPrimary = Indigo900,
primaryContainer = Color(0xFF3730A3),
onPrimaryContainer = Indigo100,
secondary = Color(0xFF2DD4BF),
onSecondary = Color(0xFF003731),
secondaryContainer = Color(0xFF00504A),
primary = Red500,
onPrimary = Color.White,
primaryContainer = Red900,
onPrimaryContainer = Red100,
secondary = Color(0xFFFF7043),
onSecondary = Color.White,
secondaryContainer = Color(0xFF4E1500),
tertiary = Amber500,
tertiaryContainer = Color(0xFF92400E),
background = Color(0xFF0F0F1A),
surface = Color(0xFF1A1A2E),
surfaceVariant = Color(0xFF252538),
onSurfaceVariant = Color(0xFF94A3B8),
error = Color(0xFFF87171),
errorContainer = Color(0xFF7F1D1D),
outline = Color(0xFF334155),
tertiaryContainer = Color(0xFF3E2700),
background = Color(0xFF0F0F0F),
surface = Color(0xFF1C1C1C),
surfaceVariant = Color(0xFF2A2A2A),
onSurface = Color(0xFFEAEAEA),
onSurfaceVariant = Color(0xFF9E9E9E),
error = Color(0xFFFF5252),
errorContainer = Color(0xFF5C0000),
outline = Color(0xFF3D3D3D),
)
private val AppTypography = Typography(
@@ -13,14 +13,20 @@ import android.os.Looper
import android.provider.DocumentsContract
import androidx.core.app.NotificationCompat
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.first
import com.syncflow.MainActivity
import com.syncflow.R
import com.syncflow.data.db.SyncFileStateDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.toDomain
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.sync.LocalAccessor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -33,11 +39,18 @@ class FileWatchService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
// Prevents concurrent refresh() calls from doubling watchers + catchup scans
private val refreshMutex = Mutex()
// Multiple FileObserver instances per pair: one per directory (recursive)
private val fileObservers = mutableMapOf<Long, MutableList<FileObserver>>()
private val contentObservers = mutableMapOf<Long, ContentObserver>()
private val debounceJobs = mutableMapOf<Long, Job>()
// Persistent monitors that watch WorkManager for ANY sync (manual, catchup, onchange)
// so the cooldown is set regardless of who triggered the sync.
private val syncMonitorJobs = mutableMapOf<Long, Job>()
// After a sync completes, suppress FileObserver events for this long.
private val syncCooldownUntil = mutableMapOf<Long, Long>()
companion object {
const val CHANNEL_WATCH = "sync_watching"
@@ -76,7 +89,7 @@ class FileWatchService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private suspend fun refresh() {
private suspend fun refresh() = refreshMutex.withLock {
clearWatchers()
val pairs = syncPairDao.getEnabled().filter { it.scheduleType == ScheduleType.ON_CHANGE }
@@ -140,11 +153,41 @@ class FileWatchService : Service() {
return
}
fileObservers[pairId] = mutableListOf()
// Set startup cooldown BEFORE registering watchers so inotify events that fire
// immediately on registration don't trigger the debounce before catchupScan runs.
syncCooldownUntil[pairId] = System.currentTimeMillis() + 15_000
watchDirRecursive(dir, pairId, wifiOnly, chargingOnly)
Timber.d("FileWatchService: watching pair $pairId at $path (${fileObservers[pairId]?.size} dirs)")
startSyncMonitor(pairId)
scope.launch { catchupScan(pairId, dir, wifiOnly, chargingOnly) }
}
// Watches WorkManager for ANY sync tagged sync_$pairId (manual, catchup, onchange).
// Sets cooldown while running and for 60s after, so FileObserver events from our
// own file writes never trigger a re-sync regardless of what started the sync.
private fun startSyncMonitor(pairId: Long) {
syncMonitorJobs[pairId]?.cancel()
syncMonitorJobs[pairId] = scope.launch {
var wasSyncing = false
WorkManager.getInstance(applicationContext)
.getWorkInfosByTagFlow("sync_$pairId")
.collect { infos ->
val isSyncing = infos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
if (isSyncing) {
Timber.d("FileWatchService: sync active for pair $pairId — cooldown extended")
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
wasSyncing = true
} else if (wasSyncing) {
Timber.d("FileWatchService: sync finished for pair $pairId — 60s settle cooldown")
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
wasSyncing = false
}
}
}
}
private fun watchDirRecursive(dir: File, pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
if (!dir.isDirectory) return
val mask = FileObserver.CREATE or FileObserver.DELETE or FileObserver.MODIFY or
@@ -183,40 +226,102 @@ class FileWatchService : Service() {
val known = fileStateDao.getForPair(pairId).associateBy { it.relativePath }
if (known.isEmpty()) return // Never synced — first sync will be triggered manually
val current = mutableMapOf<String, Long>()
dir.walk().filter { it.isFile }.forEach { f ->
current[f.relativeTo(dir).path.replace('\\', '/')] = f.lastModified()
}
val pairEntity = syncPairDao.getById(pairId) ?: return
val pair = pairEntity.toDomain()
// Use the same accessor + filters as SyncEngine so hidden/excluded/size-filtered files
// don't appear as "new" in the catchup scan and trigger a perpetual sync loop.
val accessor = if (pair.localPath.startsWith("content://"))
LocalAccessor.Saf(Uri.parse(pair.localPath), contentResolver)
else
LocalAccessor.JavaFile(dir)
val current = accessor.walkFiles(pair)
val hasNew = current.any { (rel, _) -> rel !in known }
val hasModified = current.any { (rel, mtime) ->
val hasModified = current.any { (rel, info) ->
val s = known[rel]; s != null && s.localModifiedAt != null &&
s.localModifiedAt.toEpochMilli() != mtime
s.localModifiedAt.epochSecond != info.lastModifiedMs / 1000
}
val hasDeleted = known.keys.any { rel -> rel !in current }
if (hasNew || hasModified || hasDeleted) {
Timber.d("FileWatchService: catchup detected changes for pair $pairId, scheduling sync")
val pair = syncPairDao.getById(pairId) ?: return
// Cancel any debounce that started before our startup cooldown was set
debounceJobs[pairId]?.cancel()
debounceJobs.remove(pairId)
// Hold cooldown for duration of sync + 60s settle
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork(
"catchup_$pairId",
ExistingWorkPolicy.KEEP,
SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly),
)
.enqueueUniqueWork("catchup_$pairId", ExistingWorkPolicy.KEEP, req)
scope.launch {
try {
WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
}
}
}
}
private fun onChangeDetected(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean) {
// Ignore events fired by our own sync writing files — prevents the feedback loop
// where downloaded/uploaded files trigger another sync indefinitely.
if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) {
Timber.d("FileWatchService: suppressing change event for pair $pairId (sync cooldown)")
return
}
debounceJobs[pairId]?.cancel()
debounceJobs[pairId] = scope.launch {
delay(5_000)
// Re-check: catchupScan or another path may have already set a cooldown
// and handled this sync while we were waiting.
if (System.currentTimeMillis() < (syncCooldownUntil[pairId] ?: 0L)) {
Timber.d("FileWatchService: debounce fired but cooldown active for pair $pairId, skipping")
return@launch
}
val pair = syncPairDao.getById(pairId)
if (pair == null || !pair.isEnabled) return@launch
Timber.d("FileWatchService: triggering sync for pair $pairId after debounce")
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly)
// Block new triggers from this point until 60s after sync completes
syncCooldownUntil[pairId] = System.currentTimeMillis() + 120_000
val req = SyncWorker.buildOneTimeRequest(pairId, wifiOnly, chargingOnly, silent = true)
WorkManager.getInstance(applicationContext)
.enqueueUniqueWork("onchange_$pairId", ExistingWorkPolicy.KEEP, req)
updateNotificationDynamic("Syncing: ${pair.name}")
scope.launch {
try {
val info = WorkManager.getInstance(applicationContext)
.getWorkInfoByIdFlow(req.id)
.first { it?.state?.isFinished == true }
// Extend cooldown: 60s after sync finishes to let filesystem settle
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
val summary = info?.outputData?.getString(SyncWorker.KEY_RESULT_SUMMARY)
val watchCount = fileObservers.keys.size + contentObservers.size
val watching = "Watching $watchCount folder${if (watchCount != 1) "s" else ""}"
if (info?.state == WorkInfo.State.SUCCEEDED && summary != null) {
updateNotificationDynamic("${pair.name}: $summary$watching")
} else {
updateNotificationDynamic("$watching")
}
delay(12_000)
updateNotificationDynamic(null)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
syncCooldownUntil[pairId] = System.currentTimeMillis() + 60_000
updateNotificationDynamic(null)
}
}
}
}
@@ -227,6 +332,9 @@ class FileWatchService : Service() {
contentObservers.clear()
debounceJobs.values.forEach { it.cancel() }
debounceJobs.clear()
syncMonitorJobs.values.forEach { it.cancel() }
syncMonitorJobs.clear()
syncCooldownUntil.clear()
}
private fun ensureChannel() {
@@ -241,7 +349,7 @@ class FileWatchService : Service() {
}
}
private fun buildNotification(count: Int): Notification {
private fun buildNotification(count: Int, overrideText: String? = null): Notification {
val tapIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
@@ -250,7 +358,7 @@ class FileWatchService : Service() {
return NotificationCompat.Builder(this, CHANNEL_WATCH)
.setContentTitle("SyncFlow")
.setContentText(
if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
overrideText ?: if (count > 0) "Watching $count folder${if (count != 1) "s" else ""} for changes"
else "Starting file watcher…"
)
.setSmallIcon(R.drawable.ic_sync)
@@ -264,4 +372,10 @@ class FileWatchService : Service() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count))
}
private fun updateNotificationDynamic(overrideText: String?) {
val count = fileObservers.keys.size + contentObservers.size
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification(count, overrideText))
}
}
@@ -39,15 +39,32 @@ class SyncWorker @AssistedInject constructor(
val pair = syncPairDao.getById(pairId) ?: return Result.failure()
val account = accountRepository.getAccount(pair.accountId) ?: return Result.failure()
val silent = inputData.getBoolean(KEY_SILENT, false)
ensureChannels()
setForeground(buildForegroundInfo(pair.name, "Syncing…"))
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,
))
}
if (result.error != null && pair.notifyOnError) {
val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
if (result.downloaded > 0) add("${result.downloaded}")
if (result.deleted > 0) add("🗑${result.deleted}")
if (result.conflicts > 0) add("${result.conflicts}")
}
val resultSummary = if (lines.isEmpty()) "Up to date" else lines.joinToString(" ")
if (!silent && result.error != null && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -55,28 +72,28 @@ class SyncWorker @AssistedInject constructor(
text = result.error.message ?: "Unknown error",
priority = NotificationCompat.PRIORITY_DEFAULT,
)
} else if (pair.notifyOnComplete && result.error == null) {
val lines = buildList {
} else if (!silent && pair.notifyOnComplete && result.error == null) {
val fullLines = buildList {
if (result.uploaded > 0) add("↑ Uploaded: ${result.uploaded} file${if (result.uploaded != 1) "s" else ""}")
if (result.downloaded > 0) add("↓ Downloaded: ${result.downloaded} file${if (result.downloaded != 1) "s" else ""}")
if (result.deleted > 0) add("🗑 Deleted: ${result.deleted} file${if (result.deleted != 1) "s" else ""}")
if (result.conflicts > 0) add("${result.conflicts} conflict${if (result.conflicts != 1) "s" else ""}")
}
val summary = if (lines.isEmpty()) "Up to date — nothing to sync" else lines.joinToString("\n")
val summary = if (fullLines.isEmpty()) "Up to date — nothing to sync" else fullLines.joinToString("\n")
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_COMPLETE,
title = "${pair.name}Changes synced",
text = if (lines.isEmpty()) summary else lines.first(),
title = "${pair.name}Synced",
text = if (fullLines.isEmpty()) summary else fullLines.first(),
bigText = summary,
priority = NotificationCompat.PRIORITY_LOW,
)
}
Result.success()
Result.success(workDataOf(KEY_RESULT_SUMMARY to resultSummary))
} catch (e: Exception) {
Timber.e(e, "SyncWorker failed for pair $pairId")
if (pair.notifyOnError) {
if (!silent && pair.notifyOnError) {
notify(
id = pairId.toInt() + RESULT_ID_OFFSET,
channelId = CHANNEL_ALERTS,
@@ -146,19 +163,25 @@ class SyncWorker @AssistedInject constructor(
companion object {
const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
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"
private const val CHANNEL_COMPLETE = "sync_complete"
private const val CHANNEL_ALERTS = "sync_alerts"
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean): OneTimeWorkRequest {
fun buildOneTimeRequest(pairId: Long, wifiOnly: Boolean, chargingOnly: Boolean, silent: Boolean = false): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
return OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf(KEY_PAIR_ID to pairId))
.setInputData(workDataOf(KEY_PAIR_ID to pairId, KEY_SILENT to silent))
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync_$pairId")
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,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:type="radial"
android:gradientRadius="80%"
android:centerX="0.35"
android:centerY="0.3"
android:startColor="#1C1124"
android:centerColor="#0E0A18"
android:endColor="#060408"/>
</shape>
@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
Three identical arcs pointing right, evenly spaced vertically.
Same curve shape, same gradient direction, same arrowhead geometry — purely harmonious.
Control point 14dp above midline on each arc.
-->
<!-- Arc 1 — top -->
<path
android:pathData="M 26,34 Q 54,20 82,34"
android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="26" android:startY="34"
android:endX="82" android:endY="34"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,27.3 L 82,34 L 74.0,34.5 Z" android:fillColor="#32EDBB"/>
<!-- Arc 2 — middle -->
<path
android:pathData="M 26,54 Q 54,40 82,54"
android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="26" android:startY="54"
android:endX="82" android:endY="54"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,47.3 L 82,54 L 74.0,54.5 Z" android:fillColor="#32EDBB"/>
<!-- Arc 3 — bottom -->
<path
android:pathData="M 26,74 Q 54,60 82,74"
android:fillColor="#00000000"
android:strokeWidth="8.5"
android:strokeLineCap="round"
android:strokeLineJoin="round">
<aapt:attr name="android:strokeColor">
<gradient android:type="linear"
android:startX="26" android:startY="74"
android:endX="82" android:endY="74"
android:startColor="#64C8FF"
android:endColor="#32EDBB"/>
</aapt:attr>
</path>
<path android:pathData="M 77.5,67.3 L 82,74 L 74.0,74.5 Z" android:fillColor="#32EDBB"/>
</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.24
VERSION_CODE=25
VERSION_NAME=1.0.65
VERSION_CODE=66