Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb26e83484 | |||
| 0131d8d4fd | |||
| d2ca3f1918 | |||
| 812b40b42f | |||
| b7ec3f4ad3 | |||
| 537808ca10 | |||
| 147da702a1 | |||
| cf2fd8c452 | |||
| c415dceb22 | |||
| e1abf80f11 | |||
| 15b94a0407 | |||
| abec5276f9 | |||
| 4c24f45808 | |||
| a348c43c66 | |||
| f90d84e1fc | |||
| 10007eb4fb | |||
| 29b5d555b8 | |||
| 369e260158 | |||
| 1ecae2c690 | |||
| 39aa2f7dfd | |||
| 402d0447a0 |
@@ -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)"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,19 @@ 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 = listRemoteFilesRecursive(provider, pair.remotePath)
|
||||
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
|
||||
.filterKeys { !isExcludedPath(it, pair) }
|
||||
val localFiles = accessor.walkFiles(pair)
|
||||
|
||||
// Self-healing: if every known-state path is absent from the current local scan but
|
||||
@@ -91,9 +132,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)
|
||||
@@ -375,6 +421,41 @@ internal fun syncDecide(
|
||||
|
||||
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
|
||||
|
||||
/**
|
||||
* 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")
|
||||
|
||||
/**
|
||||
* 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 segments = rel.replace('\\', '/').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
|
||||
|
||||
@@ -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.*
|
||||
@@ -44,7 +47,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,
|
||||
@@ -176,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)
|
||||
@@ -189,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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 `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)))
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME=1.0.63
|
||||
VERSION_CODE=64
|
||||
VERSION_NAME=1.0.74
|
||||
VERSION_CODE=74
|
||||
|
||||
Reference in New Issue
Block a user