Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7ec3f4ad3 | |||
| 537808ca10 | |||
| 147da702a1 | |||
| cf2fd8c452 | |||
| c415dceb22 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME=1.0.66
|
||||
VERSION_CODE=67
|
||||
VERSION_NAME=1.0.71
|
||||
VERSION_CODE=71
|
||||
|
||||
Reference in New Issue
Block a user