Compare commits

...

36 Commits

Author SHA1 Message Date
amir 0131d8d4fd v1.0.73: treat HTTP 423 Locked as success for MKCOL
Build & Release APK / build (push) Successful in 12m53s
SFTPGo returns HTTP 423 (Locked) on MKCOL when a directory already
exists and has an active lock. ensureRemoteDirs only handled 405
(already exists), so 423 was thrown as an exception causing all file
uploads within that directory to fail.

65 files failed every time because they were all inside directories
that returned 423 on MKCOL, not 405. Treat 423 the same as 405.
2026-06-07 02:55:50 +00:00
amir d2ca3f1918 v1.0.73: auto-upgrade http:// to https:// for WebDAV
Zahra's sync pair was configured with http://dav.khodak.me. Traefik has
a global HTTP->HTTPS redirect, but PROPFIND/PUT/MOVE are not followed
through redirects by OkHttp — so every WebDAV operation was getting
redirected and silently failing. 1072 logins, 0 actual DAV operations.

Silently rewrite http:// to https:// at the provider level so users
never need to reconfigure.
2026-06-07 02:51:32 +00:00
amir 812b40b42f v1.0.72: raise WebDAV timeout from 30s to 5min for large video uploads
Build & Release APK / build (push) Successful in 13m11s
30s read/write timeout killed uploads of large video files mid-stream.
Videos in zahra's folders took 56s+ to upload — anything over 30s was
failing and counted as a failed file (PARTIAL). Raised to 5 minutes.
2026-06-07 02:44:19 +00:00
amir b7ec3f4ad3 v1.0.71: SFTP connection pooling — reuse SSH session across all operations
Build & Release APK / build (push) Failing after 20m59s
Previously every listFiles/uploadFile/downloadFile/deleteFile call created
a fresh SSH connection (connect → auth → use → disconnect). For zahra's
folder with 69 subdirectories, the recursive listing alone made 70 full
SSH handshakes, then one more per downloaded file — causing connection
timeouts and 65 upload/download failures reported as PARTIAL.

Now the provider holds a persistent SSH session and reuses it for all
calls, reconnecting automatically if the connection drops.
2026-06-07 02:34:01 +00:00
amir 537808ca10 v1.0.70: single-source version (name always tracks build number)
Build & Release APK / build (push) Successful in 12m58s
versionName is now derived as 1.0.<versionCode>, so the git tag, APK filename,
and in-app About version are always the same number and can't drift.
2026-06-07 02:08:10 +00:00
amir 147da702a1 v1.0.68: fix two-way DATA LOSS — list remote recursively
Build & Release APK / build (push) Successful in 12m54s
The remote was listed Depth:1 (top level only) while the local folder is
walked recursively. Files inside remote subfolders looked 'missing from
remote', so TWO_WAY + mirror-delete ran DELETE_LOCAL and wiped them off the
device. Now walk the remote tree (Depth:1 per dir) so subfolder files are
matched and never falsely deleted.
2026-06-07 00:43:16 +00:00
amir cf2fd8c452 v1.0.67: bump version for release
Build & Release APK / build (push) Successful in 12m50s
2026-06-06 17:58:42 +00:00
amir c415dceb22 v1.0.60: skip remote directories in sync + reduce concurrency to 2
Build & Release APK / build (push) Successful in 12m49s
- Filter out isDirectory entries from remoteFiles so remote folders are
  never treated as files to sync (fixes phantom-directory 'Partial ✗5' status)
- Lower Semaphore from 4 → 2 to reduce concurrent SFTP sessions and
  avoid hitting server session limits
