Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 019ba930d3 | |||
| ddb558263f | |||
| fb26e83484 | |||
| 0131d8d4fd | |||
| d2ca3f1918 | |||
| 812b40b42f | |||
| b7ec3f4ad3 | |||
| 537808ca10 | |||
| 147da702a1 | |||
| cf2fd8c452 | |||
| c415dceb22 | |||
| e1abf80f11 | |||
| 15b94a0407 | |||
| abec5276f9 | |||
| 4c24f45808 | |||
| a348c43c66 |
@@ -10,6 +10,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # needed to create the release object on a tag
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -63,12 +65,17 @@ jobs:
|
|||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
VERSION: ${{ steps.ver.outputs.name }}
|
VERSION: ${{ steps.ver.outputs.name }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID=$(curl -sf \
|
API="https://gitea.khodak.me/api/v1/repos/amir/SyncFlow"
|
||||||
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/tags/$TAG" \
|
# A pushed git tag does NOT create a Gitea release object — fetch it, create if missing.
|
||||||
-H "Authorization: token $TOKEN" \
|
RELEASE_ID=$(curl -s "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" \
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
| python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('id','') if isinstance(d,dict) else '')" 2>/dev/null)
|
||||||
curl -sf -X POST \
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
"https://gitea.khodak.me/api/v1/repos/amir/SyncFlow/releases/$RELEASE_ID/assets" \
|
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" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
|
-F "attachment=@dist/SyncFlow-v${VERSION}.apk"
|
||||||
echo "APK uploaded to release $TAG"
|
echo "APK uploaded to release $TAG (id $RELEASE_ID)"
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = versionProps["VERSION_CODE"].toString().toInt()
|
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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
// Placeholder — replace with real keys before release
|
// Placeholder — replace with real keys before release
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.syncflow.domain.sync.LocalFileInfo
|
|||||||
import com.syncflow.domain.sync.SyncDecision
|
import com.syncflow.domain.sync.SyncDecision
|
||||||
import com.syncflow.domain.sync.syncDecide
|
import com.syncflow.domain.sync.syncDecide
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -21,6 +22,9 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +40,7 @@ import java.time.Instant
|
|||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class NextcloudIntegrationTest {
|
class NextcloudIntegrationTest {
|
||||||
|
|
||||||
|
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val args = InstrumentationRegistry.getArguments()
|
private val args = InstrumentationRegistry.getArguments()
|
||||||
private val url = args.getString("ncUrl")
|
private val url = args.getString("ncUrl")
|
||||||
private val user = args.getString("ncUser")
|
private val user = args.getString("ncUser")
|
||||||
@@ -114,4 +119,67 @@ class NextcloudIntegrationTest {
|
|||||||
runCatching { p.deleteFile(dir) } // best-effort cleanup of the test folder
|
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.data.providers.webdav.WebDavProvider
|
||||||
import com.syncflow.domain.model.CloudAccount
|
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
|
override val baseUrl: String
|
||||||
get() {
|
get() {
|
||||||
val server = account.serverUrl?.trimEnd('/') ?: ""
|
val server = account.serverUrl?.trimEnd('/') ?: ""
|
||||||
val email = account.email ?: "user"
|
val email = account.email ?: "user"
|
||||||
return "$server/remote.php/dav/files/$email"
|
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 password = creds["password"]?.jsonPrimitive?.content
|
||||||
private val privateKey = creds["private_key"]?.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()
|
val ssh = SSHClient()
|
||||||
ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
|
ssh.addHostKeyVerifier(TofuHostKeyVerifier(credentialStore))
|
||||||
ssh.connect(host, port)
|
ssh.connect(host, port)
|
||||||
try {
|
if (!privateKey.isNullOrBlank()) {
|
||||||
if (!privateKey.isNullOrBlank()) {
|
ssh.authPublickey(username, ssh.loadKeys(privateKey, null, null))
|
||||||
ssh.authPublickey(username, ssh.loadKeys(privateKey, null, null))
|
} else {
|
||||||
} else {
|
ssh.authPassword(username, password ?: "")
|
||||||
ssh.authPassword(username, password ?: "")
|
}
|
||||||
}
|
sshClient = ssh
|
||||||
return ssh.newSFTPClient().use(block)
|
return ssh
|
||||||
} finally {
|
}
|
||||||
ssh.disconnect()
|
|
||||||
|
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 ?: ""
|
val pass = creds["password"]?.jsonPrimitive?.content ?: ""
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.MINUTES)
|
||||||
|
.writeTimeout(5, TimeUnit.MINUTES)
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val req = chain.request().newBuilder()
|
val req = chain.request().newBuilder()
|
||||||
.header("Authorization", Credentials.basic(user, pass))
|
.header("Authorization", Credentials.basic(user, pass))
|
||||||
@@ -149,7 +150,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
|
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
|
||||||
client.newCall(req).execute().use { resp ->
|
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
|
else
|
||||||
LocalAccessor.JavaFile(File(localPath))
|
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(
|
private suspend fun performSync(
|
||||||
pair: SyncPair,
|
pair: SyncPair,
|
||||||
provider: CloudProvider,
|
provider: CloudProvider,
|
||||||
@@ -76,8 +106,32 @@ class SyncEngine @Inject constructor(
|
|||||||
): SyncResult {
|
): SyncResult {
|
||||||
val accessor = makeAccessor(pair.localPath)
|
val accessor = makeAccessor(pair.localPath)
|
||||||
var knownStates = fileStateDao.getForPair(pair.id).associateBy { it.relativePath }
|
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
|
||||||
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
|
// 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 = try {
|
||||||
|
listRemoteFilesRecursive(provider, pair.remotePath)
|
||||||
|
.associateBy { it.path.removePrefix(pair.remotePath).trimStart('/') }
|
||||||
|
.filterKeys { !isExcludedPath(it, pair) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// A brand-new pair whose remote folder doesn't exist yet 404s on the first listing —
|
||||||
|
// that simply means "empty remote", so everything uploads. This is ONLY safe with no
|
||||||
|
// prior sync state: on a first sync every known==null, so no DELETE_* branch can fire
|
||||||
|
// (verified in syncDecide). Once state exists, a 404 means the remote folder vanished
|
||||||
|
// or is unreachable, and treating it as empty would mirror-delete every local file —
|
||||||
|
// so we rethrow and let the sync fail loudly instead of destroying data.
|
||||||
|
if (knownStates.isEmpty() && isRemoteNotFound(e)) {
|
||||||
|
runCatching { ensureRemoteBaseDir(provider, pair.remotePath) }
|
||||||
|
emptyMap()
|
||||||
|
} else throw e
|
||||||
|
}
|
||||||
val localFiles = accessor.walkFiles(pair)
|
val localFiles = accessor.walkFiles(pair)
|
||||||
|
|
||||||
// Self-healing: if every known-state path is absent from the current local scan but
|
// Self-healing: if every known-state path is absent from the current local scan but
|
||||||
@@ -91,9 +145,14 @@ class SyncEngine @Inject constructor(
|
|||||||
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
|
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 hasPriorSyncState = knownStates.isNotEmpty()
|
||||||
val semaphore = Semaphore(4)
|
val semaphore = Semaphore(2) // limit concurrency to be gentle on the server
|
||||||
val uploadedAtomic = AtomicInteger(0)
|
val uploadedAtomic = AtomicInteger(0)
|
||||||
val downloadedAtomic = AtomicInteger(0)
|
val downloadedAtomic = AtomicInteger(0)
|
||||||
val deletedAtomic = AtomicInteger(0)
|
val deletedAtomic = AtomicInteger(0)
|
||||||
@@ -252,6 +311,20 @@ class SyncEngine @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create every path component of the pair's remote root (e.g. /Backup/DCIM → MKCOL /Backup
|
||||||
|
* then /Backup/DCIM) so the first uploads of a brand-new pair have a parent to land in.
|
||||||
|
* Only called on a first sync where the root listing 404'd; existing-dir MKCOLs fail harmlessly.
|
||||||
|
*/
|
||||||
|
private suspend fun ensureRemoteBaseDir(provider: CloudProvider, remotePath: String) {
|
||||||
|
val parts = remotePath.replace('\\', '/').split('/').filter { it.isNotEmpty() }
|
||||||
|
var current = ""
|
||||||
|
for (part in parts) {
|
||||||
|
current = "$current/$part"
|
||||||
|
provider.createDirectory(current).onFailure { e -> Timber.w("MKCOL base $current: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
|
private suspend fun ensureRemoteDirs(provider: CloudProvider, remotePairPath: String, rel: String) {
|
||||||
val parts = rel.replace('\\', '/').split('/')
|
val parts = rel.replace('\\', '/').split('/')
|
||||||
var currentPath = remotePairPath
|
var currentPath = remotePairPath
|
||||||
@@ -375,6 +448,63 @@ internal fun syncDecide(
|
|||||||
|
|
||||||
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
|
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic: did a provider call fail because the remote path doesn't exist (HTTP 404)? Providers
|
||||||
|
* surface errors as exceptions carrying the HTTP status in the message, so we match on that. Used
|
||||||
|
* only to let a first-ever sync proceed against a not-yet-created remote folder (see performSync).
|
||||||
|
*/
|
||||||
|
internal fun isRemoteNotFound(e: Throwable): Boolean {
|
||||||
|
val m = (e.message ?: "").lowercase()
|
||||||
|
return "404" in m || "not found" in m || "notfound" in m
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-private storage trees. On Android 11+ (scoped storage) another app's SAF grant can LIST
|
||||||
|
* these directories but cannot OPEN the files inside, so every transfer fails and the pair is
|
||||||
|
* stuck reporting "Partial" forever (e.g. Android/media/com.whatsapp/...). They hold app-managed
|
||||||
|
* data, not user content worth syncing, so they are excluded entirely. Matched case-insensitively
|
||||||
|
* against the full relative path so the whole subtree is ignored on both sides.
|
||||||
|
*/
|
||||||
|
private val ALWAYS_IGNORED_PATH_PREFIXES = listOf("android/data/", "android/media/", "android/obb/")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 normalized = rel.replace('\\', '/')
|
||||||
|
val lower = normalized.lowercase()
|
||||||
|
if (ALWAYS_IGNORED_PATH_PREFIXES.any { lower.startsWith(it) }) return true
|
||||||
|
val segments = normalized.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 ".."
|
* 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
|
* segment that would let a hostile remote escape the sync root via path traversal. Applied to
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
|||||||
LocalBrowserDialog(
|
LocalBrowserDialog(
|
||||||
initialPath = s.localPath.ifBlank { "" },
|
initialPath = s.localPath.ifBlank { "" },
|
||||||
onSelect = { path ->
|
onSelect = { path ->
|
||||||
vm.update { copy(localPath = path) }
|
if (s.multiFolder) vm.addLocalFolder(path) else vm.update { copy(localPath = path) }
|
||||||
showLocalBrowser = false
|
showLocalBrowser = false
|
||||||
},
|
},
|
||||||
onDismiss = { showLocalBrowser = false },
|
onDismiss = { showLocalBrowser = false },
|
||||||
@@ -94,7 +94,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
|||||||
Section(title = null) {
|
Section(title = null) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = s.name, onValueChange = { vm.update { copy(name = it) } },
|
value = s.name, onValueChange = { vm.update { copy(name = it) } },
|
||||||
label = { Text("Sync pair name") },
|
label = { Text(if (s.multiFolder) "Group name prefix (optional)" else "Sync pair name") },
|
||||||
leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
|
leadingIcon = { Icon(Icons.Default.DriveFileRenameOutline, null) },
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
@@ -122,23 +122,57 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
|||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
// Local folder
|
// Multi-folder mode toggle (new pairs only — editing stays single-folder)
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
if (!s.isEditing) {
|
||||||
OutlinedTextField(
|
ToggleRow(
|
||||||
value = uriToDisplay(s.localPath), onValueChange = {},
|
label = "Back up multiple folders",
|
||||||
label = { Text("Local folder") },
|
description = "Pick several folders; each is saved as its own subfolder under the remote base (no overwrites)",
|
||||||
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
|
checked = s.multiFolder,
|
||||||
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
|
onToggle = { vm.setMultiFolder(it) },
|
||||||
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = { Text("Tap to choose folder…") },
|
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote folder
|
if (s.multiFolder) {
|
||||||
|
// Chosen folders list
|
||||||
|
s.localPaths.forEach { p ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Folder, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(uriToDisplay(p), style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1, modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { vm.removeLocalFolder(p) }) {
|
||||||
|
Icon(Icons.Default.Close, "Remove folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { showLocalBrowser = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Icon(Icons.Default.Add, null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Add folder")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single local folder
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uriToDisplay(s.localPath), onValueChange = {},
|
||||||
|
label = { Text("Local folder") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
|
||||||
|
trailingIcon = { Icon(Icons.Default.FolderOpen, null) },
|
||||||
|
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("Tap to choose folder…") },
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.matchParentSize().clickable { showLocalBrowser = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote folder (base, in multi-folder mode)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } },
|
value = s.remotePath, onValueChange = { vm.update { copy(remotePath = it) } },
|
||||||
label = { Text("Remote folder") },
|
label = { Text(if (s.multiFolder) "Remote base folder" else "Remote folder") },
|
||||||
leadingIcon = { Icon(Icons.Default.Cloud, null) },
|
leadingIcon = { Icon(Icons.Default.Cloud, null) },
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -147,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
|||||||
) { Icon(Icons.Default.Folder, "Browse remote") }
|
) { Icon(Icons.Default.Folder, "Browse remote") }
|
||||||
},
|
},
|
||||||
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
placeholder = { Text("/ or /Documents/Photos") },
|
placeholder = { Text(if (s.multiFolder) "/Backup — each folder becomes a subfolder" else "/ or /Documents/Photos") },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recursive
|
// Recursive
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import com.syncflow.data.db.SyncPairDao
|
|||||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||||
import com.syncflow.data.db.entities.SyncPairEntity
|
import com.syncflow.data.db.entities.SyncPairEntity
|
||||||
import com.syncflow.domain.model.*
|
import com.syncflow.domain.model.*
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.WorkManager
|
||||||
import com.syncflow.worker.FileWatchService
|
import com.syncflow.worker.FileWatchService
|
||||||
|
import com.syncflow.worker.SyncWorker
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -22,6 +25,10 @@ data class AddPairUiState(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
// ── Folders ──────────────────────────────────────────────────────────────
|
// ── Folders ──────────────────────────────────────────────────────────────
|
||||||
val localPath: String = "",
|
val localPath: String = "",
|
||||||
|
// Multi-folder mode: pick several local folders that each back up to their own subfolder
|
||||||
|
// under one remote base. Empty unless multiFolder is on.
|
||||||
|
val multiFolder: Boolean = false,
|
||||||
|
val localPaths: List<String> = emptyList(),
|
||||||
val remotePath: String = "",
|
val remotePath: String = "",
|
||||||
val selectedAccountId: Long = -1L,
|
val selectedAccountId: Long = -1L,
|
||||||
val accounts: List<CloudAccountEntity> = emptyList(),
|
val accounts: List<CloudAccountEntity> = emptyList(),
|
||||||
@@ -44,7 +51,7 @@ data class AddPairUiState(
|
|||||||
val chargingOnly: Boolean = false,
|
val chargingOnly: Boolean = false,
|
||||||
val minBatteryPct: Int = 0,
|
val minBatteryPct: Int = 0,
|
||||||
// ── File filters ─────────────────────────────────────────────────────────
|
// ── 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 includeExtensions: String = "",
|
||||||
val excludeExtensions: String = "",
|
val excludeExtensions: String = "",
|
||||||
val skipHiddenFiles: Boolean = true,
|
val skipHiddenFiles: Boolean = true,
|
||||||
@@ -54,6 +61,7 @@ data class AddPairUiState(
|
|||||||
val notifyOnComplete: Boolean = false,
|
val notifyOnComplete: Boolean = false,
|
||||||
val notifyOnError: Boolean = true,
|
val notifyOnError: Boolean = true,
|
||||||
// ── Form state ───────────────────────────────────────────────────────────
|
// ── Form state ───────────────────────────────────────────────────────────
|
||||||
|
val isEditing: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
@@ -69,6 +77,31 @@ internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior
|
|||||||
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
|
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last path component of a local folder path or SAF content:// tree URI, used as the remote
|
||||||
|
* subfolder name in multi-folder mode. Handles URL-encoded tree URIs
|
||||||
|
* (content://…/tree/primary%3ADCIM%2FCamera → "Camera") and plain filesystem paths.
|
||||||
|
*/
|
||||||
|
internal fun folderLeafName(path: String): String {
|
||||||
|
val decoded = try { java.net.URLDecoder.decode(path, "UTF-8") } catch (e: Exception) { path }
|
||||||
|
val afterColon = decoded.substringAfterLast(':') // strip the "primary:" storage-volume prefix
|
||||||
|
val leaf = afterColon.trimEnd('/').substringAfterLast('/')
|
||||||
|
return leaf.ifBlank { "folder" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a remote subfolder name unique within one multi-folder batch so two source folders that
|
||||||
|
* share a leaf name (e.g. DCIM/Camera and Movies/Camera) don't collide into one remote dir.
|
||||||
|
* Slashes are flattened to underscores; collisions get a numeric suffix.
|
||||||
|
*/
|
||||||
|
internal fun uniqueSubName(base: String, used: MutableSet<String>): String {
|
||||||
|
val clean = base.replace('/', '_').replace('\\', '_').ifBlank { "folder" }
|
||||||
|
if (used.add(clean)) return clean
|
||||||
|
var n = 2
|
||||||
|
while (!used.add("${clean}_$n")) n++
|
||||||
|
return "${clean}_$n"
|
||||||
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AddPairViewModel @Inject constructor(
|
class AddPairViewModel @Inject constructor(
|
||||||
private val syncPairDao: SyncPairDao,
|
private val syncPairDao: SyncPairDao,
|
||||||
@@ -80,7 +113,7 @@ class AddPairViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
|
private val editPairId = savedState.get<Long>("pairId").takeIf { it != -1L }
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AddPairUiState())
|
private val _state = MutableStateFlow(AddPairUiState(isEditing = editPairId != null))
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -99,6 +132,7 @@ class AddPairViewModel @Inject constructor(
|
|||||||
syncPairDao.getById(id)?.let { pair ->
|
syncPairDao.getById(id)?.let { pair ->
|
||||||
_state.update { _ ->
|
_state.update { _ ->
|
||||||
AddPairUiState(
|
AddPairUiState(
|
||||||
|
isEditing = true,
|
||||||
name = pair.name,
|
name = pair.name,
|
||||||
localPath = pair.localPath,
|
localPath = pair.localPath,
|
||||||
remotePath = pair.remotePath,
|
remotePath = pair.remotePath,
|
||||||
@@ -145,12 +179,25 @@ class AddPairViewModel @Inject constructor(
|
|||||||
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
|
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMultiFolder(enabled: Boolean) = _state.update { it.copy(multiFolder = enabled) }
|
||||||
|
|
||||||
|
fun addLocalFolder(path: String) = _state.update {
|
||||||
|
if (path.isBlank() || path in it.localPaths) it else it.copy(localPaths = it.localPaths + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeLocalFolder(path: String) = _state.update { it.copy(localPaths = it.localPaths - path) }
|
||||||
|
|
||||||
fun save() {
|
fun save() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
val errors = buildList {
|
val errors = buildList {
|
||||||
if (s.name.isBlank()) add("Name is required")
|
if (s.multiFolder) {
|
||||||
if (s.localPath.isBlank()) add("Local folder is required")
|
if (s.localPaths.isEmpty()) add("Add at least one folder")
|
||||||
if (s.remotePath.isBlank()) add("Remote folder is required")
|
if (s.remotePath.isBlank()) add("Remote base folder is required")
|
||||||
|
} else {
|
||||||
|
if (s.name.isBlank()) add("Name is required")
|
||||||
|
if (s.localPath.isBlank()) add("Local folder is required")
|
||||||
|
if (s.remotePath.isBlank()) add("Remote folder is required")
|
||||||
|
}
|
||||||
if (s.selectedAccountId == -1L) add("Select a cloud account")
|
if (s.selectedAccountId == -1L) add("Select a cloud account")
|
||||||
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
|
if (s.scheduleType == ScheduleType.INTERVAL && s.intervalMinutes < 15) add("Minimum interval is 15 minutes")
|
||||||
}
|
}
|
||||||
@@ -159,43 +206,87 @@ class AddPairViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isSaving = true, error = null) }
|
_state.update { it.copy(isSaving = true, error = null) }
|
||||||
runCatching {
|
runCatching {
|
||||||
val entity = SyncPairEntity(
|
if (s.multiFolder) {
|
||||||
id = editPairId ?: 0L,
|
// One normal pair per folder, each into its OWN subfolder under the remote base.
|
||||||
name = s.name, localPath = s.localPath, remotePath = s.remotePath,
|
// Keeping each source in a distinct subfolder is what makes many-to-one safe —
|
||||||
accountId = s.selectedAccountId,
|
// flattening would let same-named files from different folders overwrite each other.
|
||||||
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
|
val base = s.remotePath.trimEnd('/')
|
||||||
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
|
val used = mutableSetOf<String>()
|
||||||
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes,
|
s.localPaths.map { folder ->
|
||||||
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null,
|
val sub = uniqueSubName(folderLeafName(folder), used)
|
||||||
scheduleWeekdays = s.weekdays,
|
val pairName = if (s.name.isBlank()) sub else "${s.name} — $sub"
|
||||||
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid,
|
val entity = buildEntity(s, name = pairName, localPath = folder, remotePath = "$base/$sub", id = 0L)
|
||||||
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct,
|
entity.copy(id = syncPairDao.insert(entity))
|
||||||
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions,
|
|
||||||
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles,
|
|
||||||
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb,
|
|
||||||
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
|
|
||||||
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
|
|
||||||
)
|
|
||||||
if (editPairId == null) {
|
|
||||||
syncPairDao.insert(entity)
|
|
||||||
} else {
|
|
||||||
val existing = syncPairDao.getById(editPairId)
|
|
||||||
syncPairDao.update(entity)
|
|
||||||
// If local or remote folder changed, old file-state records no longer
|
|
||||||
// correspond to any real path — wipe them so the next sync starts fresh
|
|
||||||
// instead of trying to delete/re-upload stale paths.
|
|
||||||
if (existing != null &&
|
|
||||||
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
|
|
||||||
) {
|
|
||||||
fileStateDao.deleteForPair(editPairId)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val entity = buildEntity(s, name = s.name, localPath = s.localPath, remotePath = s.remotePath, id = editPairId ?: 0L)
|
||||||
|
val pairId = if (editPairId == null) {
|
||||||
|
syncPairDao.insert(entity)
|
||||||
|
} else {
|
||||||
|
val existing = syncPairDao.getById(editPairId)
|
||||||
|
syncPairDao.update(entity)
|
||||||
|
// If local or remote folder changed, old file-state records no longer
|
||||||
|
// correspond to any real path — wipe them so the next sync starts fresh
|
||||||
|
// instead of trying to delete/re-upload stale paths.
|
||||||
|
if (existing != null &&
|
||||||
|
(existing.localPath != entity.localPath || existing.remotePath != entity.remotePath)
|
||||||
|
) {
|
||||||
|
fileStateDao.deleteForPair(editPairId)
|
||||||
|
}
|
||||||
|
editPairId
|
||||||
|
}
|
||||||
|
listOf(entity.copy(id = pairId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onSuccess {
|
.onSuccess { saved ->
|
||||||
if (s.scheduleType == ScheduleType.ON_CHANGE) FileWatchService.start(context)
|
saved.forEach { applySchedule(it) }
|
||||||
_state.update { it.copy(done = true) }
|
_state.update { it.copy(done = true) }
|
||||||
}
|
}
|
||||||
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
|
.onFailure { e -> _state.update { it.copy(isSaving = false, error = e.message) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildEntity(s: AddPairUiState, name: String, localPath: String, remotePath: String, id: Long) =
|
||||||
|
SyncPairEntity(
|
||||||
|
id = id,
|
||||||
|
name = name, localPath = localPath, remotePath = remotePath,
|
||||||
|
accountId = s.selectedAccountId,
|
||||||
|
syncDirection = s.syncDirection, conflictStrategy = s.conflictStrategy,
|
||||||
|
deleteBehavior = s.deleteBehavior, recursive = s.recursive,
|
||||||
|
scheduleType = s.scheduleType, scheduleIntervalMinutes = s.intervalMinutes,
|
||||||
|
scheduleDailyTime = if (s.scheduleType == ScheduleType.DAILY || s.scheduleType == ScheduleType.WEEKLY) s.dailyTime else null,
|
||||||
|
scheduleWeekdays = s.weekdays,
|
||||||
|
wifiOnly = s.wifiOnly, wifiSsid = s.wifiSsid,
|
||||||
|
chargingOnly = s.chargingOnly, minBatteryPct = s.minBatteryPct,
|
||||||
|
excludePatterns = s.excludePatterns, includeExtensions = s.includeExtensions,
|
||||||
|
excludeExtensions = s.excludeExtensions, skipHiddenFiles = s.skipHiddenFiles,
|
||||||
|
minFileSizeKb = s.minFileSizeKb, maxFileSizeKb = s.maxFileSizeKb,
|
||||||
|
notifyOnComplete = s.notifyOnComplete, notifyOnError = s.notifyOnError,
|
||||||
|
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,106 @@
|
|||||||
|
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 `android app-private trees are excluded (scoped-storage unreadable)`() {
|
||||||
|
val p = pair()
|
||||||
|
assertTrue(isExcludedPath("Android/media/com.whatsapp/WhatsApp/Media/IMG.jpg", p))
|
||||||
|
assertTrue(isExcludedPath("Android/data/com.foo/files/x.bin", p))
|
||||||
|
assertTrue(isExcludedPath("Android/obb/com.game/main.obb", p))
|
||||||
|
assertTrue(isExcludedPath("android/MEDIA/com.x/y.jpg", p)) // case-insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `non-private Android paths are not excluded`() {
|
||||||
|
val p = pair()
|
||||||
|
// A user folder literally named "Android" at a deeper level is fine; only the
|
||||||
|
// top-level app-private trees are blocked.
|
||||||
|
assertFalse(isExcludedPath("DCIM/Android/holiday.jpg", p))
|
||||||
|
assertFalse(isExcludedPath("Pictures/android-wallpaper.png", 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `remote-not-found detected from http status messages`() {
|
||||||
|
assertTrue(isRemoteNotFound(Exception("HTTP 404")))
|
||||||
|
assertTrue(isRemoteNotFound(Exception("Not Found")))
|
||||||
|
assertTrue(isRemoteNotFound(RuntimeException("PROPFIND failed: notfound")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `remote-not-found is false for other errors`() {
|
||||||
|
assertFalse(isRemoteNotFound(Exception("HTTP 500")))
|
||||||
|
assertFalse(isRemoteNotFound(Exception("timeout")))
|
||||||
|
assertFalse(isRemoteNotFound(Exception(null as String?)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.syncflow.ui.addpair
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-folder backup: each selected folder maps to its OWN remote subfolder. The leaf-name and
|
||||||
|
* uniqueness helpers are what keep that safe — same-named folders from different parents must not
|
||||||
|
* collapse into one remote dir and overwrite each other.
|
||||||
|
*/
|
||||||
|
class MultiFolderTest {
|
||||||
|
|
||||||
|
@Test fun `leaf name from SAF tree uri`() {
|
||||||
|
assertEquals("Camera", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM%2FCamera"))
|
||||||
|
assertEquals("DCIM", folderLeafName("content://com.android.externalstorage.documents/tree/primary%3ADCIM"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `leaf name from plain filesystem path`() {
|
||||||
|
assertEquals("Camera", folderLeafName("/storage/emulated/0/DCIM/Camera"))
|
||||||
|
assertEquals("Pictures", folderLeafName("/storage/emulated/0/Pictures/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `blank-ish paths fall back to folder`() {
|
||||||
|
assertEquals("folder", folderLeafName(""))
|
||||||
|
assertEquals("folder", folderLeafName("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `unique sub names disambiguate collisions`() {
|
||||||
|
val used = mutableSetOf<String>()
|
||||||
|
assertEquals("Camera", uniqueSubName("Camera", used))
|
||||||
|
assertEquals("Camera_2", uniqueSubName("Camera", used))
|
||||||
|
assertEquals("Camera_3", uniqueSubName("Camera", used))
|
||||||
|
assertEquals("Pictures", uniqueSubName("Pictures", used))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `unique sub name flattens slashes`() {
|
||||||
|
val used = mutableSetOf<String>()
|
||||||
|
assertTrue('/' !in uniqueSubName("a/b/c", used))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
VERSION_NAME=1.0.64
|
VERSION_NAME=1.0.76
|
||||||
VERSION_CODE=65
|
VERSION_CODE=76
|
||||||
|
|||||||
Reference in New Issue
Block a user