Compare commits

...

12 Commits

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

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

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

Now the provider holds a persistent SSH session and reuses it for all
calls, reconnecting automatically if the connection drops.
2026-06-07 02:34:01 +00:00
amir 537808ca10 v1.0.70: single-source version (name always tracks build number)
Build & Release APK / build (push) Successful in 12m58s
versionName is now derived as 1.0.<versionCode>, so the git tag, APK filename,
and in-app About version are always the same number and can't drift.
2026-06-07 02:08:10 +00:00
amir 147da702a1 v1.0.68: fix two-way DATA LOSS — list remote recursively
Build & Release APK / build (push) Successful in 12m54s
The remote was listed Depth:1 (top level only) while the local folder is
walked recursively. Files inside remote subfolders looked 'missing from
remote', so TWO_WAY + mirror-delete ran DELETE_LOCAL and wiped them off the
device. Now walk the remote tree (Depth:1 per dir) so subfolder files are
matched and never falsely deleted.
2026-06-07 00:43:16 +00:00
amir cf2fd8c452 v1.0.67: bump version for release
Build & Release APK / build (push) Successful in 12m50s
2026-06-06 17:58:42 +00:00
amir c415dceb22 v1.0.60: skip remote directories in sync + reduce concurrency to 2
Build & Release APK / build (push) Successful in 12m49s
- Filter out isDirectory entries from remoteFiles so remote folders are
  never treated as files to sync (fixes phantom-directory 'Partial ✗5' status)
- Lower Semaphore from 4 → 2 to reduce concurrent SFTP sessions and
  avoid hitting server session limits
2026-06-06 17:45:32 +00:00
amir e1abf80f11 v1.0.66: fix scheduled background sync never registering on pair creation
Build & Release APK / build (push) Successful in 12m49s
Creating an interval/daily/weekly sync pair saved it enabled but never enqueued
the periodic WorkManager job — it only scheduled on the enable-toggle or a
reboot, so a freshly-created scheduled backup silently never ran in the
background. AddPairViewModel.save now registers the work (periodic / watcher)
on save, mirroring toggleEnabled + BootReceiver. Verified on-device: the
JobScheduler periodic job appears on save and a forced run performs the sync.
2026-06-05 21:08:42 +00:00
amir 15b94a0407 Add real-world large-file test (multi-GB from phone via external URL, chunked)
Build & Release APK / build (push) Successful in 12m58s
Opt-in (-e bigFileMB=<size>): streams a multi-GB file from the device through
the app's chunked-upload path to the external nextcloud.khodak.me and verifies
the full size lands. Verified live: 1.5 GB and 5 GB both succeed end-to-end.
2026-06-05 16:14:13 +00:00
amir abec5276f9 CI: create the Gitea release object if missing on tag (was failing to publish)
Build & Release APK / build (push) Successful in 12m49s
A pushed git tag doesn't create a Gitea release object, so the publish step
404'd trying to attach the APK. Now it creates the release if absent (with
contents:write permission), then uploads. v1.0.65 was published manually.
2026-06-05 16:05:13 +00:00
amir 4c24f45808 Add live SFTPGo WebDAV test (real 2nd WebDAV server via dav.khodak.me)
Build & Release APK / build (push) Successful in 12m53s
Tests the app's SFTPGo provider (WebDavProvider) end-to-end against a real
SFTPGo server over its exposed WebDAV URL: connect, mkdir, atomic upload,
list, download, overwrite, non-ASCII filename, delete. Validates the WebDAV
code path against a non-Nextcloud server. Creds via -e davUrl/davUser/davPass.
2026-06-05 16:02:57 +00:00
9 changed files with 236 additions and 27 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)"
+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
@@ -22,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
/**
@@ -37,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")
@@ -138,4 +142,44 @@ class NextcloudIntegrationTest {
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,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) }
}
}
}
@@ -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}")
}
}
}
}
@@ -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,7 +106,11 @@ 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.
val remoteFiles = listRemoteFilesRecursive(provider, pair.remotePath)
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
val localFiles = accessor.walkFiles(pair)
@@ -93,7 +127,7 @@ class SyncEngine @Inject constructor(
val allPaths = (localFiles.keys + remoteFiles.keys + knownStates.keys).toSet()
val hasPriorSyncState = knownStates.isNotEmpty()
val semaphore = Semaphore(4)
val semaphore = Semaphore(2) // limit concurrency to be gentle on the server
val uploadedAtomic = AtomicInteger(0)
val downloadedAtomic = AtomicInteger(0)
val deletedAtomic = AtomicInteger(0)
@@ -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.*
@@ -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)
}
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.65
VERSION_CODE=66
VERSION_NAME=1.0.73
VERSION_CODE=73