Compare commits

...

23 Commits

Author SHA1 Message Date
amir 019ba930d3 v1.0.76: multi-folder backup (many folders -> one remote base, each its own subfolder)
Build & Release APK / build (push) Successful in 12m53s
Adds an opt-in 'Back up multiple folders' mode to Add Sync: pick N folders +
one remote base, and the app creates one normal pair per folder at
base/<folderName>. Each source keeps its own subfolder so same-named files
across folders never collide/overwrite — the safe many-to-one.

- AddPairViewModel: multiFolder + localPaths state, batch save via buildEntity;
  folderLeafName() + uniqueSubName() derive collision-free remote subfolders
- AddPairScreen: mode toggle (new pairs only), folder list w/ add/remove,
  remote-base labelling
- SyncEngine: first-sync against a not-yet-created remote folder no longer fails
  — treat 404 as empty remote ONLY when no prior state exists (no DELETE_* can
  fire), and MKCOL the base dir so first uploads have a parent. An established
  pair still fails loudly on 404 (never mass-deletes a vanished remote).
- Tests: MultiFolderTest, isRemoteNotFound cases in ExcludePathTest
2026-06-07 13:49:59 +00:00
amir ddb558263f v1.0.75: exclude Android app-private trees (Android/data,media,obb)
Build & Release APK / build (push) Successful in 12m49s
Scoped storage (Android 11+) lets a SAF grant LIST another app's
Android/media|data|obb dir but not OPEN the files, so every transfer there
failed and the pair was stuck reporting Partial forever (65 failures under
Android/media/com.whatsapp/ on Zahra). These hold app-managed data, not user
content, so exclude the whole subtree on both sides via path-prefix match in
isExcludedPath. ExcludePathTest covers the new prefixes + case-insensitivity.
2026-06-07 13:01:05 +00:00
amir fb26e83484 v1.0.74: exclude OS-volatile junk (.thumbnails/.trashed-/.pending-/.sfpart) symmetrically
Build & Release APK / build (push) Successful in 12m50s
The .thumbnails cache was synced then DELETE_REMOTE'd every cycle: exclude
patterns were applied only to the local walk (filename-only), never to the
remote listing, so a previously-uploaded thumbnail looked local-missing and
got mirror-deleted endlessly as Android regenerates the cache.

- isExcludedPath(): path-segment-aware, hardcoded always-ignored set protects
  all existing pairs without a DB migration
- applied symmetrically to remote listing + merged path set (never upload,
  download, or delete an excluded path on either side)
