Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c415dceb22 | |||
| e1abf80f11 | |||
| 15b94a0407 | |||
| abec5276f9 | |||
| 4c24f45808 |
@@ -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)"
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ class SyncEngine @Inject constructor(
|
||||
val accessor = makeAccessor(pair.localPath)
|
||||
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
|
||||
val remoteFiles = provider.listFiles(pair.remotePath).getOrThrow()
|
||||
.filter { !it.isDirectory } // skip remote directories — they are not sync targets
|
||||
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
|
||||
val localFiles = accessor.walkFiles(pair)
|
||||
|
||||
@@ -93,7 +94,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
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME=1.0.65
|
||||
VERSION_CODE=66
|
||||
VERSION_NAME=1.0.66
|
||||
VERSION_CODE=67
|
||||
|
||||
Reference in New Issue
Block a user