Compare commits

...

7 Commits

Author SHA1 Message Date
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
7 changed files with 394 additions and 6 deletions
+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.
@@ -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"))
}
}
@@ -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 })
}
}
@@ -181,7 +181,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
}
}
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
// Build a properly percent-encoded URL. addPathSegments encodes each segment (spaces,
// ampersands, and — critically — non-ASCII like "café"), which keeps OkHttp from rejecting
// non-ASCII in the WebDAV MOVE "Destination" header and avoids malformed request URLs.
protected fun url(path: String): String {
val base = baseUrl.toHttpUrlOrNull() ?: return "$baseUrl/${path.trimStart('/')}"
return base.newBuilder().addPathSegments(path.trimStart('/')).build().toString()
}
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
val results = mutableListOf<RemoteFile>()
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.63
VERSION_CODE=64
VERSION_NAME=1.0.64
VERSION_CODE=65