- add .thumbnails to new-pair default excludePatterns
- ExcludePathTest covers cache/trash/pending/sfpart + user patterns
2026-06-07 05:30:00 +00:00
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
19 changed files with 1140 additions and 83 deletions
+14 -7
View File
@@ -10,6 +10,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write # needed to create the release object on a tag
steps:
- uses: actions/checkout@v4
@@ -63,12 +65,17 @@ jobs:
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)"
+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.
+7
View File
@@ -40,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.
+4 -1
View File
@@ -33,7 +33,10 @@ android {
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
@@ -7,7 +7,9 @@ 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
@@ -73,8 +75,10 @@ class FullSyncEngineTest {
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() }
@@ -86,8 +90,8 @@ class FullSyncEngineTest {
syncDirection = dir, conflictStrategy = conflict, deleteBehavior = delete, recursive = recursive,
scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 30, scheduleDailyTime = null, scheduleWeekdays = 0,
wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
excludePatterns = excludePatterns, includeExtensions = "", excludeExtensions = excludeExtensions,
skipHiddenFiles = skipHidden, minFileSizeKb = 0, maxFileSizeKb = maxKb,
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,
))
@@ -236,11 +240,226 @@ class FullSyncEngineTest {
assertEquals("newer local must win", "local-newer", remoteText("$remote/n.txt"))
}
// ── 13. Content integrity: binary-ish bytes round-trip exactly ───────────
// ── 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"))
}
}
@@ -13,6 +13,7 @@ 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
@@ -21,6 +22,9 @@ 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
/**
@@ -36,6 +40,7 @@ import java.time.Instant
@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")
@@ -114,4 +119,67 @@ class NextcloudIntegrationTest {
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) }
}
}
}
@@ -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()
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)
}
}
@@ -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))
@@ -149,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}")
}
}
}
}
@@ -181,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>()
@@ -68,6 +68,36 @@ 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,
@@ -76,8 +106,32 @@ class SyncEngine @Inject constructor(
): 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.
// Exclusions are filtered on BOTH sides. The local walk already drops excluded files,
// but the remote listing did not, so any excluded path that already existed on the
// server (e.g. a previously-uploaded ".thumbnails" entry) looked "local-missing" and
// got DELETE_REMOTE'd every cycle — endless churn as Android regenerates the cache.
// Filtering remote + the merged path set makes excluded paths invisible to the engine:
// never uploaded, downloaded, or deleted on either side.
val remoteFiles = try {
listRemoteFilesRecursive(provider, pair.remotePath)
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
.filterKeys { !isExcludedPath(it, pair) }
} catch (e: Exception) {
// A brand-new pair whose remote folder doesn't exist yet 404s on the first listing —
// that simply means "empty remote", so everything uploads. This is ONLY safe with no
// prior sync state: on a first sync every known==null, so no DELETE_* branch can fire
// (verified in syncDecide). Once state exists, a 404 means the remote folder vanished
// or is unreachable, and treating it as empty would mirror-delete every local file —
// so we rethrow and let the sync fail loudly instead of destroying data.
if (knownStates.isEmpty() && isRemoteNotFound(e)) {
runCatching { ensureRemoteBaseDir(provider, pair.remotePath) }
emptyMap()
} else throw e
}
val localFiles = accessor.walkFiles(pair)
// Self-healing: if every known-state path is absent from the current local scan but
@@ -91,9 +145,14 @@ class SyncEngine @Inject constructor(
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
}
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
// knownStates may still hold records for now-excluded paths (e.g. thumbnails uploaded
// by an older build). Drop them from the work set so they aren't acted on; their stale
// state rows are harmless and ignored.
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys)
.filter { !isExcludedPath(it, pair) }
.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)
@@ -252,6 +311,20 @@ class SyncEngine @Inject constructor(
)
}
/**
* Create every path component of the pair's remote root (e.g. /Backup/DCIM → MKCOL /Backup
* then /Backup/DCIM) so the first uploads of a brand-new pair have a parent to land in.
* Only called on a first sync where the root listing 404'd; existing-dir MKCOLs fail harmlessly.
*/
private suspend fun ensureRemoteBaseDir(provider: CloudProvider, remotePath: String) {
val parts = remotePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }
var current = ""
for (part in parts) {
current = "$current/$part"
provider.createDirectory(current).onFailure { e -> Timber.w("MKCOL base $current: ${e.message}") }
}
}
private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
val parts = rel.replace('\\', '/').split('/')
var currentPath = remotePairPath
@@ -375,6 +448,63 @@ internal fun syncDecide(
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
/**
* Heuristic: did a provider call fail because the remote path doesn't exist (HTTP 404)? Providers
* surface errors as exceptions carrying the HTTP status in the message, so we match on that. Used
* only to let a first-ever sync proceed against a not-yet-created remote folder (see performSync).
*/
internal fun isRemoteNotFound(e: Throwable): Boolean {
val m = (e.message ?: "").lowercase()
return "404" in m || "not found" in m || "notfound" in m
}
/**
* OS-generated, volatile paths that must NEVER sync on any pair, regardless of user exclude
* config. These are matched against every path SEGMENT (directory names included), so an entire
* subtree like "DCIM/.thumbnails/..." is ignored. Android continuously regenerates and evicts
* its thumbnail cache and shuffles files through .trashed-/.pending- staging dirs; syncing them
* produces an endless upload→evict→DELETE_REMOTE→regenerate loop. ".sfpart" is our own atomic-
* write temp suffix and must never be propagated either.
*/
private val ALWAYS_IGNORED_SEGMENTS = setOf(".thumbnails")
private val ALWAYS_IGNORED_PREFIXES = listOf(".trashed-", ".pending-")
private val ALWAYS_IGNORED_SUFFIXES = listOf(".sfpart")
/**
* App-private storage trees. On Android 11+ (scoped storage) another app's SAF grant can LIST
* these directories but cannot OPEN the files inside, so every transfer fails and the pair is
* stuck reporting "Partial" forever (e.g. Android/media/com.whatsapp/...). They hold app-managed
* data, not user content worth syncing, so they are excluded entirely. Matched case-insensitively
* against the full relative path so the whole subtree is ignored on both sides.
*/
private val ALWAYS_IGNORED_PATH_PREFIXES = listOf("android/data/", "android/media/", "android/obb/")
/**
* True if [rel] should be excluded from sync entirely. Applied symmetrically to the local walk,
* the remote listing, and known state so an excluded path is never uploaded, downloaded, or
* deleted. The always-ignored rules run on every segment; user-configured rules
* (skipHiddenFiles, excludePatterns, excludeExtensions) match the filename, mirroring the
* existing local-walk semantics in LocalAccessor.
*/
internal fun isExcludedPath(rel: String, pair: SyncPair): Boolean {
val normalized = rel.replace('\\', '/')
val lower = normalized.lowercase()
if (ALWAYS_IGNORED_PATH_PREFIXES.any { lower.startsWith(it) }) return true
val segments = normalized.split('/').filter { it.isNotEmpty() }
if (segments.isEmpty()) return false
for (seg in segments) {
if (seg in ALWAYS_IGNORED_SEGMENTS) return true
if (ALWAYS_IGNORED_PREFIXES.any { seg.startsWith(it) }) return true
if (ALWAYS_IGNORED_SUFFIXES.any { seg.endsWith(it) }) return true
}
val fileName = segments.last()
if (pair.skipHiddenFiles && fileName.startsWith('.')) return true
if (pair.excludePatterns.any { pat -> fileName.matches(globToRegex(pat)) }) return true
val ext = fileName.substringAfterLast('.', "").lowercase()
if (pair.excludeExtensions.any { ext == it.lowercase().trimStart('.') }) return true
return false
}
/**
* 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
@@ -52,7 +52,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" },
onSelect = { path ->
vm.update { copy(localPath = path) }
if (s.multiFolder) vm.addLocalFolder(path) else vm.update { copy(localPath = path) }
showLocalBrowser = false
},
onDismiss = { showLocalBrowser = false },
@@ -94,7 +94,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Section(title = null) {
OutlinedTextField(
value = s.name, onValueChange = { vm.update { copy(name = it) } },
label = { Text("Sync pair name") },
label = { Text(if (s.multiFolder) "Group name prefix (optional)" else "Sync pair name") },
leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
@@ -122,7 +122,40 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
Spacer(Modifier.height(4.dp))
// Local folder
// Multi-folder mode toggle (new pairs only — editing stays single-folder)
if (!s.isEditing) {
ToggleRow(
label = "Back up multiple folders",
description = "Pick several folders; each is saved as its own subfolder under the remote base (no overwrites)",
checked = s.multiFolder,
onToggle = { vm.setMultiFolder(it) },
)
Spacer(Modifier.height(4.dp))
}
if (s.multiFolder) {
// Chosen folders list
s.localPaths.forEach { p ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Folder, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text(uriToDisplay(p), style = MaterialTheme.typography.bodyMedium,
maxLines = 1, modifier = Modifier.weight(1f))
IconButton(onClick = { vm.removeLocalFolder(p) }) {
Icon(Icons.Default.Close, "Remove folder")
}
}
}
OutlinedButton(onClick = { showLocalBrowser = true }, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(6.dp))
Text("Add folder")
}
} else {
// Single local folder
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = uriToDisplay(s.localPath), onValueChange = {},
@@ -134,11 +167,12 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
)
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
}
}
// Remote folder
// Remote folder (base, in multi-folder mode)
OutlinedTextField(
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } },
label = { Text("Remote folder") },
label = { Text(if (s.multiFolder) "Remote base folder" else "Remote folder") },
leadingIcon = { Icon(Icons.Default.Cloud, null) },
trailingIcon = {
IconButton(
@@ -147,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
) { Icon(Icons.Default.Folder, "Browse remote") }
},
singleLine = true, modifier = Modifier.fillMaxWidth(),
placeholder = { Text("/ or /Documents/Photos") },
placeholder = { Text(if (s.multiFolder) "/Backup — each folder becomes a subfolder" else "/ or /Documents/Photos") },
)
// Recursive
@@ -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.*
@@ -22,6 +25,10 @@ data class AddPairUiState(
val name: String = "",
// ── Folders ──────────────────────────────────────────────────────────────
val localPath: String = "",
// Multi-folder mode: pick several local folders that each back up to their own subfolder
// under one remote base. Empty unless multiFolder is on.
val multiFolder: Boolean = false,
val localPaths: List<String> = emptyList(),
val remotePath: String = "",
val selectedAccountId: Long = -1L,
val accounts: List<CloudAccountEntity> = emptyList(),
@@ -44,7 +51,7 @@ data class AddPairUiState(
val chargingOnly: Boolean = false,
val minBatteryPct: Int = 0,
// ── File filters ─────────────────────────────────────────────────────────
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db",
val excludePatterns: String = ".DS_Store\n*.tmp\n.nomedia\nThumbs.db\n.thumbnails",
val includeExtensions: String = "",
val excludeExtensions: String = "",
val skipHiddenFiles: Boolean = true,
@@ -54,6 +61,7 @@ data class AddPairUiState(
val notifyOnComplete: Boolean = false,
val notifyOnError: Boolean = true,
// ── Form state ───────────────────────────────────────────────────────────
val isEditing: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
val done: Boolean = false,
@@ -69,6 +77,31 @@ internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
}
/**
* Last path component of a local folder path or SAF content:// tree URI, used as the remote
* subfolder name in multi-folder mode. Handles URL-encoded tree URIs
* (content://…/tree/primary%3ADCIM%2FCamera → "Camera") and plain filesystem paths.
*/
internal fun folderLeafName(path: String): String {
val decoded = try { java.net.URLDecoder.decode(path, "UTF-8") } catch (e: Exception) { path }
val afterColon = decoded.substringAfterLast(':') // strip the "primary:" storage-volume prefix
val leaf = afterColon.trimEnd('/').substringAfterLast('/')
return leaf.ifBlank { "folder" }
}
/**
* Make a remote subfolder name unique within one multi-folder batch so two source folders that
* share a leaf name (e.g. DCIM/Camera and Movies/Camera) don't collide into one remote dir.
* Slashes are flattened to underscores; collisions get a numeric suffix.
*/
internal fun uniqueSubName(base: String, used: MutableSet<String>): String {
val clean = base.replace('/', '_').replace('\\', '_').ifBlank { "folder" }
if (used.add(clean)) return clean
var n = 2
while (!used.add("${clean}_$n")) n++
return "${clean}_$n"
}
@HiltViewModel
class AddPairViewModel @Inject constructor(
private val syncPairDao: SyncPairDao,
@@ -80,7 +113,7 @@ class AddPairViewModel @Inject constructor(
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
private val _state = MutableStateFlow(AddPairUiState())
private val _state = MutableStateFlow(AddPairUiState(isEditing = editPairId != null))
val state = _state.asStateFlow()
init {
@@ -99,6 +132,7 @@ class AddPairViewModel @Inject constructor(
syncPairDao.getById(id)?.let { pair ->
_state.update { _ ->
AddPairUiState(
isEditing = true,
name = pair.name,
localPath = pair.localPath,
remotePath = pair.remotePath,
@@ -145,12 +179,25 @@ class AddPairViewModel @Inject constructor(
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
}
fun setMultiFolder(enabled: Boolean) = _state.update { it.copy(multiFolder = enabled) }
fun addLocalFolder(path: String) = _state.update {
if (path.isBlank() || path in it.localPaths) it else it.copy(localPaths = it.localPaths + path)
}
fun removeLocalFolder(path: String) = _state.update { it.copy(localPaths = it.localPaths - path) }
fun save() {
val s = _state.value
val errors = buildList {
if (s.multiFolder) {
if (s.localPaths.isEmpty()) add("Add at least one folder")
if (s.remotePath.isBlank()) add("Remote base folder is required")
} else {
if (s.name.isBlank()) add("Name is required")
if (s.localPath.isBlank()) add("Local folder is required")
if (s.remotePath.isBlank()) add("Remote folder is required")
}
if (s.selectedAccountId == -1L) add("Select a cloud account")
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
}
@@ -159,9 +206,50 @@ class AddPairViewModel @Inject constructor(
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null) }
runCatching {
val entity = SyncPairEntity(
id = editPairId ?: 0L,
name = s.name, localPath = s.localPath, remotePath = s.remotePath,
if (s.multiFolder) {
// One normal pair per folder, each into its OWN subfolder under the remote base.
// Keeping each source in a distinct subfolder is what makes many-to-one safe —
// flattening would let same-named files from different folders overwrite each other.
val base = s.remotePath.trimEnd('/')
val used = mutableSetOf<String>()
s.localPaths.map { folder ->
val sub = uniqueSubName(folderLeafName(folder), used)
val pairName = if (s.name.isBlank()) sub else "${s.name}$sub"
val entity = buildEntity(s, name = pairName, localPath = folder, remotePath = "$base/$sub", id = 0L)
entity.copy(id = syncPairDao.insert(entity))
}
} else {
val entity = buildEntity(s, name = s.name, localPath = s.localPath, remotePath = s.remotePath, id = editPairId ?: 0L)
val pairId = if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
syncPairDao.update(entity)
// If local or remote folder changed, old file-state records no longer
// correspond to any real path — wipe them so the next sync starts fresh
// instead of trying to delete/re-upload stale paths.
if (existing != null &&
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
) {
fileStateDao.deleteForPair(editPairId)
}
editPairId
}
listOf(entity.copy(id = pairId))
}
}
.onSuccess { saved ->
saved.forEach { applySchedule(it) }
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
private fun buildEntity(s: AddPairUiState, name: String, localPath: String, remotePath: String, id: Long) =
SyncPairEntity(
id = id,
name = name, localPath = localPath, remotePath = remotePath,
accountId = s.selectedAccountId,
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
@@ -176,26 +264,29 @@ class AddPairViewModel @Inject constructor(
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
)
if (editPairId == null) {
syncPairDao.insert(entity)
} else {
val existing = syncPairDao.getById(editPairId)
syncPairDao.update(entity)
// If local or remote folder changed, old file-state records no longer
// correspond to any real path — wipe them so the next sync starts fresh
// instead of trying to delete/re-upload stale paths.
if (existing != null &&
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
) {
fileStateDao.deleteForPair(editPairId)
/**
* 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)
}
}
}
.onSuccess {
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
_state.update { it.copy(done = true) }
}
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
}
}
}
@@ -0,0 +1,106 @@
package com.syncflow.domain.sync
import com.syncflow.domain.model.ConflictStrategy
import com.syncflow.domain.model.DeleteBehavior
import com.syncflow.domain.model.ScheduleType
import com.syncflow.domain.model.SyncDirection
import com.syncflow.domain.model.SyncPair
import com.syncflow.domain.model.SyncStatus
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Exclusion symmetry: paths excluded from the local walk must also be excluded from the remote
* listing and the merged work set, so a previously-uploaded excluded file is never DELETE_REMOTE'd
* in an endless churn loop (the ".thumbnails" cache regression).
*/
class ExcludePathTest {
private fun pair(
excludePatterns: List<String> = emptyList(),
excludeExtensions: List<String> = emptyList(),
skipHiddenFiles: Boolean = false,
) = SyncPair(
id = 1, name = "t", localPath = "/l", remotePath = "/r", accountId = 1,
syncDirection = SyncDirection.TWO_WAY,
conflictStrategy = ConflictStrategy.KEEP_NEWEST,
deleteBehavior = DeleteBehavior.MIRROR,
recursive = true,
scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 0,
scheduleDailyTime = null, scheduleWeekdays = 0,
wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
excludePatterns = excludePatterns, includeExtensions = emptyList(),
excludeExtensions = excludeExtensions, skipHiddenFiles = skipHiddenFiles,
minFileSizeKb = 0, maxFileSizeKb = 0,
notifyOnComplete = false, notifyOnError = false,
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE,
pendingConflicts = 0,
)
@Test fun `thumbnails cache is always excluded regardless of config`() {
val p = pair()
assertTrue(isExcludedPath("DCIM/.thumbnails/123.jpg", p))
assertTrue(isExcludedPath("Pictures/.thumbnails/1000020397.jpg", p))
assertTrue(isExcludedPath(".thumbnails/x.jpg", p))
}
@Test fun `android trash and pending staging dirs are excluded`() {
val p = pair()
assertTrue(isExcludedPath("DCIM/Camera/.trashed-1700000000-IMG_0001.jpg", p))
assertTrue(isExcludedPath("DCIM/.pending-1700000000-VID_0001.mp4", p))
}
@Test fun `our own atomic-write temp files are excluded`() {
val p = pair()
assertTrue(isExcludedPath("Download/.movie.mp4.sfpart", p))
assertTrue(isExcludedPath("a/b/.photo.jpg.sfpart", p))
}
@Test fun `android app-private trees are excluded (scoped-storage unreadable)`() {
val p = pair()
assertTrue(isExcludedPath("Android/media/com.whatsapp/WhatsApp/Media/IMG.jpg", p))
assertTrue(isExcludedPath("Android/data/com.foo/files/x.bin", p))
assertTrue(isExcludedPath("Android/obb/com.game/main.obb", p))
assertTrue(isExcludedPath("android/MEDIA/com.x/y.jpg", p)) // case-insensitive
}
@Test fun `non-private Android paths are not excluded`() {
val p = pair()
// A user folder literally named "Android" at a deeper level is fine; only the
// top-level app-private trees are blocked.
assertFalse(isExcludedPath("DCIM/Android/holiday.jpg", p))
assertFalse(isExcludedPath("Pictures/android-wallpaper.png", p))
}
@Test fun `normal media files are not excluded`() {
val p = pair()
assertFalse(isExcludedPath("DCIM/Camera/IMG_0001.jpg", p))
assertFalse(isExcludedPath("Pictures/cat.png", p))
assertFalse(isExcludedPath("اصفهان/20171023.jpg", p)) // unicode folder, real data
}
@Test fun `user exclude patterns and extensions still apply on filename`() {
assertTrue(isExcludedPath("a/Thumbs.db", pair(excludePatterns = listOf("Thumbs.db"))))
assertTrue(isExcludedPath("a/note.tmp", pair(excludePatterns = listOf("*.tmp"))))
assertTrue(isExcludedPath("a/data.log", pair(excludeExtensions = listOf("log"))))
assertFalse(isExcludedPath("a/keep.jpg", pair(excludeExtensions = listOf("log"))))
}
@Test fun `skipHiddenFiles excludes dotfiles by filename only`() {
assertTrue(isExcludedPath("a/.hidden", pair(skipHiddenFiles = true)))
assertFalse(isExcludedPath("a/.hidden", pair(skipHiddenFiles = false)))
}
@Test fun `remote-not-found detected from http status messages`() {
assertTrue(isRemoteNotFound(Exception("HTTP 404")))
assertTrue(isRemoteNotFound(Exception("Not Found")))
assertTrue(isRemoteNotFound(RuntimeException("PROPFIND failed: notfound")))
}
@Test fun `remote-not-found is false for other errors`() {
assertFalse(isRemoteNotFound(Exception("HTTP 500")))
assertFalse(isRemoteNotFound(Exception("timeout")))
assertFalse(isRemoteNotFound(Exception(null as String?)))
}
}
@@ -0,0 +1,41 @@
package com.syncflow.ui.addpair
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Multi-folder backup: each selected folder maps to its OWN remote subfolder. The leaf-name and
* uniqueness helpers are what keep that safe — same-named folders from different parents must not
* collapse into one remote dir and overwrite each other.
*/
class MultiFolderTest {
@Test fun `leaf name from SAF tree uri`() {
assertEquals("Camera", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM%2FCamera"))
assertEquals("DCIM", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM"))
}
@Test fun `leaf name from plain filesystem path`() {
assertEquals("Camera", folderLeafName("/storage/emulated/0/DCIM/Camera"))
assertEquals("Pictures", folderLeafName("/storage/emulated/0/Pictures/"))
}
@Test fun `blank-ish paths fall back to folder`() {
assertEquals("folder", folderLeafName(""))
assertEquals("folder", folderLeafName("/"))
}
@Test fun `unique sub names disambiguate collisions`() {
val used = mutableSetOf<String>()
assertEquals("Camera", uniqueSubName("Camera", used))
assertEquals("Camera_2", uniqueSubName("Camera", used))
assertEquals("Camera_3", uniqueSubName("Camera", used))
assertEquals("Pictures", uniqueSubName("Pictures", used))
}
@Test fun `unique sub name flattens slashes`() {
val used = mutableSetOf<String>()
assertTrue('/' !in uniqueSubName("a/b/c", used))
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.63
VERSION_CODE=64
VERSION_NAME=1.0.76
VERSION_CODE=76