2026-06-06 17:45:32 +00:00
amir e1abf80f11 v1.0.66: fix scheduled background sync never registering on pair creation
Build & Release APK / build (push) Successful in 12m49s
Creating an interval/daily/weekly sync pair saved it enabled but never enqueued
the periodic WorkManager job — it only scheduled on the enable-toggle or a
reboot, so a freshly-created scheduled backup silently never ran in the
background. AddPairViewModel.save now registers the work (periodic / watcher)
on save, mirroring toggleEnabled + BootReceiver. Verified on-device: the
JobScheduler periodic job appears on save and a forced run performs the sync.
2026-06-05 21:08:42 +00:00
amir 15b94a0407 Add real-world large-file test (multi-GB from phone via external URL, chunked)
Build & Release APK / build (push) Successful in 12m58s
Opt-in (-e bigFileMB=<size>): streams a multi-GB file from the device through
the app's chunked-upload path to the external nextcloud.khodak.me and verifies
the full size lands. Verified live: 1.5 GB and 5 GB both succeed end-to-end.
2026-06-05 16:14:13 +00:00
amir abec5276f9 CI: create the Gitea release object if missing on tag (was failing to publish)
Build & Release APK / build (push) Successful in 12m49s
A pushed git tag doesn't create a Gitea release object, so the publish step
404'd trying to attach the APK. Now it creates the release if absent (with
contents:write permission), then uploads. v1.0.65 was published manually.
2026-06-05 16:05:13 +00:00
amir 4c24f45808 Add live SFTPGo WebDAV test (real 2nd WebDAV server via dav.khodak.me)
Build & Release APK / build (push) Successful in 12m53s
Tests the app's SFTPGo provider (WebDavProvider) end-to-end against a real
SFTPGo server over its exposed WebDAV URL: connect, mkdir, atomic upload,
list, download, overwrite, non-ASCII filename, delete. Validates the WebDAV
code path against a non-Nextcloud server. Creds via -e davUrl/davUser/davPass.
2026-06-05 16:02:57 +00:00
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
37 changed files with 2423 additions and 539 deletions
+39 -10
View File
@@ -2,12 +2,16 @@ name: Build & Release APK
on:
push:
branches: ['**']
tags:
- 'v*'
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # needed to create the release object on a tag
steps:
- uses: actions/checkout@v4
@@ -20,10 +24,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,21 +55,27 @@ 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 }}
VERSION: ${{ steps.ver.outputs.name }}
run: |
RELEASE_ID=$(curl -sf \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -sf -X POST \
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/$RELEASE_ID/assets" \
API="https://gitea.khodak.me/api/v1/repos/amir/SyncFlow"
# A pushed git tag does NOT create a Gitea release object — fetch it, create if missing.
RELEASE_ID=$(curl -s "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" \
| python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('id','') if isinstance(d,dict) else '')" 2>/dev/null)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(curl -s -X POST "$API/releases" -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))")
echo "created release object $RELEASE_ID for $TAG"
fi
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=SyncFlow-v${VERSION}.apk" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
echo "APK uploaded to release $TAG"
echo "APK uploaded to release $TAG (id $RELEASE_ID)"
+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.
+27 -9
View File
@@ -18,16 +18,25 @@ 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"
minSdk = 26
targetSdk = 35
versionCode = versionProps["VERSION_CODE"].toString().toInt()
versionName = versionProps["VERSION_NAME"].toString()
// Single source of truth: the human version always tracks the build number, so the
// git tag (v1.0.N), the APK filename, and the in-app "About" all read 1.0.N and
// can never drift apart again. Bump only VERSION_CODE in version.properties.
versionName = "1.0.${versionProps["VERSION_CODE"].toString().toInt()}"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Placeholder — replace with real keys before release
@@ -38,19 +47,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,185 @@
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.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
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 ctx = InstrumentationRegistry.getInstrumentation().targetContext
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) }
}
}
/**
* Real-world large-file test: streams a multi-GB file FROM THE PHONE through the app's
* chunked-upload path to the external URL, verifies the full size landed, then cleans up.
* Opt-in (slow): pass -e bigFileMB=<size>, e.g. 1536 for 1.5 GB.
*/
@Test
fun realWorld_largeFileChunkedUpload() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
val mb = args.getString("bigFileMB")?.toIntOrNull() ?: 0
assumeTrue("pass -e bigFileMB=<size> to run the big-file test", mb > 0)
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) // default 100 MB chunks -> chunked path for >100 MB
val dir = "SyncFlowBig_${System.currentTimeMillis()}"
val tmp = File(ctx.cacheDir, "bigfile_${System.currentTimeMillis()}.bin")
try {
val total = mb.toLong() * 1024 * 1024
FileOutputStream(tmp).use { os ->
val buf = ByteArray(8 * 1024 * 1024)
var written = 0L
while (written < total) {
val n = minOf(buf.size.toLong(), total - written).toInt()
os.write(buf, 0, n); written += n
}
}
assertEquals(total, tmp.length())
p.createDirectory(dir).getOrThrow()
val up = p.uploadFile(FileInputStream(tmp), "$dir/big.bin", tmp.length())
assertTrue("large chunked upload failed: ${up.exceptionOrNull()}", up.isSuccess)
assertEquals("full file size must land on the server", total,
p.listFiles(dir).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
} finally {
tmp.delete()
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 })
}
}
@@ -0,0 +1,68 @@
package com.syncflow
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.syncflow.data.providers.webdav.WebDavProvider
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 test of the app's SFTPGo provider (which is WebDavProvider) against a real SFTPGo
* server over its externally-exposed WebDAV URL. Validates the provider against a different
* WebDAV implementation than Nextcloud. Creds via -e davUrl/davUser/davPass; skips otherwise.
*/
@RunWith(AndroidJUnit4::class)
class SftpgoWebDavTest {
private val args = InstrumentationRegistry.getArguments()
private fun provider() = WebDavProvider(
CloudAccount(
id = 1, displayName = "sftpgo", email = null, providerType = ProviderType.SFTPGO,
credentialJson = """{"username":"${args.getString("davUser")}","password":"${args.getString("davPass")}"}""",
serverUrl = args.getString("davUrl"), port = null,
),
)
@Test fun sftpgoWebDavRoundTrip() = runBlocking {
assumeTrue("davUrl/davUser/davPass required", args.getString("davUrl") != null)
val p = provider()
val dir = "SyncFlowDav_${System.currentTimeMillis()}"
try {
assertTrue("testConnection", p.testConnection().isSuccess)
assertTrue("mkdir", p.createDirectory(dir).isSuccess)
// upload (atomic temp + MOVE), list, download — with a non-ASCII payload
val body = "sftpgo webdav round-trip ✓ café".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("sftpgo webdav round-trip ✓ café", out.toString("UTF-8"))
// overwrite via atomic temp+MOVE
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"))
// non-ASCII / special filename (the URL/MOVE-header encoding fix)
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 })
} finally {
runCatching { p.deleteFile(dir) }
}
}
}
@@ -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}")
}
}
}
@@ -23,19 +23,36 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
private val password = creds["password"]?.jsonPrimitive?.content
private val privateKey = creds["private_key"]?.jsonPrimitive?.content
private fun <T> withSftp(block: (SFTPClient) -> T): T {
// Persistent SSH connection reused across all operations in the provider's lifetime.
// Each call to withSftp checks liveness and reconnects if the connection dropped.
// This eliminates the per-operation connect/auth/disconnect cycle that caused
// 100+ SSH handshakes during a recursive directory listing + file-transfer sync,
// leading to connection timeouts on large folder trees (e.g. 69 subdirectories).
private var sshClient: SSHClient? = null
private fun getOrCreateSsh(): SSHClient {
val existing = sshClient
if (existing != null && existing.isConnected && existing.isAuthenticated) return existing
val ssh = SSHClient()
ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
ssh.connect(host, port)
try {
if (!privateKey.isNullOrBlank()) {
ssh.authPublickey(username, ssh.loadKeys(privateKey, null, null))
} else {
ssh.authPassword(username, password ?: "")
}
return ssh.newSFTPClient().use(block)
} finally {
ssh.disconnect()
if (!privateKey.isNullOrBlank()) {
ssh.authPublickey(username, ssh.loadKeys(privateKey, null, null))
} else {
ssh.authPassword(username, password ?: "")
}
sshClient = ssh
return ssh
}
private fun <T> withSftp(block: (SFTPClient) -> T): T {
return try {
getOrCreateSsh().newSFTPClient().use(block)
} catch (e: Exception) {
// Connection may have gone stale — reset and retry once with a fresh connection.
runCatching { sshClient?.disconnect() }
sshClient = null
getOrCreateSsh().newSFTPClient().use(block)
}
}
@@ -43,17 +60,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 +84,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()
}
@@ -35,7 +35,8 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
val pass = creds["password"]?.jsonPrimitive?.content ?: ""
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("Authorization", Credentials.basic(user, pass))
@@ -93,15 +94,30 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
localStream.source().use { source -> sink.writeAll(source) }
}
}
val req = Request.Builder().url(url(remotePath)).put(body).build()
// 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}")
}
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()
@@ -134,7 +150,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
withContext(Dispatchers.IO) {
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful && resp.code != 405) throw Exception("MKCOL HTTP ${resp.code}")
// 405 = directory already exists (most servers)
// 423 = Locked — SFTPGo returns this when the dir exists and has a lock;
// treat as "already there", not a failure, so uploads inside it proceed.
if (!resp.isSuccessful && resp.code != 405 && resp.code != 423) {
throw Exception("MKCOL HTTP ${resp.code}")
}
}
}
}
@@ -166,7 +187,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()
@@ -70,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,35 +35,28 @@ class SyncEngine @Inject constructor(
private val eventDao: SyncEventDao,
@ApplicationContext private val context: Context,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
if (!pair.localPath.startsWith("content://") &&
pair.syncDirection != SyncDirection.UPLOAD_ONLY) {
val canonical = runCatching { File(pair.localPath).canonicalPath }.getOrElse { pair.localPath }
if (canonical == "/storage/emulated/0") {
val msg = "Local folder is the storage root — Android blocks writes here. Use Upload Only direction, or select a subfolder."
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, msg, 0)
return SyncResult(failedFiles = 1, error = Exception(msg))
}
}
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)
}
@@ -73,14 +68,49 @@ class SyncEngine @Inject constructor(
else
LocalAccessor.JavaFile(File(localPath))
/**
* Recursively collect every FILE under [basePath] on the remote, descending into each
* subdirectory (one Depth:1 PROPFIND per directory). Directories are never returned as
* files — only their contents. The provider already drops the parent entry from each
* listing, so children-only is returned; the explicit self-path guard prevents any
* pathological infinite recursion. This MUST mirror the recursive local walk: otherwise
* files in remote subfolders appear absent and a TWO_WAY/MIRROR sync deletes them locally.
*/
private suspend fun listRemoteFilesRecursive(
provider: CloudProvider,
basePath: String,
depth: Int = 0,
): List<RemoteFile> {
if (depth > 64) {
Timber.w("SyncEngine: remote recursion depth limit hit at $basePath")
return emptyList()
}
val out = mutableListOf<RemoteFile>()
for (entry in provider.listFiles(basePath).getOrThrow()) {
if (entry.isDirectory) {
if (entry.path.trimEnd('/') != basePath.trimEnd('/')) {
out += listRemoteFilesRecursive(provider, entry.path, depth + 1)
}
} else {
out += entry
}
}
return out
}
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)
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
// The local walk is RECURSIVE, so the remote listing must be too. Listing only the top
// level (Depth:1) made every file inside a remote subfolder look "missing from remote",
// which on a TWO_WAY/MIRROR pair triggered DELETE_LOCAL and wiped those files off the
// device (data loss). Walk the remote tree so subfolder files are matched, not deleted.
val remoteFiles = listRemoteFilesRecursive(provider, pair.remotePath)
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
@@ -92,12 +122,16 @@ class SyncEngine @Inject constructor(
knownStates.keys.none { it in localFiles }) {
Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
fileStateDao.deleteForPair(pair.id)
return performSync(pair, provider, isRetry = true)
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
val semaphore = Semaphore(2) // limit concurrency to be gentle on the server
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(
@@ -111,6 +145,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]
@@ -130,6 +172,9 @@ 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
@@ -139,8 +184,8 @@ class SyncEngine @Inject constructor(
}
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 ->
@@ -153,6 +198,9 @@ class SyncEngine @Inject constructor(
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
?: System.currentTimeMillis()
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
val down = downloadedAtomic.incrementAndGet()
bytesAtomic.addAndGet(bytes)
onProgress?.invoke(uploadedAtomic.get(), down, deletedAtomic.get(), bytesAtomic.get())
FileOutcome(downloaded = 1, bytesTransferred = bytes,
newState = buildState(pair.id, rel,
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
@@ -164,12 +212,18 @@ class SyncEngine @Inject constructor(
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
fileStateDao.delete(pair.id, rel)
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
val del = deletedAtomic.incrementAndGet()
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
FileOutcome(deleted = 1)
}
SyncDecision.DELETE_REMOTE -> {
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") }
@@ -181,6 +235,8 @@ class SyncEngine @Inject constructor(
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 -> {
@@ -353,6 +409,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,7 +1,12 @@
package com.syncflow.ui.addpair
import android.content.Intent
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
@@ -12,6 +17,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.input.KeyboardType
@@ -28,9 +34,20 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
var showLocalBrowser by remember { mutableStateOf(false) }
val safLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = uri.toString()) }
}
}
if (showLocalBrowser) {
LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" },
@@ -148,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))
@@ -164,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}" },
)
}
@@ -367,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)
}
}
@@ -10,7 +10,10 @@ import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.CloudAccountEntity
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.*
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkManager
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.*
@@ -28,7 +31,10 @@ data class AddPairUiState(
// ── Sync type ────────────────────────────────────────────────────────────
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
val deleteBehavior: DeleteBehavior = recommendedDeleteBehavior(SyncDirection.TWO_WAY),
// True once the user explicitly picks a deletion behaviour, so changing direction stops
// auto-overriding their choice.
val deleteBehaviorTouched: Boolean = false,
val recursive: Boolean = true,
// ── Schedule ─────────────────────────────────────────────────────────────
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
@@ -56,6 +62,16 @@ data class AddPairUiState(
val done: Boolean = false,
)
/**
* Safe default deletion behaviour for a given direction. One-way backups must NOT propagate a
* local deletion to the cloud (the whole point of a backup), so they default to KEEP; two-way
* sync defaults to MIRROR. The user can always override — all three options stay selectable.
*/
internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior = when (direction) {
SyncDirection.UPLOAD_ONLY, SyncDirection.DOWNLOAD_ONLY -> DeleteBehavior.KEEP
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
}
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
@@ -93,6 +109,7 @@ class AddPairViewModel @Inject constructor(
syncDirection = pair.syncDirection,
conflictStrategy = pair.conflictStrategy,
deleteBehavior = pair.deleteBehavior,
deleteBehaviorTouched = true, // preserve the saved choice when editing
recursive = pair.recursive,
scheduleType = pair.scheduleType,
intervalMinutes = pair.scheduleIntervalMinutes,
@@ -119,6 +136,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 {
@@ -150,7 +179,7 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
if (editPairId == null) {
val pairId = if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
@@ -163,13 +192,40 @@ class AddPairViewModel @Inject constructor(
) {
fileStateDao.deleteForPair(editPairId)
}
editPairId
}
entity.copy(id = pairId)
}
.onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
.onSuccess { saved ->
applySchedule(saved)
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
/**
* Register the pair's background work the moment it's saved. Previously this only happened on
* the enable-toggle or a reboot, so a freshly-created scheduled pair never actually ran in the
* background. Mirrors HomeViewModel.toggleEnabled / BootReceiver.
*/
private fun applySchedule(pair: SyncPairEntity) {
val wm = WorkManager.getInstance(context)
when (pair.scheduleType) {
ScheduleType.ON_CHANGE -> {
wm.cancelUniqueWork("periodic_${pair.id}")
FileWatchService.start(context)
}
ScheduleType.MANUAL -> wm.cancelUniqueWork("periodic_${pair.id}")
else -> {
val req = SyncWorker.buildPeriodicRequest(
pair.id,
pair.scheduleIntervalMinutes.toLong().coerceAtLeast(15),
pair.wifiOnly,
pair.chargingOnly,
)
wm.enqueueUniquePeriodicWork("periodic_${pair.id}", ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
}
@@ -34,6 +34,12 @@ 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
@@ -131,6 +137,10 @@ fun LocalBrowserDialog(
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,
@@ -192,6 +202,35 @@ fun LocalBrowserDialog(
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(
@@ -263,23 +302,13 @@ fun LocalBrowserDialog(
}
// ── Select button ────────────────────────────────────────────
val isStorageRoot = currentPath.absolutePath == STORAGE_ROOT.absolutePath
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
if (isStorageRoot) {
Text(
"Android blocks writes to the storage root — please open a subfolder first",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
Button(
onClick = { onSelect(currentPath.absolutePath) },
enabled = !isStorageRoot,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 12.dp)
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
File diff suppressed because it is too large Load Diff
@@ -40,6 +40,9 @@ class FilesViewModel @Inject constructor(
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val accounts = accountRepository.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedPairId = MutableStateFlow<Long?>(null)
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
@@ -158,6 +161,31 @@ class FilesViewModel @Inject constructor(
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
fun openCloudFile(accountId: Long, remotePath: String) {
viewModelScope.launch {
val account = accountRepository.getAccount(accountId) ?: run {
_fileAction.emit(FileAction.Error("Account not found"))
return@launch
}
val provider = providerFactory.create(account)
val fileName = remotePath.substringAfterLast('/')
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
cacheFile.parentFile?.mkdirs()
_isDownloading.value = true
try {
cacheFile.outputStream().use { out ->
provider.downloadFile(remotePath, out) { }.getOrThrow()
}
_fileAction.emit(FileAction.Open(cacheFile))
} catch (e: Exception) {
Timber.e(e, "Cloud open failed: $remotePath")
_fileAction.emit(FileAction.Error("Cannot open: ${e.message}"))
} finally {
_isDownloading.value = false
}
}
}
// ── Download-then-open/share ──────────────────────────────────────────────
private fun downloadAndOpen(file: SyncFileStateEntity) {
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
@@ -38,6 +39,7 @@ fun HomeScreen(
vm: HomeViewModel = hiltViewModel(),
) {
val pairs by vm.syncPairs.collectAsState()
val progressMap by vm.syncProgressMap.collectAsState()
if (pairs.isEmpty()) {
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
@@ -50,9 +52,11 @@ fun HomeScreen(
items(pairs, key = { it.id }) { pair ->
SyncPairCard(
pair = pair,
progress = progressMap[pair.id],
onClick = { onPairClick(pair.id) },
onSync = { vm.triggerSync(pair) },
onToggle = { vm.toggleEnabled(pair) },
onPause = { vm.pauseSync(pair) },
)
}
item { Spacer(Modifier.height(80.dp)) }
@@ -63,9 +67,11 @@ fun HomeScreen(
@Composable
private fun SyncPairCard(
pair: SyncPairEntity,
progress: SyncProgress? = null,
onClick: () -> Unit,
onSync: () -> Unit,
onToggle: () -> Unit,
onPause: () -> Unit = {},
) {
val accentColor = pair.lastSyncResult.accentColor
@@ -170,13 +176,57 @@ private fun SyncPairCard(
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "cardRotation",
)
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Default.Sync, "Sync now",
modifier = Modifier.size(18.dp).graphicsLayer {
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
},
)
when (pair.lastSyncResult) {
SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
}
SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp), enabled = pair.isEnabled) {
Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp))
}
else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = syncRotation * 0f })
}
}
}
val displayProgress = when {
pair.lastSyncResult == SyncStatus.SYNCING -> progress
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
else -> null
}
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
Text(
"Starting…",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
} else if (displayProgress != null) {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (displayProgress.uploaded > 0) {
Icon(Icons.Default.ArrowUpward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary)
Text("${displayProgress.uploaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
}
if (displayProgress.downloaded > 0) {
Icon(Icons.Default.ArrowDownward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.secondary)
Text("${displayProgress.downloaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
}
if (displayProgress.deleted > 0) {
Icon(Icons.Default.DeleteOutline, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.error)
Text("${displayProgress.deleted}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
}
if (displayProgress.bytesTransferred > 0) {
Text(
"· ${displayProgress.bytesTransferred.toDisplaySize()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -189,6 +239,7 @@ private fun StatusPill(status: SyncStatus) {
val (icon, label) = when (status) {
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
@@ -245,13 +296,21 @@ 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 -> Color(0xFF2E7D32) // green — done, healthy
SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress
SyncStatus.FAILED -> Color(0xFFC62828) // red — error
SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed
SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution
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
}
@@ -4,15 +4,20 @@ import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import com.syncflow.data.db.SyncPairDao
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import com.syncflow.worker.FileWatchService
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -27,11 +32,33 @@ class HomeViewModel @Inject constructor(
val syncPairs = syncPairDao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
val syncProgressMap: kotlinx.coroutines.flow.StateFlow<Map<Long, SyncProgress>> =
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
.map { infos ->
infos
.mapNotNull { info ->
val tag = info.tags.firstOrNull { it.startsWith("sync_") } ?: return@mapNotNull null
val pairId = tag.removePrefix("sync_").toLongOrNull() ?: return@mapNotNull null
val up = info.progress.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0)
val down = info.progress.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0)
val del = info.progress.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0)
val bytes = info.progress.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L)
if (up > 0 || down > 0 || del > 0) pairId to SyncProgress(up, down, del, bytes) else null
}
.toMap()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
fun triggerSync(pair: SyncPairEntity) {
val req = SyncWorker.buildOneTimeRequest(pair.id, wifiOnly = false, chargingOnly = false)
workManager.enqueue(req)
}
fun pauseSync(pair: SyncPairEntity) {
workManager.cancelAllWorkByTag("sync_${pair.id}")
viewModelScope.launch { syncPairDao.updateStatus(pair.id, SyncStatus.PAUSED) }
}
fun toggleEnabled(pair: SyncPairEntity) {
viewModelScope.launch {
val nowEnabled = !pair.isEnabled
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.syncflow.data.db.entities.SyncPairEntity
import com.syncflow.domain.model.SyncStatus
import com.syncflow.ui.shared.SyncProgress
import com.syncflow.ui.shared.SyncEventRow
import java.time.Duration
import java.time.Instant
@@ -40,6 +41,7 @@ fun PairDetailScreen(
val pair by vm.pair.collectAsState()
val events by vm.events.collectAsState()
val conflictCount by vm.unresolvedConflicts.collectAsState()
val syncProgress by vm.syncProgress.collectAsState()
var showDelete by remember { mutableStateOf(false) }
if (showDelete) {
@@ -66,7 +68,17 @@ fun PairDetailScreen(
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
actions = {
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
when (pair?.lastSyncResult) {
SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) {
Icon(Icons.Default.Pause, "Pause sync")
}
SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }, enabled = pair?.isEnabled == true) {
Icon(Icons.Default.PlayArrow, "Resume sync")
}
else -> IconButton(onClick = { vm.syncNow() }) {
Icon(Icons.Default.Sync, "Sync now")
}
}
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
},
)
@@ -78,7 +90,7 @@ fun PairDetailScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
pair?.let { p -> StatusBanner(p) }
pair?.let { p -> StatusBanner(p, syncProgress) }
}
item {
@@ -138,10 +150,11 @@ fun PairDetailScreen(
}
@Composable
private fun StatusBanner(pair: SyncPairEntity) {
private fun StatusBanner(pair: SyncPairEntity, progress: SyncProgress? = null) {
val (icon, label, containerColor) = when (pair.lastSyncResult) {
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
SyncStatus.PAUSED -> Triple(Icons.Default.Pause, "Paused — tap ▶ to resume", MaterialTheme.colorScheme.surfaceVariant)
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
@@ -170,8 +183,39 @@ private fun StatusBanner(pair: SyncPairEntity) {
Spacer(Modifier.width(16.dp))
Column {
Text(label, style = MaterialTheme.typography.titleMedium)
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
val displayProgress = when {
pair.lastSyncResult == SyncStatus.SYNCING -> progress
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
else -> null
}
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
Text("Starting…", style = MaterialTheme.typography.bodySmall)
} else if (displayProgress != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (displayProgress.uploaded > 0) {
Icon(Icons.Default.ArrowUpward, null, Modifier.size(12.dp))
Text("${displayProgress.uploaded} up", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.downloaded > 0) {
Icon(Icons.Default.ArrowDownward, null, Modifier.size(12.dp))
Text("${displayProgress.downloaded} down", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.deleted > 0) {
Icon(Icons.Default.DeleteOutline, null, Modifier.size(12.dp))
Text("${displayProgress.deleted} del", style = MaterialTheme.typography.bodySmall)
}
if (displayProgress.bytesTransferred > 0) {
Text("· ${displayProgress.bytesTransferred.toDisplaySize()}", style = MaterialTheme.typography.bodySmall)
}
}
} else {
pair.lastSyncAt?.let {
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
}
}
}
}
@@ -229,6 +273,13 @@ private fun InfoRow(
}
}
private fun Long.toDisplaySize(): String = when {
this < 1_024 -> "$this B"
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
}
private fun String.toDisplayPath(): String {
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
@@ -3,13 +3,17 @@ package com.syncflow.ui.pairdetail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.syncflow.data.db.SyncConflictDao
import com.syncflow.data.db.SyncEventDao
import com.syncflow.data.db.SyncPairDao
import com.syncflow.domain.model.SyncStatus
import com.syncflow.worker.SyncWorker
import dagger.hilt.android.lifecycle.HiltViewModel
import com.syncflow.ui.shared.SyncProgress
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -34,11 +38,30 @@ class PairDetailViewModel @Inject constructor(
val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val syncProgress = workManager.getWorkInfosByTagFlow("sync_$pairId")
.map { infos ->
infos.firstOrNull { it.state == WorkInfo.State.RUNNING }?.progress?.let { data ->
SyncProgress(
uploaded = data.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0),
downloaded = data.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0),
deleted = data.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0),
bytesTransferred = data.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L),
).takeIf { it.uploaded > 0 || it.downloaded > 0 || it.deleted > 0 }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun syncNow() {
val p = pair.value ?: return
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false))
}
fun pauseSync() {
val p = pair.value ?: return
workManager.cancelAllWorkByTag("sync_${p.id}")
viewModelScope.launch { syncPairDao.updateStatus(p.id, SyncStatus.PAUSED) }
}
fun delete() {
viewModelScope.launch {
pair.value?.let { syncPairDao.delete(it) }
@@ -0,0 +1,3 @@
package com.syncflow.ui.shared
data class SyncProgress(val uploaded: Int, val downloaded: Int, val deleted: Int, val bytesTransferred: Long)
@@ -47,7 +47,14 @@ class SyncWorker @AssistedInject constructor(
return try {
val domainPair = pair.toDomain()
val provider = providerFactory.create(account)
val result = syncEngine.sync(domainPair, provider)
val result = syncEngine.sync(domainPair, provider) { up, down, del, bytes ->
setProgress(workDataOf(
KEY_PROGRESS_UPLOADED to up,
KEY_PROGRESS_DOWNLOADED to down,
KEY_PROGRESS_DELETED to del,
KEY_PROGRESS_BYTES to bytes,
))
}
val lines = buildList {
if (result.uploaded > 0) add("${result.uploaded}")
@@ -158,6 +165,10 @@ class SyncWorker @AssistedInject constructor(
const val KEY_PAIR_ID = "pair_id"
const val KEY_SILENT = "silent"
const val KEY_RESULT_SUMMARY = "result_summary"
const val KEY_PROGRESS_UPLOADED = "prog_up"
const val KEY_PROGRESS_DOWNLOADED = "prog_down"
const val KEY_PROGRESS_DELETED = "prog_del"
const val KEY_PROGRESS_BYTES = "prog_bytes"
private const val NOTIFICATION_ID = 1001
private const val RESULT_ID_OFFSET = 2000
private const val CHANNEL_PROGRESS = "sync_progress"
@@ -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.54
VERSION_CODE=55
VERSION_NAME=1.0.73
VERSION_CODE=73