Compare commits

...

5 Commits

Author SHA1 Message Date
amir e1abf80f11 v1.0.66: fix scheduled background sync never registering on pair creation
Build & Release APK / build (push) Successful in 12m49s
Creating an interval/daily/weekly sync pair saved it enabled but never enqueued
the periodic WorkManager job — it only scheduled on the enable-toggle or a
reboot, so a freshly-created scheduled backup silently never ran in the
background. AddPairViewModel.save now registers the work (periodic / watcher)
on save, mirroring toggleEnabled + BootReceiver. Verified on-device: the
JobScheduler periodic job appears on save and a forced run performs the sync.
2026-06-05 21:08:42 +00:00
amir 15b94a0407 Add real-world large-file test (multi-GB from phone via external URL, chunked)
Build & Release APK / build (push) Successful in 12m58s
Opt-in (-e bigFileMB=<size>): streams a multi-GB file from the device through
the app's chunked-upload path to the external nextcloud.khodak.me and verifies
the full size lands. Verified live: 1.5 GB and 5 GB both succeed end-to-end.
2026-06-05 16:14:13 +00:00
amir abec5276f9 CI: create the Gitea release object if missing on tag (was failing to publish)
Build & Release APK / build (push) Successful in 12m49s
A pushed git tag doesn't create a Gitea release object, so the publish step
404'd trying to attach the APK. Now it creates the release if absent (with
contents:write permission), then uploads. v1.0.65 was published manually.
2026-06-05 16:05:13 +00:00
amir 4c24f45808 Add live SFTPGo WebDAV test (real 2nd WebDAV server via dav.khodak.me)
Build & Release APK / build (push) Successful in 12m53s
Tests the app's SFTPGo provider (WebDavProvider) end-to-end against a real
SFTPGo server over its exposed WebDAV URL: connect, mkdir, atomic upload,
list, download, overwrite, non-ASCII filename, delete. Validates the WebDAV
code path against a non-Nextcloud server. Creds via -e davUrl/davUser/davPass.
2026-06-05 16:02:57 +00:00
amir a348c43c66 v1.0.65: chunked upload for large files (>100MB) on Nextcloud
Build & Release APK / build (push) Successful in 12m58s
Big-file testing found single-PUT uploads 413 above the server's per-request
cap (Apache LimitRequestBody / PHP post_max_size / proxy limits). NextcloudProvider
now uploads files >chunkSize (100MB) via the dav/uploads chunked API: MKCOL a
session, PUT N chunks, then MOVE .file onto the destination (atomic assemble).
Bypasses any per-request cap so multi-GB files back up. Verified byte-exact
(multi-chunk) against live Nextcloud. SFTP already streams; single-PUT path
unchanged for <=100MB.
2026-06-05 15:45:47 +00:00
6 changed files with 285 additions and 14 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)"
@@ -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,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}")
}
}
}
@@ -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.64
VERSION_CODE=65
VERSION_NAME=1.0.66
VERSION_CODE=67