Sync engine / providers: - LocalAccessor: replace createOutputStream with writeAtomically (temp sibling + rename/commit) for both JavaFile and SAF backends, so an interrupted download no longer truncates the destination file. - SyncEngine: use writeAtomically for DOWNLOAD and propagate downloadFile failures via getOrThrow (was silently swallowed -> false success + state). - WebDavProvider (covers Nextcloud/ownCloud): PUT to hidden temp then MOVE onto destination, so a failed upload can't leave a truncated remote file. - SftpProvider: upload to temp then rename onto destination. Build / CI: - compileSdk 34 -> 35 (was below targetSdk 35). - Release signing reads keystore from local.properties or env (CI), with a debug-key fallback so builds still succeed without secrets. - Disable R8/minify for release (never exercised by CI; keeps signed release behaving like the debug builds in use today). - CI: run unit tests on every push/PR, build assembleRelease (signed when KEYSTORE_BASE64 present), publish APK only on v* tags.
This commit is contained in:
@@ -2,8 +2,10 @@ name: Build & Release APK
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: ['**']
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -20,10 +22,29 @@ jobs:
|
|||||||
|
|
||||||
- uses: android-actions/setup-android@v3
|
- uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
- name: Build debug APK
|
- name: Run unit tests
|
||||||
run: |
|
run: |
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew assembleDebug --no-daemon
|
./gradlew testDebugUnitTest --no-daemon
|
||||||
|
|
||||||
|
- name: Decode release keystore
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||||
|
echo "KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||||
|
echo "Release keystore decoded — building signed release."
|
||||||
|
else
|
||||||
|
echo "::warning::KEYSTORE_BASE64 secret not set — release APK will be debug-signed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
env:
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||||
|
run: ./gradlew assembleRelease --no-daemon
|
||||||
|
|
||||||
- name: Get version name
|
- name: Get version name
|
||||||
id: ver
|
id: ver
|
||||||
@@ -32,10 +53,11 @@ jobs:
|
|||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
mkdir dist
|
mkdir dist
|
||||||
cp app/build/outputs/apk/debug/app-debug.apk \
|
cp app/build/outputs/apk/release/app-release.apk \
|
||||||
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
|
dist/SyncFlow-v${{ steps.ver.outputs.name }}.apk
|
||||||
|
|
||||||
- name: Attach APK to release
|
- name: Attach APK to release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
|
|||||||
+23
-8
@@ -18,9 +18,15 @@ val localProps = Properties().apply {
|
|||||||
if (f.exists()) load(f.inputStream())
|
if (f.exists()) load(f.inputStream())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing is read from local.properties (local builds) or environment variables
|
||||||
|
// (CI). When no keystore is available the release build falls back to the debug key so the
|
||||||
|
// build still succeeds — it just isn't a distributable, properly-signed APK.
|
||||||
|
val keystorePath = (localProps["KEYSTORE_PATH"] as String?) ?: System.getenv("KEYSTORE_PATH")
|
||||||
|
val hasReleaseKeystore = keystorePath != null && file(keystorePath).exists()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.syncflow"
|
namespace = "com.syncflow"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.syncflow"
|
applicationId = "com.syncflow"
|
||||||
@@ -38,19 +44,28 @@ android {
|
|||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = localProps["KEYSTORE_PATH"]?.toString()?.let { file(it) }
|
if (hasReleaseKeystore) {
|
||||||
storePassword = localProps["KEYSTORE_PASSWORD"]?.toString()
|
storeFile = file(keystorePath!!)
|
||||||
keyAlias = localProps["KEY_ALIAS"]?.toString()
|
storePassword = (localProps["KEYSTORE_PASSWORD"] as String?) ?: System.getenv("KEYSTORE_PASSWORD")
|
||||||
keyPassword = localProps["KEY_PASSWORD"]?.toString()
|
keyAlias = (localProps["KEY_ALIAS"] as String?) ?: System.getenv("KEY_ALIAS")
|
||||||
|
keyPassword = (localProps["KEY_PASSWORD"] as String?) ?: System.getenv("KEY_PASSWORD")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
// R8/minify has never been exercised by CI (it only built debug), so leave it off
|
||||||
isShrinkResources = true
|
// to keep the signed release behaving identically to the debug builds in use today.
|
||||||
|
// Re-enable with proper keep rules and an on-device smoke test if APK size matters.
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,12 +63,25 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
|
|||||||
sizeBytes: Long,
|
sizeBytes: Long,
|
||||||
onProgress: (Long) -> Unit,
|
onProgress: (Long) -> Unit,
|
||||||
): Result<RemoteFile> = runCatching {
|
): Result<RemoteFile> = runCatching {
|
||||||
|
// Upload to a hidden temp sibling, then rename onto the destination so an interrupted
|
||||||
|
// transfer never leaves a truncated file at the real path.
|
||||||
|
val dir = remotePath.substringBeforeLast('/', "")
|
||||||
|
val name = remotePath.substringAfterLast('/')
|
||||||
|
val tmpPath = if (dir.isEmpty()) ".$name.sfpart" else "$dir/.$name.sfpart"
|
||||||
withSftp { sftp ->
|
withSftp { sftp ->
|
||||||
sftp.put(object : InMemorySourceFile() {
|
sftp.put(object : InMemorySourceFile() {
|
||||||
override fun getName() = remotePath.substringAfterLast('/')
|
override fun getName() = name
|
||||||
override fun getLength() = sizeBytes
|
override fun getLength() = sizeBytes
|
||||||
override fun getInputStream() = localStream
|
override fun getInputStream() = localStream
|
||||||
}, remotePath)
|
}, tmpPath)
|
||||||
|
// SFTP rename fails if the target exists on servers without the POSIX-rename
|
||||||
|
// extension, so fall back to removing the destination first.
|
||||||
|
try {
|
||||||
|
sftp.rename(tmpPath, remotePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { sftp.rm(remotePath) }
|
||||||
|
sftp.rename(tmpPath, remotePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
getFileMetadata(remotePath).getOrThrow()
|
getFileMetadata(remotePath).getOrThrow()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,15 +93,30 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
|||||||
localStream.source().use { source -> sink.writeAll(source) }
|
localStream.source().use { source -> sink.writeAll(source) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val req = Request.Builder().url(url(remotePath)).put(body).build()
|
// Upload to a hidden temp sibling first, then MOVE it onto the destination. A
|
||||||
|
// failed PUT leaves the real file untouched instead of overwriting it with a
|
||||||
|
// truncated body; the MOVE is a server-side atomic-ish swap.
|
||||||
|
val tmpPath = tempPathFor(remotePath)
|
||||||
|
val req = Request.Builder().url(url(tmpPath)).put(body).build()
|
||||||
client.newCall(req).execute().use { resp ->
|
client.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
|
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
|
||||||
}
|
}
|
||||||
|
moveFile(tmpPath, remotePath).getOrElse { e ->
|
||||||
|
runCatching { deleteFile(tmpPath) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
onProgress(sizeBytes)
|
onProgress(sizeBytes)
|
||||||
getFileMetadata(remotePath).getOrThrow()
|
getFileMetadata(remotePath).getOrThrow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tempPathFor(remotePath: String): String {
|
||||||
|
val dir = remotePath.substringBeforeLast('/', "")
|
||||||
|
val name = remotePath.substringAfterLast('/')
|
||||||
|
val tmp = ".$name.sfpart"
|
||||||
|
return if (dir.isEmpty()) tmp else "$dir/$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
|
override suspend fun downloadFile(remotePath: String, destination: OutputStream, onProgress: (Long) -> Unit): Result<Unit> = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val req = Request.Builder().url(url(remotePath)).get().build()
|
val req = Request.Builder().url(url(remotePath)).get().build()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.net.Uri
|
|||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import com.syncflow.domain.model.SyncPair
|
import com.syncflow.domain.model.SyncPair
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
@@ -14,10 +16,17 @@ sealed class LocalAccessor {
|
|||||||
|
|
||||||
abstract fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo>
|
abstract fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo>
|
||||||
abstract fun openInputStream(relativePath: String): InputStream?
|
abstract fun openInputStream(relativePath: String): InputStream?
|
||||||
abstract fun createOutputStream(relativePath: String): OutputStream?
|
|
||||||
abstract fun delete(relativePath: String): Boolean
|
abstract fun delete(relativePath: String): Boolean
|
||||||
abstract fun lastModifiedMs(relativePath: String): Long
|
abstract fun lastModifiedMs(relativePath: String): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [relativePath] atomically: stream into a temp sibling first, then swap it into
|
||||||
|
* place only after [write] completes without throwing. An interrupted transfer (network
|
||||||
|
* drop, process death) leaves the existing destination untouched instead of truncating it.
|
||||||
|
* On failure the temp is removed and the exception is rethrown.
|
||||||
|
*/
|
||||||
|
abstract suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit)
|
||||||
|
|
||||||
// ── java.io.File backend (regular /storage/... paths) ────────────────────
|
// ── java.io.File backend (regular /storage/... paths) ────────────────────
|
||||||
|
|
||||||
class JavaFile(private val root: File) : LocalAccessor() {
|
class JavaFile(private val root: File) : LocalAccessor() {
|
||||||
@@ -48,10 +57,30 @@ sealed class LocalAccessor {
|
|||||||
override fun openInputStream(relativePath: String): InputStream =
|
override fun openInputStream(relativePath: String): InputStream =
|
||||||
File(root, relativePath).inputStream()
|
File(root, relativePath).inputStream()
|
||||||
|
|
||||||
override fun createOutputStream(relativePath: String): OutputStream {
|
override suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit) {
|
||||||
val dest = File(root, relativePath)
|
val dest = File(root, relativePath)
|
||||||
dest.parentFile?.mkdirs()
|
dest.parentFile?.mkdirs()
|
||||||
return dest.outputStream()
|
val tmp = File(dest.parentFile, ".${dest.name}.sfpart")
|
||||||
|
try {
|
||||||
|
FileOutputStream(tmp).use { os ->
|
||||||
|
write(os)
|
||||||
|
os.flush()
|
||||||
|
os.fd.sync() // durably persist bytes before the rename swaps the file in
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
tmp.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
// Same-directory rename is atomic on POSIX/Android and replaces the destination.
|
||||||
|
if (!tmp.renameTo(dest)) {
|
||||||
|
try {
|
||||||
|
tmp.copyTo(dest, overwrite = true)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
tmp.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(relativePath: String): Boolean = File(root, relativePath).delete()
|
override fun delete(relativePath: String): Boolean = File(root, relativePath).delete()
|
||||||
@@ -131,7 +160,7 @@ sealed class LocalAccessor {
|
|||||||
return resolver.openInputStream(docUri)
|
return resolver.openInputStream(docUri)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createOutputStream(relativePath: String): OutputStream? {
|
override suspend fun writeAtomically(relativePath: String, write: suspend (OutputStream) -> Unit) {
|
||||||
val parts = relativePath.replace('\\', '/').split('/')
|
val parts = relativePath.replace('\\', '/').split('/')
|
||||||
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
|
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||||
|
|
||||||
@@ -141,7 +170,7 @@ sealed class LocalAccessor {
|
|||||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
||||||
val newDir = DocumentsContract.createDocument(
|
val newDir = DocumentsContract.createDocument(
|
||||||
resolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, parts[i]
|
resolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, parts[i]
|
||||||
) ?: return null
|
) ?: throw IOException("Cannot create directory ${parts[i]} for $relativePath")
|
||||||
DocumentsContract.getDocumentId(newDir)
|
DocumentsContract.getDocumentId(newDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,19 +178,47 @@ sealed class LocalAccessor {
|
|||||||
val fileName = parts.last()
|
val fileName = parts.last()
|
||||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
|
||||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
||||||
|
val tmpName = ".$fileName.sfpart"
|
||||||
|
|
||||||
// Delete existing to allow overwrite
|
// Clear any leftover temp document from a previously interrupted write.
|
||||||
findChildId(childrenUri, fileName)?.let { existingId ->
|
findChildId(childrenUri, tmpName)?.let { staleId ->
|
||||||
|
runCatching {
|
||||||
DocumentsContract.deleteDocument(
|
DocumentsContract.deleteDocument(
|
||||||
resolver,
|
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, staleId)
|
||||||
DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val newUri = DocumentsContract.createDocument(
|
val tmpUri = DocumentsContract.createDocument(
|
||||||
resolver, parentUri, "application/octet-stream", fileName
|
resolver, parentUri, "application/octet-stream", tmpName
|
||||||
) ?: return null
|
) ?: throw IOException("Cannot create temp document for $relativePath")
|
||||||
return resolver.openOutputStream(newUri)
|
|
||||||
|
try {
|
||||||
|
(resolver.openOutputStream(tmpUri)
|
||||||
|
?: throw IOException("Cannot open temp stream for $relativePath")).use { os ->
|
||||||
|
write(os)
|
||||||
|
os.flush()
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
runCatching { DocumentsContract.deleteDocument(resolver, tmpUri) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit: remove the existing destination, then rename the fully-written temp into
|
||||||
|
// place. If interrupted between the two steps the temp still holds the complete data
|
||||||
|
// (recoverable by hand), which is strictly safer than truncating the destination.
|
||||||
|
findChildId(childrenUri, fileName)?.let { existingId ->
|
||||||
|
DocumentsContract.deleteDocument(
|
||||||
|
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val renamed = DocumentsContract.renameDocument(resolver, tmpUri, fileName)
|
||||||
|
if (renamed == null) {
|
||||||
|
runCatching { DocumentsContract.deleteDocument(resolver, tmpUri) }
|
||||||
|
throw IOException("Cannot finalize $relativePath")
|
||||||
|
}
|
||||||
|
// Drop the stale cache entry so the next read re-resolves the new document id.
|
||||||
|
docIdCache.remove(relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun delete(relativePath: String): Boolean {
|
override fun delete(relativePath: String): Boolean {
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ class SyncEngine @Inject constructor(
|
|||||||
}
|
}
|
||||||
SyncDecision.DOWNLOAD -> {
|
SyncDecision.DOWNLOAD -> {
|
||||||
val bytes = runCatching {
|
val bytes = runCatching {
|
||||||
accessor.createOutputStream(rel)?.use { stream ->
|
accessor.writeAtomically(rel) { stream ->
|
||||||
provider.downloadFile("${pair.remotePath}/$rel", stream) { }
|
provider.downloadFile("${pair.remotePath}/$rel", stream) { }.getOrThrow()
|
||||||
}
|
}
|
||||||
remote!!.sizeBytes
|
remote!!.sizeBytes
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
|
|||||||
Reference in New Issue
Block a user