Compare commits
40 Commits
v1.0.40
...
0131d8d4fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 0131d8d4fd | |||
| d2ca3f1918 | |||
| 812b40b42f | |||
| b7ec3f4ad3 | |||
| 537808ca10 | |||
| 147da702a1 | |||
| cf2fd8c452 | |||
| c415dceb22 | |||
| e1abf80f11 | |||
| 15b94a0407 | |||
| abec5276f9 | |||
| 4c24f45808 | |||
| a348c43c66 | |||
| f90d84e1fc | |||
| 10007eb4fb | |||
| 29b5d555b8 | |||
| 369e260158 | |||
| 1ecae2c690 | |||
| 39aa2f7dfd | |||
| 402d0447a0 | |||
| c1b7221324 | |||
| 556645226a | |||
| 1e5ae2c65f | |||
| a0d759364e | |||
| 160a3e5478 | |||
| 92cad9ca56 | |||
| 62f9f015d6 | |||
| b973e58d9e | |||
| dbd317624d | |||
| 25e4c6c4e3 | |||
| c60eb8d27b | |||
| 21b7ffc7b3 | |||
| cb9fa1d3db | |||
| e59564ac07 | |||
| 0ba4fd7eb9 | |||
| 69d4257a18 | |||
| 683169e8b7 | |||
| 77d56ee6be | |||
| 99193af2c5 | |||
| 3c008ec8df |
@@ -2,12 +2,16 @@ name: Build & Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # needed to create the release object on a tag
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -20,10 +24,29 @@ jobs:
|
||||
|
||||
- uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Build debug APK
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
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
|
||||
id: ver
|
||||
@@ -32,21 +55,27 @@ jobs:
|
||||
- name: Rename APK
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Attach APK to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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)"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
.kotlin/
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
SyncFlow Source-Available License
|
||||
Copyright (c) 2026 Amir Khodak. All rights reserved.
|
||||
|
||||
This is NOT an OSI-approved open-source license. The source code is made
|
||||
publicly viewable ("source-available"), but the rights granted are limited as
|
||||
described below. Where this license is silent, all rights are reserved.
|
||||
|
||||
1. DEFINITIONS
|
||||
"Software" means the SyncFlow source code, assets, and documentation in this
|
||||
repository. "You" means anyone other than the copyright holder.
|
||||
|
||||
2. WHAT YOU MAY DO
|
||||
a. View, read, and study the Software.
|
||||
b. Clone or fork the repository for your own private, personal,
|
||||
non-commercial use and experimentation.
|
||||
c. Build the Software from source and run it on devices you personally own.
|
||||
d. Submit contributions (pull requests) back to this repository; by doing so
|
||||
you license your contribution to the copyright holder under these terms.
|
||||
|
||||
3. WHAT YOU MAY NOT DO (without the copyright holder's prior written permission)
|
||||
a. Redistribute, publish, or make available the Software or any derivative
|
||||
work — in source or binary/APK form — to any third party or app store
|
||||
(including but not limited to Google Play, F-Droid, Amazon Appstore,
|
||||
Gitea/GitHub releases, or any website).
|
||||
b. Use the Software, in whole or in part, for any commercial purpose.
|
||||
c. Sell, sublicense, rent, or offer the Software as a service.
|
||||
d. Use the names, app identity ("SyncFlow"), package identifier
|
||||
("com.syncflow"), logos, or signing keys of the original work.
|
||||
e. Remove or alter this license or the copyright notice.
|
||||
|
||||
4. RESERVED RIGHTS
|
||||
All publishing and distribution rights are reserved exclusively to the
|
||||
copyright holder. Only the copyright holder may publish official builds.
|
||||
|
||||
5. NO WARRANTY
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES, OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||
|
||||
To request permission for anything in section 3, contact the copyright holder.
|
||||
@@ -1,3 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" width="108" alt="SyncFlow">
|
||||
</p>
|
||||
|
||||
# SyncFlow
|
||||
|
||||
Native Android file sync app — sync any folder to WebDAV, SFTP, Nextcloud, ownCloud, Google Drive, Dropbox, or OneDrive.
|
||||
@@ -36,3 +40,10 @@ Native Android file sync app — sync any folder to WebDAV, SFTP, Nextcloud, own
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Storage permission (or SAF picker) for local folder access
|
||||
|
||||
## License
|
||||
|
||||
SyncFlow is **source-available, not open-source** — see [LICENSE](LICENSE).
|
||||
You may read, study, and fork it for personal, non-commercial use, but
|
||||
**redistributing or publishing the app (source or APK) is not permitted**.
|
||||
Only the copyright holder publishes official, signed builds.
|
||||
|
||||
@@ -18,16 +18,25 @@ val localProps = Properties().apply {
|
||||
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 {
|
||||
namespace = "com.syncflow"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.syncflow"
|
||||
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
|
||||
@@ -38,19 +47,28 @@ android {
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = localProps["KEYSTORE_PATH"]?.toString()?.let { file(it) }
|
||||
storePassword = localProps["KEYSTORE_PASSWORD"]?.toString()
|
||||
keyAlias = localProps["KEY_ALIAS"]?.toString()
|
||||
keyPassword = localProps["KEY_PASSWORD"]?.toString()
|
||||
if (hasReleaseKeystore) {
|
||||
storeFile = file(keystorePath!!)
|
||||
storePassword = (localProps["KEYSTORE_PASSWORD"] as String?) ?: System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = (localProps["KEY_ALIAS"] as String?) ?: System.getenv("KEY_ALIAS")
|
||||
keyPassword = (localProps["KEY_PASSWORD"] as String?) ?: System.getenv("KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
// R8/minify has never been exercised by CI (it only built debug), so leave it off
|
||||
// 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")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
signingConfig = if (hasReleaseKeystore) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
package com.syncflow
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.syncflow.data.db.SyncDatabase
|
||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.data.db.entities.toDomain
|
||||
import com.syncflow.data.providers.CloudProvider
|
||||
import com.syncflow.data.providers.nextcloud.NextcloudProvider
|
||||
import com.syncflow.domain.sync.LocalAccessor
|
||||
import com.syncflow.domain.model.*
|
||||
import com.syncflow.domain.sync.SyncEngine
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Full-matrix end-to-end test of the REAL SyncEngine against a live Nextcloud, on the device:
|
||||
* in-memory Room DB, a real local folder per test, and the real NextcloudProvider over TLS.
|
||||
* Covers every direction, every delete behavior, updates, nested/recursive, filters, and conflicts.
|
||||
*
|
||||
* Creds via instrumentation args: -e ncUrl ... -e ncUser ... -e ncPass ...
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FullSyncEngineTest {
|
||||
|
||||
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val args = InstrumentationRegistry.getArguments()
|
||||
private val url get() = args.getString("ncUrl")
|
||||
private val user get() = args.getString("ncUser")
|
||||
private val pass get() = args.getString("ncPass")
|
||||
|
||||
private lateinit var db: SyncDatabase
|
||||
private lateinit var engine: SyncEngine
|
||||
private lateinit var provider: NextcloudProvider
|
||||
private var accountId = 0L
|
||||
private val remoteRoot = "SyncFlowFull_${System.currentTimeMillis()}"
|
||||
private val localDirs = mutableListOf<File>()
|
||||
|
||||
@Before fun setUp() = runBlocking {
|
||||
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
|
||||
db = Room.inMemoryDatabaseBuilder(ctx, SyncDatabase::class.java).build()
|
||||
val account = CloudAccount(0, "IT", user, ProviderType.NEXTCLOUD,
|
||||
"""{"username":"$user","password":"$pass"}""", url, null)
|
||||
accountId = db.cloudAccountDao().insert(
|
||||
CloudAccountEntity(0, account.displayName, account.email, account.providerType,
|
||||
account.credentialJson, account.serverUrl, account.port))
|
||||
provider = NextcloudProvider(account)
|
||||
engine = SyncEngine(db.syncPairDao(), db.syncFileStateDao(), db.syncConflictDao(), db.syncEventDao(), ctx)
|
||||
provider.createDirectory(remoteRoot).getOrThrow()
|
||||
}
|
||||
|
||||
@After fun tearDown() = runBlocking {
|
||||
runCatching { provider.deleteFile(remoteRoot) }
|
||||
localDirs.forEach { it.deleteRecursively() }
|
||||
if (::db.isInitialized) db.close()
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun newPair(
|
||||
name: String,
|
||||
dir: SyncDirection = SyncDirection.TWO_WAY,
|
||||
delete: DeleteBehavior = DeleteBehavior.MIRROR,
|
||||
conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
|
||||
recursive: Boolean = true,
|
||||
excludeExtensions: String = "",
|
||||
includeExtensions: String = "",
|
||||
excludePatterns: String = "",
|
||||
skipHidden: Boolean = false,
|
||||
minKb: Long = 0L,
|
||||
maxKb: Long = 0L,
|
||||
): Triple<SyncPair, File, String> {
|
||||
val local = File(ctx.cacheDir, "synctest_${name}_${System.currentTimeMillis()}").apply { mkdirs() }
|
||||
localDirs += local
|
||||
val remote = "$remoteRoot/$name"
|
||||
provider.createDirectory(remote).getOrThrow()
|
||||
val id = db.syncPairDao().insert(SyncPairEntity(
|
||||
id = 0, name = name, localPath = local.absolutePath, remotePath = remote, accountId = accountId,
|
||||
syncDirection = dir, conflictStrategy = conflict, deleteBehavior = delete, recursive = recursive,
|
||||
scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 30, scheduleDailyTime = null, scheduleWeekdays = 0,
|
||||
wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0,
|
||||
excludePatterns = excludePatterns, includeExtensions = includeExtensions, excludeExtensions = excludeExtensions,
|
||||
skipHiddenFiles = skipHidden, minFileSizeKb = minKb, maxFileSizeKb = maxKb,
|
||||
notifyOnComplete = false, notifyOnError = false,
|
||||
isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0,
|
||||
))
|
||||
return Triple(db.syncPairDao().getById(id)!!.toDomain(), local, remote)
|
||||
}
|
||||
|
||||
private suspend fun sync(pair: SyncPair) = engine.sync(pair, provider)
|
||||
private fun write(dir: File, rel: String, content: String) =
|
||||
File(dir, rel).apply { parentFile?.mkdirs() }.writeText(content)
|
||||
private suspend fun remoteNames(remote: String) =
|
||||
provider.listFiles(remote).getOrThrow().map { it.name }
|
||||
private suspend fun remoteText(path: String): String {
|
||||
val out = ByteArrayOutputStream(); provider.downloadFile(path, out).getOrThrow(); return out.toString("UTF-8")
|
||||
}
|
||||
private suspend fun putRemote(remote: String, name: String, content: String) {
|
||||
val b = content.toByteArray()
|
||||
provider.uploadFile(ByteArrayInputStream(b), "$remote/$name", b.size.toLong()).getOrThrow()
|
||||
}
|
||||
|
||||
// ── 1. Upload-only backup: delete on phone keeps the cloud copy (KEEP) ────
|
||||
@Test fun uploadOnly_keep_localDeleteKeepsCloud() = runBlocking {
|
||||
val (pair, local, remote) = newPair("ul_keep", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
write(local, "a.txt", "hello")
|
||||
assertEquals(1, sync(pair).uploaded)
|
||||
assertTrue("a.txt" in remoteNames(remote))
|
||||
File(local, "a.txt").delete()
|
||||
val r = sync(pair)
|
||||
assertEquals("KEEP must not delete remotely", 0, r.deleted)
|
||||
assertTrue("cloud copy must survive", "a.txt" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// ── 2. Upload-only + MIRROR: local delete removes the cloud copy ──────────
|
||||
@Test fun uploadOnly_mirror_localDeleteRemovesCloud() = runBlocking {
|
||||
val (pair, local, remote) = newPair("ul_mirror", SyncDirection.UPLOAD_ONLY, DeleteBehavior.MIRROR)
|
||||
write(local, "a.txt", "hello"); assertEquals(1, sync(pair).uploaded)
|
||||
File(local, "a.txt").delete()
|
||||
assertEquals(1, sync(pair).deleted)
|
||||
assertFalse("a.txt" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// ── 3. Upload-only + ARCHIVE: deleted file moved to _Deleted/ ─────────────
|
||||
@Test fun uploadOnly_archive_movesToDeleted() = runBlocking {
|
||||
val (pair, local, remote) = newPair("ul_archive", SyncDirection.UPLOAD_ONLY, DeleteBehavior.ARCHIVE)
|
||||
write(local, "a.txt", "keepme"); sync(pair)
|
||||
File(local, "a.txt").delete(); sync(pair)
|
||||
assertFalse("a.txt" in remoteNames(remote))
|
||||
assertTrue("archived copy expected", "a.txt" in remoteNames("$remote/_Deleted"))
|
||||
}
|
||||
|
||||
// ── 4. Two-way initial sync: each side gets the other's files ─────────────
|
||||
@Test fun twoWay_initial_mergesBothSides() = runBlocking {
|
||||
val (pair, local, remote) = newPair("tw_init", SyncDirection.TWO_WAY)
|
||||
write(local, "local.txt", "L")
|
||||
putRemote(remote, "remote.txt", "R")
|
||||
val r = sync(pair)
|
||||
assertEquals(1, r.uploaded); assertEquals(1, r.downloaded)
|
||||
assertTrue(File(local, "remote.txt").exists())
|
||||
assertTrue("local.txt" in remoteNames(remote) && "remote.txt" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// ── 5. Two-way: a local edit propagates to the remote ─────────────────────
|
||||
@Test fun twoWay_localEdit_updatesRemote() = runBlocking {
|
||||
val (pair, local, remote) = newPair("tw_edit", SyncDirection.TWO_WAY)
|
||||
write(local, "f.txt", "v1"); sync(pair)
|
||||
Thread.sleep(1100) // cross the 1s mtime resolution
|
||||
write(local, "f.txt", "v2-updated"); val r = sync(pair)
|
||||
assertEquals(1, r.uploaded)
|
||||
assertEquals("v2-updated", remoteText("$remote/f.txt"))
|
||||
}
|
||||
|
||||
// ── 6. Two-way + MIRROR: deleting locally removes it remotely ─────────────
|
||||
@Test fun twoWay_mirror_localDeletePropagates() = runBlocking {
|
||||
val (pair, local, remote) = newPair("tw_mirror", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
|
||||
write(local, "f.txt", "x"); sync(pair)
|
||||
File(local, "f.txt").delete()
|
||||
assertEquals(1, sync(pair).deleted)
|
||||
assertFalse("f.txt" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// ── 7. Download-only: pulls remote, never uploads local-only files ────────
|
||||
@Test fun downloadOnly_pullsRemoteIgnoresLocal() = runBlocking {
|
||||
val (pair, local, remote) = newPair("dl_only", SyncDirection.DOWNLOAD_ONLY)
|
||||
putRemote(remote, "cloud.txt", "from-cloud")
|
||||
write(local, "phoneonly.txt", "P")
|
||||
val r = sync(pair)
|
||||
assertEquals(1, r.downloaded); assertEquals(0, r.uploaded)
|
||||
assertEquals("from-cloud", File(local, "cloud.txt").readText())
|
||||
assertFalse("local-only file must NOT upload", "phoneonly.txt" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// ── 8. Recursive: nested directory structure is preserved ─────────────────
|
||||
@Test fun recursive_uploadsNestedTree() = runBlocking {
|
||||
val (pair, local, remote) = newPair("rec_on", SyncDirection.UPLOAD_ONLY, recursive = true)
|
||||
write(local, "sub/deep/n.txt", "nested")
|
||||
assertEquals(1, sync(pair).uploaded)
|
||||
assertTrue("n.txt" in remoteNames("$remote/sub/deep"))
|
||||
}
|
||||
|
||||
// ── 9. recursive=false: subfolders are skipped ────────────────────────────
|
||||
@Test fun nonRecursive_skipsSubfolders() = runBlocking {
|
||||
val (pair, local, remote) = newPair("rec_off", SyncDirection.UPLOAD_ONLY, recursive = false)
|
||||
write(local, "top.txt", "T")
|
||||
write(local, "sub/deep.txt", "D")
|
||||
assertEquals(1, sync(pair).uploaded)
|
||||
assertTrue("top.txt" in remoteNames(remote))
|
||||
assertTrue("subfolder must be skipped", remoteNames(remote).none { it == "sub" })
|
||||
}
|
||||
|
||||
// ── 10. Filters: excluded extension + hidden file are not uploaded ─────────
|
||||
@Test fun filters_excludeExtensionAndHidden() = runBlocking {
|
||||
val (pair, local, remote) = newPair("filters", SyncDirection.UPLOAD_ONLY,
|
||||
excludeExtensions = "tmp", skipHidden = true)
|
||||
write(local, "keep.txt", "k")
|
||||
write(local, "skip.tmp", "s")
|
||||
write(local, ".hidden", "h")
|
||||
sync(pair)
|
||||
val names = remoteNames(remote)
|
||||
assertTrue("keep.txt" in names)
|
||||
assertFalse("skip.tmp" in names)
|
||||
assertFalse(".hidden" in names)
|
||||
}
|
||||
|
||||
// ── 11. Conflict: both sides changed (ASK) → conflict recorded, no clobber ─
|
||||
@Test fun twoWay_bothChanged_recordsConflict() = runBlocking {
|
||||
val (pair, local, remote) = newPair("conflict", SyncDirection.TWO_WAY, conflict = ConflictStrategy.ASK)
|
||||
write(local, "c.txt", "base"); sync(pair) // upload
|
||||
sync(pair) // reconcile: record remote baseline (etag/mtime)
|
||||
Thread.sleep(1100)
|
||||
write(local, "c.txt", "LOCAL-change") // change local
|
||||
putRemote(remote, "c.txt", "REMOTE-change") // change remote out-of-band
|
||||
val r = sync(pair)
|
||||
assertEquals("a conflict must be detected", 1, r.conflicts)
|
||||
// ASK must not silently overwrite either side
|
||||
assertEquals("LOCAL-change", File(local, "c.txt").readText())
|
||||
assertEquals("REMOTE-change", remoteText("$remote/c.txt"))
|
||||
}
|
||||
|
||||
// ── 12. Conflict KEEP_NEWEST: newer local wins and uploads ────────────────
|
||||
@Test fun twoWay_keepNewest_newerLocalWins() = runBlocking {
|
||||
val (pair, local, remote) = newPair("newest", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_NEWEST)
|
||||
write(local, "n.txt", "base"); sync(pair)
|
||||
putRemote(remote, "n.txt", "remote-older")
|
||||
Thread.sleep(1100)
|
||||
write(local, "n.txt", "local-newer") // local is newer than remote
|
||||
sync(pair)
|
||||
assertEquals("newer local must win", "local-newer", remoteText("$remote/n.txt"))
|
||||
}
|
||||
|
||||
// ── 13b. Special & non-ASCII filenames upload (WebDAV URL/header encoding) ─
|
||||
@Test fun specialAndNonAsciiNames_upload() = runBlocking {
|
||||
val (pair, local, remote) = newPair("special", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
write(local, "naïve café.txt", "accents") // non-ASCII (broke MOVE Destination header)
|
||||
write(local, "a&b (1).txt", "ampersand") // & ( ) space
|
||||
write(local, "日本語.txt", "cjk") // multibyte unicode
|
||||
write(local, "my photo.txt", "space")
|
||||
val r = sync(pair)
|
||||
assertEquals("all special-name files must upload", 4, r.uploaded)
|
||||
assertEquals(0, r.failedFiles)
|
||||
val names = remoteNames(remote)
|
||||
assertTrue("naïve café.txt" in names)
|
||||
assertTrue("a&b (1).txt" in names)
|
||||
assertTrue("日本語.txt" in names)
|
||||
assertTrue("my photo.txt" in names)
|
||||
}
|
||||
|
||||
// ── 13c. Volume: 100+ files (incl. subfolders & non-ASCII) upload, 0 fails ─
|
||||
@Test fun volume_hundredFiles_allUploadNoFailures() = runBlocking {
|
||||
val (pair, local, remote) = newPair("vol100", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
repeat(100) { i -> write(local, "f_%03d.txt".format(i), "payload $i ".repeat(30)) }
|
||||
write(local, "sub/nested_a.txt", "n1")
|
||||
write(local, "sub/deep/nested_b.txt", "n2")
|
||||
write(local, "naïve café.txt", "accented")
|
||||
val r = sync(pair)
|
||||
assertEquals("no file may fail under volume", 0, r.failedFiles)
|
||||
assertEquals("all 103 files upload", 103, r.uploaded)
|
||||
assertEquals("100 flat files present on cloud", 100, remoteNames(remote).count { it.startsWith("f_") })
|
||||
assertTrue("non-ASCII name present too", "naïve café.txt" in remoteNames(remote))
|
||||
// re-sync is a clean no-op (no phantom re-uploads / loops at volume)
|
||||
val r2 = sync(pair)
|
||||
assertEquals(0, r2.uploaded); assertEquals(0, r2.deleted); assertEquals(0, r2.failedFiles)
|
||||
}
|
||||
|
||||
// ── 14. Content integrity: binary-ish bytes round-trip exactly ────────────
|
||||
@Test fun contentIntegrity_roundTrip() = runBlocking {
|
||||
val (pair, local, remote) = newPair("integrity", SyncDirection.TWO_WAY)
|
||||
val payload = (0..5000).joinToString("") { "Ω$it·" }
|
||||
write(local, "big.txt", payload); sync(pair)
|
||||
assertEquals(payload, remoteText("$remote/big.txt"))
|
||||
}
|
||||
|
||||
// ══ EDGE CASES & STRESS ═══════════════════════════════════════════════════
|
||||
|
||||
private fun writeBytes(dir: File, rel: String, bytes: ByteArray) =
|
||||
File(dir, rel).apply { parentFile?.mkdirs() }.writeBytes(bytes)
|
||||
|
||||
// 15. Empty (0-byte) file uploads correctly
|
||||
@Test fun emptyFile_uploads() = runBlocking {
|
||||
val (pair, local, remote) = newPair("empty", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
write(local, "zero.txt", "")
|
||||
val r = sync(pair)
|
||||
assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
|
||||
assertEquals(0L, provider.listFiles(remote).getOrThrow().first { it.name == "zero.txt" }.sizeBytes)
|
||||
}
|
||||
|
||||
// 16. Large file (20 MB) uploads + downloads byte-intact (OOM / streaming guard)
|
||||
@Test fun largeFile_intactRoundTrip() = runBlocking {
|
||||
val (pair, local, remote) = newPair("large", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
val size = 20 * 1024 * 1024
|
||||
val bytes = ByteArray(size).also { java.util.Random(42).nextBytes(it) }
|
||||
writeBytes(local, "big.bin", bytes)
|
||||
val r = sync(pair)
|
||||
assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded)
|
||||
assertEquals(size.toLong(), provider.listFiles(remote).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
|
||||
val out = ByteArrayOutputStream(size); provider.downloadFile("$remote/big.bin", out).getOrThrow()
|
||||
val dl = out.toByteArray()
|
||||
assertEquals(size, dl.size)
|
||||
assertArrayEquals(bytes.copyOfRange(0, 4096), dl.copyOfRange(0, 4096))
|
||||
assertArrayEquals(bytes.copyOfRange(size - 4096, size), dl.copyOfRange(size - 4096, size))
|
||||
}
|
||||
|
||||
// 17. Deeply nested path (8 levels) is created + uploaded
|
||||
@Test fun deepNesting_uploads() = runBlocking {
|
||||
val (pair, local, remote) = newPair("deep", SyncDirection.UPLOAD_ONLY, recursive = true)
|
||||
write(local, "a/b/c/d/e/f/g/deep.txt", "deep")
|
||||
assertEquals(0, sync(pair).failedFiles)
|
||||
assertTrue("deep.txt" in remoteNames("$remote/a/b/c/d/e/f/g"))
|
||||
}
|
||||
|
||||
// 18. Unicode FOLDER names (not just files) are created + encoded
|
||||
@Test fun unicodeFolderNames_upload() = runBlocking {
|
||||
val (pair, local, remote) = newPair("ufolder", SyncDirection.UPLOAD_ONLY, recursive = true)
|
||||
write(local, "Фото/café/x.txt", "u")
|
||||
assertEquals(0, sync(pair).failedFiles)
|
||||
assertTrue("x.txt" in remoteNames("$remote/Фото/café"))
|
||||
}
|
||||
|
||||
// 19. Very long filename (200 chars)
|
||||
@Test fun veryLongFilename_uploads() = runBlocking {
|
||||
val (pair, local, remote) = newPair("longname", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
val name = "L".repeat(200) + ".txt"
|
||||
write(local, name, "x")
|
||||
assertEquals(0, sync(pair).failedFiles)
|
||||
assertTrue(name in remoteNames(remote))
|
||||
}
|
||||
|
||||
// 20. File with no extension
|
||||
@Test fun noExtensionFile_uploads() = runBlocking {
|
||||
val (pair, local, remote) = newPair("noext", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
write(local, "README", "x")
|
||||
assertEquals(1, sync(pair).uploaded)
|
||||
assertTrue("README" in remoteNames(remote))
|
||||
}
|
||||
|
||||
// 21. Idempotency / loop guard — repeated syncs do NOT re-upload anything
|
||||
@Test fun idempotent_repeatedSyncsNoPhantomUploads() = runBlocking {
|
||||
val (pair, local, remote) = newPair("idem", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
repeat(10) { i -> write(local, "x_$i.txt", "v$i") }
|
||||
assertEquals(10, sync(pair).uploaded)
|
||||
repeat(4) {
|
||||
val r = sync(pair)
|
||||
assertEquals("sync must be idempotent (no re-upload loop)", 0, r.uploaded)
|
||||
assertEquals(0, r.deleted); assertEquals(0, r.failedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// 22. Bulk update — modifying many files re-uploads exactly those
|
||||
@Test fun bulkUpdate_reuploadsChanged() = runBlocking {
|
||||
val (pair, local, remote) = newPair("bulkupd", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
repeat(10) { i -> write(local, "u_$i.txt", "v1") }; sync(pair)
|
||||
Thread.sleep(1100)
|
||||
repeat(10) { i -> write(local, "u_$i.txt", "v2-updated-content") }
|
||||
assertEquals(10, sync(pair).uploaded)
|
||||
assertEquals("v2-updated-content", remoteText("$remote/u_0.txt"))
|
||||
}
|
||||
|
||||
// 23. Bulk delete (MIRROR two-way) propagates all deletions
|
||||
@Test fun mirror_bulkDeletePropagates() = runBlocking {
|
||||
val (pair, local, remote) = newPair("bulkdel", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
|
||||
repeat(10) { i -> write(local, "d_$i.txt", "x") }; sync(pair)
|
||||
repeat(10) { i -> File(local, "d_$i.txt").delete() }
|
||||
assertEquals(10, sync(pair).deleted)
|
||||
assertEquals(0, remoteNames(remote).count { it.startsWith("d_") })
|
||||
}
|
||||
|
||||
// 24. Bulk download (download-only) pulls all remote files
|
||||
@Test fun downloadOnly_bulkPull() = runBlocking {
|
||||
val (pair, local, remote) = newPair("bulkdl", SyncDirection.DOWNLOAD_ONLY)
|
||||
repeat(10) { i -> putRemote(remote, "r_$i.txt", "cloud$i") }
|
||||
assertEquals(10, sync(pair).downloaded)
|
||||
assertEquals(10, local.listFiles()!!.count { it.name.startsWith("r_") })
|
||||
}
|
||||
|
||||
// 25. KEEP_BOTH conflict strategy records a conflict (no silent clobber)
|
||||
@Test fun twoWay_keepBoth_recordsConflict() = runBlocking {
|
||||
val (pair, local, remote) = newPair("keepboth", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_BOTH)
|
||||
write(local, "c.txt", "base"); sync(pair); sync(pair) // baseline + reconcile
|
||||
Thread.sleep(1100)
|
||||
write(local, "c.txt", "LOCAL"); putRemote(remote, "c.txt", "REMOTE")
|
||||
assertEquals(1, sync(pair).conflicts)
|
||||
assertEquals("LOCAL", File(local, "c.txt").readText())
|
||||
assertEquals("REMOTE", remoteText("$remote/c.txt"))
|
||||
}
|
||||
|
||||
// 26. Min-size filter skips tiny files
|
||||
@Test fun filters_minSizeSkipsTiny() = runBlocking {
|
||||
val (pair, local, remote) = newPair("minsize", SyncDirection.UPLOAD_ONLY, minKb = 1)
|
||||
write(local, "tiny.txt", "x") // < 1 KB
|
||||
write(local, "big.txt", "A".repeat(2048)) // ~2 KB
|
||||
sync(pair)
|
||||
val n = remoteNames(remote)
|
||||
assertFalse("tiny.txt" in n); assertTrue("big.txt" in n)
|
||||
}
|
||||
|
||||
// 27. Include-extension filter uploads only matching files
|
||||
@Test fun filters_includeExtensionOnly() = runBlocking {
|
||||
val (pair, local, remote) = newPair("incl", SyncDirection.UPLOAD_ONLY, includeExtensions = "jpg")
|
||||
write(local, "keep.jpg", "x"); write(local, "skip.txt", "y")
|
||||
sync(pair)
|
||||
val n = remoteNames(remote)
|
||||
assertTrue("keep.jpg" in n); assertFalse("skip.txt" in n)
|
||||
}
|
||||
|
||||
// 28. Whole-folder wipe locally (MIRROR) removes all remote copies
|
||||
@Test fun mirror_emptyLocalWipesRemote() = runBlocking {
|
||||
val (pair, local, remote) = newPair("wipe", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR)
|
||||
repeat(5) { i -> write(local, "w_$i.txt", "x") }; sync(pair)
|
||||
local.listFiles()!!.forEach { it.delete() }
|
||||
assertEquals(5, sync(pair).deleted)
|
||||
assertEquals(0, remoteNames(remote).count { it.startsWith("w_") })
|
||||
}
|
||||
|
||||
// ══ INTERRUPTION / ATOMICITY ══════════════════════════════════════════════
|
||||
|
||||
// 29. A write that fails mid-stream must leave the existing file intact (no truncation)
|
||||
@Test fun atomicWrite_failedWriteLeavesOriginalIntact() = runBlocking {
|
||||
val dir = File(ctx.cacheDir, "atomic_${System.currentTimeMillis()}").apply { mkdirs() }
|
||||
localDirs += dir
|
||||
File(dir, "f.txt").writeText("ORIGINAL-GOOD-CONTENT")
|
||||
val accessor = LocalAccessor.JavaFile(dir)
|
||||
val outcome = runCatching {
|
||||
accessor.writeAtomically("f.txt") { os ->
|
||||
os.write("PARTIAL-GARBAGE".toByteArray()); os.flush()
|
||||
throw java.io.IOException("simulated network drop mid-download")
|
||||
}
|
||||
}
|
||||
assertTrue("the failed write must propagate", outcome.isFailure)
|
||||
assertEquals("original must be untouched after a failed write", "ORIGINAL-GOOD-CONTENT", File(dir, "f.txt").readText())
|
||||
assertTrue("no leftover .sfpart temp", dir.listFiles()!!.none { it.name.endsWith(".sfpart") })
|
||||
}
|
||||
|
||||
// 30. A sync interrupted partway (provider fails after N files) loses nothing and the
|
||||
// next sync completes the rest with all content intact.
|
||||
@Test fun interruptedSync_resumesCleanlyNoCorruption() = runBlocking {
|
||||
val (pair, local, remote) = newPair("interrupt", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP)
|
||||
repeat(10) { i -> write(local, "i_$i.txt", "content-$i-".repeat(50)) }
|
||||
// Provider that simulates a connection drop after 4 successful uploads.
|
||||
val flaky = object : CloudProvider by provider {
|
||||
private val n = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
override suspend fun uploadFile(localStream: java.io.InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result<RemoteFile> =
|
||||
if (n.incrementAndGet() > 4) Result.failure(java.io.IOException("connection dropped"))
|
||||
else provider.uploadFile(localStream, remotePath, sizeBytes, onProgress)
|
||||
}
|
||||
val r1 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), flaky)
|
||||
assertTrue("some files should fail on the dropped sync", r1.failedFiles > 0)
|
||||
// Re-sync with the healthy provider completes the rest.
|
||||
val r2 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), provider)
|
||||
assertEquals("re-sync must complete with no failures", 0, r2.failedFiles)
|
||||
assertEquals("all 10 files end up on the cloud", 10, remoteNames(remote).count { it.startsWith("i_") })
|
||||
assertEquals("content intact (no truncation)", "content-0-".repeat(50), remoteText("$remote/i_0.txt"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.syncflow
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.syncflow.data.db.entities.SyncFileStateEntity
|
||||
import com.syncflow.data.providers.nextcloud.NextcloudProvider
|
||||
import com.syncflow.domain.model.CloudAccount
|
||||
import com.syncflow.domain.model.ConflictStrategy
|
||||
import com.syncflow.domain.model.DeleteBehavior
|
||||
import com.syncflow.domain.model.ProviderType
|
||||
import com.syncflow.domain.model.SyncDirection
|
||||
import com.syncflow.domain.sync.LocalFileInfo
|
||||
import com.syncflow.domain.sync.SyncDecision
|
||||
import com.syncflow.domain.sync.syncDecide
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
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
|
||||
|
||||
/**
|
||||
* Real end-to-end test against a live Nextcloud, run ON the device. Exercises the actual
|
||||
* NextcloudProvider (WebDAV over real TLS, including the atomic temp+MOVE upload) and proves
|
||||
* the backup guarantee: with Upload-only + KEEP, "deleted on phone" leaves the cloud copy.
|
||||
*
|
||||
* Credentials are passed as instrumentation args (never committed):
|
||||
* adb shell am instrument -w \
|
||||
* -e ncUrl https://nextcloud.khodak.me -e ncUser syncflow-test -e ncPass <pw> \
|
||||
* com.syncflow.test/androidx.test.runner.AndroidJUnitRunner
|
||||
*/
|
||||
@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")
|
||||
private val pass = args.getString("ncPass")
|
||||
|
||||
private fun provider(): NextcloudProvider {
|
||||
val account = CloudAccount(
|
||||
id = 1L,
|
||||
displayName = "IT",
|
||||
email = user, // Nextcloud dav path uses this
|
||||
providerType = ProviderType.NEXTCLOUD,
|
||||
credentialJson = """{"username":"$user","password":"$pass"}""",
|
||||
serverUrl = url,
|
||||
port = null,
|
||||
)
|
||||
return NextcloudProvider(account)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullBackupRoundTrip() = runBlocking {
|
||||
assumeTrue("ncUrl/ncUser/ncPass instrumentation args required", url != null && user != null && pass != null)
|
||||
val p = provider()
|
||||
val dir = "SyncFlowITest_${System.currentTimeMillis()}"
|
||||
val remoteFile = "$dir/hello.txt"
|
||||
val content = "SyncFlow integration test — 0 to 100 — ${System.currentTimeMillis()}".toByteArray()
|
||||
|
||||
try {
|
||||
// 1. Connect
|
||||
assertTrue("testConnection failed", p.testConnection().isSuccess)
|
||||
|
||||
// 2. Create the backup folder
|
||||
assertTrue("createDirectory failed", p.createDirectory(dir).isSuccess)
|
||||
|
||||
// 3. Upload (exercises atomic temp-file + MOVE)
|
||||
val uploaded = p.uploadFile(ByteArrayInputStream(content), remoteFile, content.size.toLong())
|
||||
assertTrue("upload failed: ${uploaded.exceptionOrNull()}", uploaded.isSuccess)
|
||||
|
||||
// 4. List — the file is on the cloud with the right size
|
||||
val listed = p.listFiles(dir).getOrThrow()
|
||||
val entry = listed.firstOrNull { it.name == "hello.txt" }
|
||||
assertNotNull("uploaded file not found in listing", entry)
|
||||
assertEquals("remote size mismatch", content.size.toLong(), entry!!.sizeBytes)
|
||||
|
||||
// 5. Download — bytes round-trip intact
|
||||
val out = ByteArrayOutputStream()
|
||||
assertTrue("download failed", p.downloadFile(remoteFile, out).isSuccess)
|
||||
assertEquals("downloaded content mismatch", String(content), out.toString("UTF-8"))
|
||||
|
||||
// 6. THE backup guarantee. Phone copy deleted, state record exists, Upload-only + KEEP.
|
||||
val known = SyncFileStateEntity(
|
||||
syncPairId = 1L, relativePath = "hello.txt",
|
||||
localModifiedAt = Instant.now(), localSizeBytes = content.size.toLong(), localHash = null,
|
||||
remoteModifiedAt = entry.modifiedAt, remoteSizeBytes = entry.sizeBytes, remoteEtag = entry.etag,
|
||||
lastSyncedAt = Instant.now(), syncedHash = null,
|
||||
)
|
||||
val keepDecision = syncDecide(
|
||||
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.KEEP,
|
||||
local = null, remote = entry, known = known, hasPriorSyncState = true,
|
||||
)
|
||||
assertEquals("KEEP must not delete the cloud copy", SyncDecision.SKIP, keepDecision)
|
||||
|
||||
// ...and the engine would do nothing, so the file is verifiably STILL on the cloud:
|
||||
val stillThere = p.listFiles(dir).getOrThrow().any { it.name == "hello.txt" }
|
||||
assertTrue("cloud copy must survive a local delete under KEEP", stillThere)
|
||||
|
||||
// 7. Contrast: MIRROR would delete it — prove the real DELETE works (also cleanup).
|
||||
val mirrorDecision = syncDecide(
|
||||
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.MIRROR,
|
||||
local = null, remote = entry, known = known, hasPriorSyncState = true,
|
||||
)
|
||||
assertEquals(SyncDecision.DELETE_REMOTE, mirrorDecision)
|
||||
assertTrue("deleteFile failed", p.deleteFile(remoteFile).isSuccess)
|
||||
val goneAfterDelete = p.listFiles(dir).getOrThrow().none { it.name == "hello.txt" }
|
||||
assertTrue("file should be gone after explicit remote delete", goneAfterDelete)
|
||||
} finally {
|
||||
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,47 @@
|
||||
package com.syncflow
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.work.NetworkType
|
||||
import com.syncflow.worker.SyncWorker
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Scheduling/constraint mapping for WorkManager-backed syncs. Verifies the request builders
|
||||
* translate pair settings into the right constraints (Wi-Fi-only, charging-only), interval, input
|
||||
* data, and tags — the deterministic part of scheduling (without waiting for the OS to fire it).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SchedulingTest {
|
||||
|
||||
@Test fun periodic_wifiOnly_chargingOnly_intervalAndData() {
|
||||
val req = SyncWorker.buildPeriodicRequest(pairId = 42L, intervalMinutes = 30, wifiOnly = true, chargingOnly = true)
|
||||
val ws = req.workSpec
|
||||
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
|
||||
assertTrue("charging constraint", ws.constraints.requiresCharging())
|
||||
assertEquals(TimeUnit.MINUTES.toMillis(30), ws.intervalDuration)
|
||||
assertEquals(42L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
|
||||
assertTrue("sync_42" in req.tags)
|
||||
}
|
||||
|
||||
@Test fun periodic_anyNetwork_noCharging() {
|
||||
val req = SyncWorker.buildPeriodicRequest(pairId = 7L, intervalMinutes = 60, wifiOnly = false, chargingOnly = false)
|
||||
val c = req.workSpec.constraints
|
||||
assertEquals(NetworkType.CONNECTED, c.requiredNetworkType)
|
||||
assertFalse(c.requiresCharging())
|
||||
}
|
||||
|
||||
@Test fun oneTime_constraintsDataAndTag() {
|
||||
val req = SyncWorker.buildOneTimeRequest(pairId = 9L, wifiOnly = true, chargingOnly = false, silent = true)
|
||||
val ws = req.workSpec
|
||||
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
|
||||
assertFalse(ws.constraints.requiresCharging())
|
||||
assertEquals(9L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
|
||||
assertTrue(ws.input.getBoolean(SyncWorker.KEY_SILENT, false))
|
||||
assertTrue("sync_9" in req.tags)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.syncflow
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.syncflow.data.providers.sftp.SftpProvider
|
||||
import com.syncflow.data.security.CredentialStore
|
||||
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 SFTP test (the other major provider code path: sshj). Runs against a throwaway SFTP
|
||||
* server. Skips unless -e sftpHost/sftpPort/sftpUser/sftpPass are provided.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SftpProviderTest {
|
||||
|
||||
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val args = InstrumentationRegistry.getArguments()
|
||||
|
||||
private fun provider() = SftpProvider(
|
||||
CloudAccount(
|
||||
id = 1, displayName = "sftp", email = null, providerType = ProviderType.SFTP,
|
||||
credentialJson = """{"username":"${args.getString("sftpUser")}","password":"${args.getString("sftpPass")}"}""",
|
||||
serverUrl = args.getString("sftpHost"), port = args.getString("sftpPort")?.toInt(),
|
||||
),
|
||||
CredentialStore(ctx),
|
||||
)
|
||||
|
||||
@Test fun sftpFullRoundTrip() = runBlocking {
|
||||
assumeTrue("sftp* args required", args.getString("sftpHost") != null)
|
||||
val p = provider()
|
||||
val dir = "upload/it_${System.currentTimeMillis()}"
|
||||
|
||||
// Skip (don't fail) if the endpoint isn't reachable from the test runner's network —
|
||||
// e.g. a phone on an isolated VLAN that only reaches services via the reverse proxy.
|
||||
assumeTrue("SFTP endpoint not reachable from this device's network", p.testConnection().isSuccess)
|
||||
assertTrue("mkdir", p.createDirectory(dir).isSuccess)
|
||||
|
||||
// upload (atomic temp + rename), list, download
|
||||
val body = "sftp round-trip ✓".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("sftp round-trip ✓", out.toString("UTF-8"))
|
||||
|
||||
// atomic overwrite (temp + rename over existing)
|
||||
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"))
|
||||
|
||||
// special / non-ASCII name (SFTP handles UTF-8 natively, no URL encoding)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,20 +15,27 @@ import com.syncflow.data.db.entities.*
|
||||
SyncConflictEntity::class,
|
||||
SyncEventEntity::class,
|
||||
],
|
||||
version = 3,
|
||||
version = 4,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(DbConverters::class)
|
||||
abstract class SyncDatabase : RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
// Wipe file states: timestamps were stored as epoch-seconds, now epoch-millis.
|
||||
// All previously saved states are wrong so we drop and re-learn on next sync.
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM sync_file_states")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDownloaded INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncDeleted INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE sync_pairs ADD COLUMN lastSyncBytesTransferred INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
abstract fun cloudAccountDao(): CloudAccountDao
|
||||
abstract fun syncPairDao(): SyncPairDao
|
||||
|
||||
@@ -29,8 +29,8 @@ interface SyncPairDao {
|
||||
@Delete
|
||||
suspend fun delete(entity: SyncPairEntity)
|
||||
|
||||
@Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts WHERE id = :id")
|
||||
suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int)
|
||||
@Query("UPDATE sync_pairs SET lastSyncAt = :at, lastSyncResult = :result, pendingConflicts = :conflicts, lastSyncUploaded = :uploaded, lastSyncDownloaded = :downloaded, lastSyncDeleted = :deleted, lastSyncBytesTransferred = :bytes WHERE id = :id")
|
||||
suspend fun updateSyncResult(id: Long, at: Instant, result: SyncStatus, conflicts: Int, uploaded: Int, downloaded: Int, deleted: Int, bytes: Long)
|
||||
|
||||
@Query("UPDATE sync_pairs SET lastSyncResult = :status WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: SyncStatus)
|
||||
|
||||
@@ -53,6 +53,11 @@ data class SyncPairEntity(
|
||||
val lastSyncAt: Instant?,
|
||||
val lastSyncResult: SyncStatus,
|
||||
val pendingConflicts: Int,
|
||||
// Last sync outcome counters (persist across pause/resume)
|
||||
val lastSyncUploaded: Int = 0,
|
||||
val lastSyncDownloaded: Int = 0,
|
||||
val lastSyncDeleted: Int = 0,
|
||||
val lastSyncBytesTransferred: Long = 0L,
|
||||
)
|
||||
|
||||
fun SyncPairEntity.toDomain() = SyncPair(
|
||||
|
||||
@@ -2,13 +2,111 @@ package com.syncflow.data.providers.nextcloud
|
||||
|
||||
import com.syncflow.data.providers.webdav.WebDavProvider
|
||||
import com.syncflow.domain.model.CloudAccount
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Nextcloud WebDAV provider. Endpoint is /remote.php/dav/files/<username>/.
|
||||
*
|
||||
* Large files are uploaded via Nextcloud's chunked-upload API (/remote.php/dav/uploads/<user>/)
|
||||
* so they bypass per-request size caps (Apache LimitRequestBody, PHP post_max_size, proxy body
|
||||
* limits) that otherwise 413 a single multi-GB PUT. The assembly MOVE is the atomic commit, so
|
||||
* the destination only appears once every chunk is in — no temp-file dance needed for this path.
|
||||
*
|
||||
* @param chunkSize bytes per chunk; files at or below this use the parent's single-PUT path.
|
||||
*/
|
||||
class NextcloudProvider(
|
||||
account: CloudAccount,
|
||||
private val chunkSize: Long = 100L * 1024 * 1024,
|
||||
) : WebDavProvider(account) {
|
||||
|
||||
class NextcloudProvider(account: CloudAccount) : WebDavProvider(account) {
|
||||
// Nextcloud WebDAV endpoint is /remote.php/dav/files/<username>/
|
||||
override val baseUrl: String
|
||||
get() {
|
||||
val server = account.serverUrl?.trimEnd('/') ?: ""
|
||||
val email = account.email ?: "user"
|
||||
return "$server/remote.php/dav/files/$email"
|
||||
}
|
||||
|
||||
private val uploadsBase: String
|
||||
get() {
|
||||
val server = account.serverUrl?.trimEnd('/') ?: ""
|
||||
val email = account.email ?: "user"
|
||||
return "$server/remote.php/dav/uploads/$email"
|
||||
}
|
||||
|
||||
override suspend fun uploadFile(
|
||||
localStream: InputStream,
|
||||
remotePath: String,
|
||||
sizeBytes: Long,
|
||||
onProgress: (Long) -> Unit,
|
||||
): Result<RemoteFile> {
|
||||
if (sizeBytes <= chunkSize) {
|
||||
return super.uploadFile(localStream, remotePath, sizeBytes, onProgress)
|
||||
}
|
||||
return runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val uploadId = "syncflow-${System.currentTimeMillis()}-${(0..999_999).random()}"
|
||||
val dir = "$uploadsBase/$uploadId"
|
||||
mkcol(dir)
|
||||
try {
|
||||
var index = 1
|
||||
var sent = 0L
|
||||
while (sent < sizeBytes) {
|
||||
val len = minOf(chunkSize, sizeBytes - sent)
|
||||
putChunk("$dir/%05d".format(index), localStream, len)
|
||||
sent += len
|
||||
index++
|
||||
onProgress(sent)
|
||||
}
|
||||
// Assemble: MOVE the virtual .file onto the destination (atomic commit).
|
||||
val move = Request.Builder().url("$dir/.file")
|
||||
.method("MOVE", null)
|
||||
.header("Destination", url(remotePath))
|
||||
.header("Overwrite", "T")
|
||||
.header("OC-Total-Length", sizeBytes.toString())
|
||||
.build()
|
||||
client.newCall(move).execute().use { resp ->
|
||||
if (!resp.isSuccessful) throw IOException("Chunk assembly MOVE HTTP ${resp.code}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
runCatching { client.newCall(Request.Builder().url(dir).delete().build()).execute().close() }
|
||||
throw e
|
||||
}
|
||||
getFileMetadata(remotePath).getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun mkcol(url: String) {
|
||||
client.newCall(Request.Builder().url(url).method("MKCOL", null).build()).execute().use {
|
||||
if (!it.isSuccessful && it.code != 405) throw IOException("MKCOL upload session HTTP ${it.code}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun putChunk(url: String, stream: InputStream, len: Long) {
|
||||
val body = object : RequestBody() {
|
||||
override fun contentType() = "application/octet-stream".toMediaType()
|
||||
override fun contentLength() = len
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
var remaining = len
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (remaining > 0) {
|
||||
val n = stream.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt())
|
||||
if (n < 0) break
|
||||
sink.write(buf, 0, n)
|
||||
remaining -= n
|
||||
}
|
||||
}
|
||||
}
|
||||
client.newCall(Request.Builder().url(url).put(body).build()).execute().use {
|
||||
if (!it.isSuccessful) throw IOException("Chunk PUT HTTP ${it.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,17 +60,21 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
|
||||
|
||||
override suspend fun listFiles(remotePath: String): Result<List<RemoteFile>> = runCatching {
|
||||
withSftp { sftp ->
|
||||
sftp.ls(remotePath).map { entry ->
|
||||
RemoteFile(
|
||||
path = "$remotePath/${entry.name}".replace("//", "/"),
|
||||
name = entry.name,
|
||||
isDirectory = entry.isDirectory,
|
||||
sizeBytes = entry.attributes.size,
|
||||
modifiedAt = Instant.ofEpochSecond(entry.attributes.mtime.toLong()),
|
||||
etag = null,
|
||||
mimeType = null,
|
||||
)
|
||||
}
|
||||
sftp.ls(remotePath)
|
||||
// Drop "."/".." and any name with a path separator so a hostile server can't
|
||||
// smuggle a traversal segment into a local/remote path.
|
||||
.filter { it.name != "." && it.name != ".." && !it.name.contains('/') && !it.name.contains('\\') }
|
||||
.map { entry ->
|
||||
RemoteFile(
|
||||
path = "$remotePath/${entry.name}".replace("//", "/"),
|
||||
name = entry.name,
|
||||
isDirectory = entry.isDirectory,
|
||||
sizeBytes = entry.attributes.size,
|
||||
modifiedAt = Instant.ofEpochSecond(entry.attributes.mtime.toLong()),
|
||||
etag = null,
|
||||
mimeType = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +84,25 @@ class SftpProvider(private val account: CloudAccount, private val credentialStor
|
||||
sizeBytes: Long,
|
||||
onProgress: (Long) -> Unit,
|
||||
): 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 ->
|
||||
sftp.put(object : InMemorySourceFile() {
|
||||
override fun getName() = remotePath.substringAfterLast('/')
|
||||
override fun getName() = name
|
||||
override fun getLength() = sizeBytes
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
||||
val pass = creds["password"]?.jsonPrimitive?.content ?: ""
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(5, TimeUnit.MINUTES)
|
||||
.writeTimeout(5, TimeUnit.MINUTES)
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("Authorization", Credentials.basic(user, pass))
|
||||
@@ -93,15 +94,30 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
||||
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 ->
|
||||
if (!resp.isSuccessful) throw Exception("Upload HTTP ${resp.code}")
|
||||
}
|
||||
moveFile(tmpPath, remotePath).getOrElse { e ->
|
||||
runCatching { deleteFile(tmpPath) }
|
||||
throw e
|
||||
}
|
||||
onProgress(sizeBytes)
|
||||
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 {
|
||||
withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder().url(url(remotePath)).get().build()
|
||||
@@ -134,7 +150,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
||||
withContext(Dispatchers.IO) {
|
||||
val req = Request.Builder().url(url(remotePath)).method("MKCOL", null).build()
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +187,13 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun url(path: String) = "$baseUrl/${path.trimStart('/')}"
|
||||
// Build a properly percent-encoded URL. addPathSegments encodes each segment (spaces,
|
||||
// ampersands, and — critically — non-ASCII like "café"), which keeps OkHttp from rejecting
|
||||
// non-ASCII in the WebDAV MOVE "Destination" header and avoids malformed request URLs.
|
||||
protected fun url(path: String): String {
|
||||
val base = baseUrl.toHttpUrlOrNull() ?: return "$baseUrl/${path.trimStart('/')}"
|
||||
return base.newBuilder().addPathSegments(path.trimStart('/')).build().toString()
|
||||
}
|
||||
|
||||
private fun parsePropfind(xml: String, parentPath: String, dropFirst: Boolean = true): List<RemoteFile> {
|
||||
val results = mutableListOf<RemoteFile>()
|
||||
|
||||
@@ -22,7 +22,7 @@ object AppModule {
|
||||
fun provideDatabase(@ApplicationContext ctx: Context): SyncDatabase =
|
||||
Room.databaseBuilder(ctx, SyncDatabase::class.java, "syncflow.db")
|
||||
.fallbackToDestructiveMigrationFrom(1)
|
||||
.addMigrations(SyncDatabase.MIGRATION_2_3)
|
||||
.addMigrations(SyncDatabase.MIGRATION_2_3, SyncDatabase.MIGRATION_3_4)
|
||||
.build()
|
||||
|
||||
@Provides fun provideCloudAccountDao(db: SyncDatabase): CloudAccountDao = db.cloudAccountDao()
|
||||
|
||||
@@ -58,6 +58,7 @@ enum class ConflictStrategy(val label: String) {
|
||||
enum class DeleteBehavior(val label: String, val description: String) {
|
||||
MIRROR("Mirror deletions", "Delete on target when deleted on source"),
|
||||
KEEP("Keep deleted files", "Never delete — only add/update"),
|
||||
ARCHIVE("Archive deleted", "Move files deleted from phone to _Deleted/ folder on remote"),
|
||||
}
|
||||
|
||||
enum class ScheduleType(val label: String) {
|
||||
@@ -69,5 +70,5 @@ enum class ScheduleType(val label: String) {
|
||||
}
|
||||
|
||||
enum class SyncStatus {
|
||||
IDLE, SYNCING, SUCCESS, PARTIAL, FAILED, CONFLICT,
|
||||
IDLE, SYNCING, PAUSED, SUCCESS, PARTIAL, FAILED, CONFLICT,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import com.syncflow.domain.model.SyncPair
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
@@ -14,10 +16,17 @@ sealed class LocalAccessor {
|
||||
|
||||
abstract fun walkFiles(pair: SyncPair): Map<String, LocalFileInfo>
|
||||
abstract fun openInputStream(relativePath: String): InputStream?
|
||||
abstract fun createOutputStream(relativePath: String): OutputStream?
|
||||
abstract fun delete(relativePath: String): Boolean
|
||||
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) ────────────────────
|
||||
|
||||
class JavaFile(private val root: File) : LocalAccessor() {
|
||||
@@ -48,10 +57,30 @@ sealed class LocalAccessor {
|
||||
override fun openInputStream(relativePath: String): 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)
|
||||
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()
|
||||
@@ -131,7 +160,7 @@ sealed class LocalAccessor {
|
||||
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('/')
|
||||
var currentId = DocumentsContract.getTreeDocumentId(treeUri)
|
||||
|
||||
@@ -141,7 +170,7 @@ sealed class LocalAccessor {
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
||||
val newDir = DocumentsContract.createDocument(
|
||||
resolver, parentUri, DocumentsContract.Document.MIME_TYPE_DIR, parts[i]
|
||||
) ?: return null
|
||||
) ?: throw IOException("Cannot create directory ${parts[i]} for $relativePath")
|
||||
DocumentsContract.getDocumentId(newDir)
|
||||
}
|
||||
}
|
||||
@@ -149,19 +178,47 @@ sealed class LocalAccessor {
|
||||
val fileName = parts.last()
|
||||
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, currentId)
|
||||
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, currentId)
|
||||
val tmpName = ".$fileName.sfpart"
|
||||
|
||||
// Delete existing to allow overwrite
|
||||
findChildId(childrenUri, fileName)?.let { existingId ->
|
||||
DocumentsContract.deleteDocument(
|
||||
resolver,
|
||||
DocumentsContract.buildDocumentUriUsingTree(treeUri, existingId)
|
||||
)
|
||||
// Clear any leftover temp document from a previously interrupted write.
|
||||
findChildId(childrenUri, tmpName)?.let { staleId ->
|
||||
runCatching {
|
||||
DocumentsContract.deleteDocument(
|
||||
resolver, DocumentsContract.buildDocumentUriUsingTree(treeUri, staleId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val newUri = DocumentsContract.createDocument(
|
||||
resolver, parentUri, "application/octet-stream", fileName
|
||||
) ?: return null
|
||||
return resolver.openOutputStream(newUri)
|
||||
val tmpUri = DocumentsContract.createDocument(
|
||||
resolver, parentUri, "application/octet-stream", tmpName
|
||||
) ?: throw IOException("Cannot create temp document for $relativePath")
|
||||
|
||||
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 {
|
||||
|
||||
@@ -24,6 +24,8 @@ import kotlinx.coroutines.sync.withPermit
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncEngine @Inject constructor(
|
||||
@@ -33,24 +35,28 @@ class SyncEngine @Inject constructor(
|
||||
private val eventDao: SyncEventDao,
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
|
||||
suspend fun sync(
|
||||
pair: SyncPair,
|
||||
provider: CloudProvider,
|
||||
onProgress: (suspend (uploaded: Int, downloaded: Int, deleted: Int, bytesTransferred: Long) -> Unit)? = null,
|
||||
): SyncResult {
|
||||
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
|
||||
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
|
||||
|
||||
return try {
|
||||
val result = performSync(pair, provider)
|
||||
val result = performSync(pair, provider, onProgress = onProgress)
|
||||
val finalStatus = when {
|
||||
result.failedFiles > 0 && result.conflicts > 0 -> SyncStatus.CONFLICT
|
||||
result.failedFiles > 0 -> SyncStatus.PARTIAL
|
||||
result.conflicts > 0 -> SyncStatus.CONFLICT
|
||||
else -> SyncStatus.SUCCESS
|
||||
}
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts)
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), finalStatus, result.conflicts, result.uploaded, result.downloaded, result.deleted, result.bytesTransferred)
|
||||
logEvent(pair.id, SyncEventType.SYNC_COMPLETED, null, "↑${result.uploaded} ↓${result.downloaded} ✕${result.deleted} ✗${result.failedFiles}", result.bytesTransferred)
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Sync failed for pair ${pair.id}")
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
|
||||
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0, 0, 0, 0, 0L)
|
||||
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, e.message, 0)
|
||||
SyncResult(failedFiles = 1, error = e)
|
||||
}
|
||||
@@ -62,14 +68,49 @@ 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,
|
||||
isRetry: Boolean = false,
|
||||
onProgress: (suspend (Int, Int, Int, Long) -> Unit)? = null,
|
||||
): 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)
|
||||
|
||||
@@ -81,12 +122,16 @@ class SyncEngine @Inject constructor(
|
||||
knownStates.keys.none { it in localFiles }) {
|
||||
Timber.w("SyncEngine: stale folder states detected for pair ${pair.id} — resetting")
|
||||
fileStateDao.deleteForPair(pair.id)
|
||||
return performSync(pair, provider, isRetry = true)
|
||||
return performSync(pair, provider, isRetry = true, onProgress = onProgress)
|
||||
}
|
||||
|
||||
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)
|
||||
val bytesAtomic = AtomicLong(0L)
|
||||
|
||||
// Each async block returns its outcome; no shared mutable state across coroutines.
|
||||
data class FileOutcome(
|
||||
@@ -100,6 +145,14 @@ class SyncEngine @Inject constructor(
|
||||
allPaths.map { rel ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
// Defense-in-depth against a malicious/compromised remote returning a
|
||||
// path that escapes the sync root (e.g. "../../evil"). Skip rather than
|
||||
// write outside pair.localPath / pair.remotePath.
|
||||
if (isUnsafeSyncPath(rel)) {
|
||||
Timber.w("SyncEngine: skipping unsafe path for pair ${pair.id}: $rel")
|
||||
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, "unsafe path", 0)
|
||||
return@withPermit FileOutcome(skipped = 1)
|
||||
}
|
||||
val local = localFiles[rel]
|
||||
val remote = remoteFiles[rel]
|
||||
val known = knownStates[rel]
|
||||
@@ -119,6 +172,9 @@ class SyncEngine @Inject constructor(
|
||||
return@withPermit FileOutcome(failed = 1)
|
||||
}
|
||||
logEvent(pair.id, SyncEventType.FILE_UPLOADED, rel, null, bytes)
|
||||
val up = uploadedAtomic.incrementAndGet()
|
||||
bytesAtomic.addAndGet(bytes)
|
||||
onProgress?.invoke(up, downloadedAtomic.get(), deletedAtomic.get(), bytesAtomic.get())
|
||||
// Don't store remote metadata from upload response — the server (Nextcloud etc.)
|
||||
// may change mtime/etag during post-upload processing. Leaving remoteModifiedAt
|
||||
// null forces the SKIP reconciliation on the next sync to fill it in from the
|
||||
@@ -128,8 +184,8 @@ class SyncEngine @Inject constructor(
|
||||
}
|
||||
SyncDecision.DOWNLOAD -> {
|
||||
val bytes = runCatching {
|
||||
accessor.createOutputStream(rel)?.use { stream ->
|
||||
provider.downloadFile("${pair.remotePath}/$rel", stream) { }
|
||||
accessor.writeAtomically(rel) { stream ->
|
||||
provider.downloadFile("${pair.remotePath}/$rel", stream) { }.getOrThrow()
|
||||
}
|
||||
remote!!.sizeBytes
|
||||
}.getOrElse { e ->
|
||||
@@ -142,6 +198,9 @@ class SyncEngine @Inject constructor(
|
||||
.getOrDefault(System.currentTimeMillis()).takeIf { it > 0L }
|
||||
?: System.currentTimeMillis()
|
||||
logEvent(pair.id, SyncEventType.FILE_DOWNLOADED, rel, null, bytes)
|
||||
val down = downloadedAtomic.incrementAndGet()
|
||||
bytesAtomic.addAndGet(bytes)
|
||||
onProgress?.invoke(uploadedAtomic.get(), down, deletedAtomic.get(), bytesAtomic.get())
|
||||
FileOutcome(downloaded = 1, bytesTransferred = bytes,
|
||||
newState = buildState(pair.id, rel,
|
||||
LocalFileInfo(rel, remote!!.sizeBytes, localMtime),
|
||||
@@ -153,13 +212,31 @@ class SyncEngine @Inject constructor(
|
||||
if (!deleted) Timber.w("SyncEngine: DELETE_LOCAL failed (silent) for $rel")
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "local", 0)
|
||||
val del = deletedAtomic.incrementAndGet()
|
||||
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.DELETE_REMOTE -> {
|
||||
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
|
||||
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
|
||||
if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) {
|
||||
val archivePath = "${pair.remotePath}/_Deleted/$rel"
|
||||
runCatching {
|
||||
// Create the _Deleted base itself first — ensureRemoteDirs only
|
||||
// makes sub-parents of rel, so for a top-level file the MOVE
|
||||
// would otherwise fail with a missing-parent error.
|
||||
provider.createDirectory("${pair.remotePath}/_Deleted")
|
||||
ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel)
|
||||
provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
|
||||
}.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "archived", 0)
|
||||
} else {
|
||||
runCatching { provider.deleteFile("${pair.remotePath}/$rel") }
|
||||
.onFailure { e -> Timber.e(e, "SyncEngine: DELETE_REMOTE failed for $rel") }
|
||||
fileStateDao.delete(pair.id, rel)
|
||||
logEvent(pair.id, SyncEventType.FILE_DELETED, rel, "remote", 0)
|
||||
}
|
||||
val del = deletedAtomic.incrementAndGet()
|
||||
onProgress?.invoke(uploadedAtomic.get(), downloadedAtomic.get(), del, bytesAtomic.get())
|
||||
FileOutcome(deleted = 1)
|
||||
}
|
||||
SyncDecision.CONFLICT -> {
|
||||
@@ -332,6 +409,19 @@ internal fun syncDecide(
|
||||
|
||||
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* every path before any file operation as defense-in-depth (WebDAV already filters names at the
|
||||
* parser; SFTP and any future provider are covered here).
|
||||
*/
|
||||
internal fun isUnsafeSyncPath(rel: String): Boolean {
|
||||
if (rel.isBlank()) return true
|
||||
val normalized = rel.replace('\\', '/')
|
||||
if (normalized.startsWith("/")) return true
|
||||
return normalized.split('/').any { it == ".." }
|
||||
}
|
||||
|
||||
data class SyncResult(
|
||||
val uploaded: Int = 0,
|
||||
val downloaded: Int = 0,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.syncflow.ui.addpair
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.domain.model.*
|
||||
import com.syncflow.ui.browser.LocalBrowserDialog
|
||||
import com.syncflow.ui.browser.RemoteBrowserDialog
|
||||
import java.time.DayOfWeek
|
||||
|
||||
@@ -33,17 +36,29 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
||||
|
||||
val context = LocalContext.current
|
||||
var showRemoteBrowser by remember { mutableStateOf(false) }
|
||||
var showLocalBrowser by remember { mutableStateOf(false) }
|
||||
|
||||
val dirPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val safLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
it,
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
vm.update { copy(localPath = it.toString()) }
|
||||
vm.update { copy(localPath = uri.toString()) }
|
||||
}
|
||||
}
|
||||
|
||||
if (showLocalBrowser) {
|
||||
LocalBrowserDialog(
|
||||
initialPath = s.localPath.ifBlank { "" },
|
||||
onSelect = { path ->
|
||||
vm.update { copy(localPath = path) }
|
||||
showLocalBrowser = false
|
||||
},
|
||||
onDismiss = { showLocalBrowser = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showRemoteBrowser && s.selectedAccountId != -1L) {
|
||||
RemoteBrowserDialog(
|
||||
accountId = s.selectedAccountId,
|
||||
@@ -108,18 +123,17 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Local folder
|
||||
OutlinedTextField(
|
||||
value = uriToDisplay(s.localPath), onValueChange = {},
|
||||
label = { Text("Local folder") },
|
||||
leadingIcon = { Icon(Icons.Default.PhoneAndroid, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { dirPicker.launch(null) }) {
|
||||
Icon(Icons.Default.FolderOpen, "Browse")
|
||||
}
|
||||
},
|
||||
readOnly = true, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Tap to choose 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
|
||||
OutlinedTextField(
|
||||
@@ -151,7 +165,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
||||
label = "Direction",
|
||||
options = SyncDirection.entries,
|
||||
selected = s.syncDirection,
|
||||
onSelect = { vm.update { copy(syncDirection = it) } },
|
||||
onSelect = { vm.setDirection(it) },
|
||||
itemLabel = { "${it.label} — ${it.description}" },
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -167,7 +181,7 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
|
||||
label = "Deletion behaviour",
|
||||
options = DeleteBehavior.entries,
|
||||
selected = s.deleteBehavior,
|
||||
onSelect = { vm.update { copy(deleteBehavior = it) } },
|
||||
onSelect = { vm.setDeleteBehavior(it) },
|
||||
itemLabel = { "${it.label} — ${it.description}" },
|
||||
)
|
||||
}
|
||||
@@ -370,10 +384,17 @@ private fun <T> RadioGroup(
|
||||
}
|
||||
options.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = option == selected,
|
||||
role = Role.RadioButton,
|
||||
onClick = { onSelect(option) },
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = option == selected, onClick = { onSelect(option) })
|
||||
// onClick = null: the whole row handles selection (bigger tap target + a11y).
|
||||
RadioButton(selected = option == selected, onClick = null)
|
||||
Text(itemLabel(option), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.*
|
||||
@@ -28,7 +31,10 @@ data class AddPairUiState(
|
||||
// ── Sync type ────────────────────────────────────────────────────────────
|
||||
val syncDirection: SyncDirection = SyncDirection.TWO_WAY,
|
||||
val conflictStrategy: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
|
||||
val deleteBehavior: DeleteBehavior = DeleteBehavior.MIRROR,
|
||||
val deleteBehavior: DeleteBehavior = recommendedDeleteBehavior(SyncDirection.TWO_WAY),
|
||||
// True once the user explicitly picks a deletion behaviour, so changing direction stops
|
||||
// auto-overriding their choice.
|
||||
val deleteBehaviorTouched: Boolean = false,
|
||||
val recursive: Boolean = true,
|
||||
// ── Schedule ─────────────────────────────────────────────────────────────
|
||||
val scheduleType: ScheduleType = ScheduleType.INTERVAL,
|
||||
@@ -56,6 +62,16 @@ data class AddPairUiState(
|
||||
val done: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Safe default deletion behaviour for a given direction. One-way backups must NOT propagate a
|
||||
* local deletion to the cloud (the whole point of a backup), so they default to KEEP; two-way
|
||||
* sync defaults to MIRROR. The user can always override — all three options stay selectable.
|
||||
*/
|
||||
internal fun recommendedDeleteBehavior(direction: SyncDirection): DeleteBehavior = when (direction) {
|
||||
SyncDirection.UPLOAD_ONLY, SyncDirection.DOWNLOAD_ONLY -> DeleteBehavior.KEEP
|
||||
SyncDirection.TWO_WAY -> DeleteBehavior.MIRROR
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AddPairViewModel @Inject constructor(
|
||||
private val syncPairDao: SyncPairDao,
|
||||
@@ -93,6 +109,7 @@ class AddPairViewModel @Inject constructor(
|
||||
syncDirection = pair.syncDirection,
|
||||
conflictStrategy = pair.conflictStrategy,
|
||||
deleteBehavior = pair.deleteBehavior,
|
||||
deleteBehaviorTouched = true, // preserve the saved choice when editing
|
||||
recursive = pair.recursive,
|
||||
scheduleType = pair.scheduleType,
|
||||
intervalMinutes = pair.scheduleIntervalMinutes,
|
||||
@@ -119,6 +136,18 @@ class AddPairViewModel @Inject constructor(
|
||||
|
||||
fun update(transform: AddPairUiState.() -> AddPairUiState) = _state.update(transform)
|
||||
|
||||
/** Changing direction re-applies the safe deletion default unless the user already chose one. */
|
||||
fun setDirection(direction: SyncDirection) = _state.update { s ->
|
||||
s.copy(
|
||||
syncDirection = direction,
|
||||
deleteBehavior = if (s.deleteBehaviorTouched) s.deleteBehavior else recommendedDeleteBehavior(direction),
|
||||
)
|
||||
}
|
||||
|
||||
fun setDeleteBehavior(behavior: DeleteBehavior) = _state.update {
|
||||
it.copy(deleteBehavior = behavior, deleteBehaviorTouched = true)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
val errors = buildList {
|
||||
@@ -150,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)
|
||||
@@ -163,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package com.syncflow.ui.browser
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
private data class LocalEntry(val file: File, val childCount: Int)
|
||||
|
||||
private val STORAGE_ROOT = File("/storage/emulated/0")
|
||||
|
||||
private data class Shortcut(val label: String, val icon: ImageVector, val path: String)
|
||||
private val SHORTCUTS = listOf(
|
||||
Shortcut("Camera", Icons.Default.PhotoCamera, "/storage/emulated/0/DCIM/Camera"),
|
||||
Shortcut("Downloads", Icons.Default.Download, "/storage/emulated/0/Download"),
|
||||
Shortcut("Documents", Icons.Default.Description, "/storage/emulated/0/Documents"),
|
||||
Shortcut("Pictures", Icons.Default.Image, "/storage/emulated/0/Pictures"),
|
||||
Shortcut("Music", Icons.Default.MusicNote, "/storage/emulated/0/Music"),
|
||||
Shortcut("Videos", Icons.Default.Videocam, "/storage/emulated/0/Movies"),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocalBrowserDialog(
|
||||
initialPath: String = STORAGE_ROOT.absolutePath,
|
||||
onSelect: (path: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var currentPath by remember { mutableStateOf(File(initialPath.ifBlank { STORAGE_ROOT.absolutePath }).let { if (it.isDirectory) it else STORAGE_ROOT }) }
|
||||
var pathStack by remember { mutableStateOf(listOf(currentPath)) }
|
||||
var entries by remember { mutableStateOf<List<LocalEntry>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var searchActive by remember { mutableStateOf(false) }
|
||||
val breadcrumbState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadDir(dir: File) {
|
||||
isLoading = true
|
||||
entries = emptyList()
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
dir.listFiles()
|
||||
?.filter { it.isDirectory && !it.name.startsWith(".") }
|
||||
?.sortedBy { it.name.lowercase() }
|
||||
?.map { f -> LocalEntry(f, f.listFiles()?.count { it.isDirectory } ?: 0) }
|
||||
?: emptyList()
|
||||
}
|
||||
entries = result
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(dir: File) {
|
||||
currentPath = dir
|
||||
pathStack = pathStack + dir
|
||||
searchQuery = ""
|
||||
searchActive = false
|
||||
loadDir(dir)
|
||||
}
|
||||
|
||||
fun navigateUp(): Boolean {
|
||||
if (pathStack.size <= 1) return false
|
||||
val newStack = pathStack.dropLast(1)
|
||||
pathStack = newStack
|
||||
currentPath = newStack.last()
|
||||
searchQuery = ""
|
||||
searchActive = false
|
||||
loadDir(currentPath)
|
||||
return true
|
||||
}
|
||||
|
||||
fun navigateToBreadcrumb(dir: File) {
|
||||
val idx = pathStack.indexOfLast { it.absolutePath == dir.absolutePath }
|
||||
pathStack = if (idx >= 0) pathStack.take(idx + 1) else listOf(dir)
|
||||
currentPath = dir
|
||||
searchQuery = ""
|
||||
searchActive = false
|
||||
loadDir(dir)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { loadDir(currentPath) }
|
||||
|
||||
// Build breadcrumb segments relative to storage root
|
||||
val relParts = currentPath.absolutePath
|
||||
.removePrefix(STORAGE_ROOT.absolutePath)
|
||||
.trimStart('/')
|
||||
.split('/')
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
// Auto-scroll breadcrumbs to end
|
||||
LaunchedEffect(currentPath) {
|
||||
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, relParts.size)) }
|
||||
}
|
||||
|
||||
val filtered = if (searchQuery.isBlank()) entries
|
||||
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
|
||||
|
||||
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" }
|
||||
val context = LocalContext.current
|
||||
val hasAllFilesAccess = remember {
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
var topInset by remember { mutableStateOf(0.dp) }
|
||||
var bottomInset by remember { mutableStateOf(56.dp) }
|
||||
DisposableEffect(view) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
with(density) {
|
||||
topInset = bars.top.toDp()
|
||||
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
|
||||
}
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(view)
|
||||
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (!navigateUp()) onDismiss() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
if (searchActive) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
placeholder = { Text("Search folders…") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {}),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text("Choose Local Folder", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
|
||||
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
|
||||
// ── All-files-access banner ──────────────────────────────────
|
||||
if (!hasAllFilesAccess) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onErrorContainer)
|
||||
Text(
|
||||
"Grant \"All files access\" to browse and sync all folders",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
Uri.fromParts("package", context.packageName, null))
|
||||
else Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
) { Text("Grant", style = MaterialTheme.typography.labelSmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────
|
||||
Surface(tonalElevation = 1.dp) {
|
||||
LazyRow(
|
||||
state = breadcrumbState,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
BreadcrumbChip(label = "📱 Storage", isLast = relParts.isEmpty(),
|
||||
onClick = { navigateToBreadcrumb(STORAGE_ROOT) })
|
||||
}
|
||||
itemsIndexed(relParts) { idx, part ->
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val partPath = STORAGE_ROOT.absolutePath + "/" + relParts.take(idx + 1).joinToString("/")
|
||||
BreadcrumbChip(label = part, isLast = idx == relParts.lastIndex,
|
||||
onClick = { navigateToBreadcrumb(File(partPath)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
when {
|
||||
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
if (currentPath.absolutePath == STORAGE_ROOT.absolutePath && searchQuery.isBlank()) {
|
||||
item {
|
||||
Text("Quick access", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 6.dp))
|
||||
LazyRow(contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)) {
|
||||
items(SHORTCUTS.filter { File(it.path).isDirectory }) { sc ->
|
||||
ShortcutChip(sc, onClick = { navigateTo(File(sc.path)) })
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
|
||||
Text("All folders", style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 4.dp))
|
||||
}
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
item {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(if (searchQuery.isBlank()) "No subfolders" else "No results for \"$searchQuery\"",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filtered, key = { it.file.absolutePath }) { entry ->
|
||||
LocalFolderItem(entry = entry, onClick = { navigateTo(entry.file) })
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Select button ────────────────────────────────────────────
|
||||
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Button(
|
||||
onClick = { onSelect(currentPath.absolutePath) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Select \"$currentFolderName\"",
|
||||
style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Spacer(Modifier.height(bottomInset))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
|
||||
if (isLast) {
|
||||
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1)
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = onClick, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShortcutChip(sc: Shortcut, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier.height(72.dp).width(80.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(sc.icon, null, Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(sc.label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalFolderItem(entry: LocalEntry, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF1B5E20).copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Folder, null, Modifier.size(26.dp), tint = Color(0xFF2E7D32))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(entry.file.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Medium)
|
||||
if (entry.childCount > 0) {
|
||||
Text("${entry.childCount} subfolder${if (entry.childCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
package com.syncflow.ui.browser
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -30,79 +48,205 @@ fun RemoteBrowserDialog(
|
||||
LaunchedEffect(accountId, initialPath) { vm.init(accountId, initialPath) }
|
||||
|
||||
val state by vm.state.collectAsState()
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var searchActive by remember { mutableStateOf(false) }
|
||||
var showNewFolderDialog by remember { mutableStateOf(false) }
|
||||
val breadcrumbState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Auto-scroll breadcrumbs to end when path changes
|
||||
val segments = state.currentPath.trimEnd('/').split('/').filter { it.isNotEmpty() }
|
||||
LaunchedEffect(state.currentPath) {
|
||||
scope.launch { breadcrumbState.animateScrollToItem(maxOf(0, segments.size)) }
|
||||
searchQuery = ""
|
||||
searchActive = false
|
||||
}
|
||||
|
||||
val filtered = if (searchQuery.isBlank()) state.entries
|
||||
else state.entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
|
||||
|
||||
val currentFolderName = state.currentPath.trimEnd('/').substringAfterLast('/').ifBlank { "Root" }
|
||||
|
||||
if (showNewFolderDialog) {
|
||||
NewFolderDialog(
|
||||
onConfirm = { name ->
|
||||
vm.createFolder(name)
|
||||
showNewFolderDialog = false
|
||||
},
|
||||
onDismiss = { showNewFolderDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.95f)
|
||||
.fillMaxHeight(0.85f),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column {
|
||||
// Title bar
|
||||
val view = LocalView.current
|
||||
val density = LocalDensity.current
|
||||
var topInset by remember { mutableStateOf(0.dp) }
|
||||
var bottomInset by remember { mutableStateOf(56.dp) }
|
||||
DisposableEffect(view) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
with(density) {
|
||||
topInset = bars.top.toDp()
|
||||
bottomInset = maxOf(bars.bottom.toDp(), 56.dp)
|
||||
}
|
||||
insets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(view)
|
||||
onDispose { ViewCompat.setOnApplyWindowInsetsListener(view, null) }
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("Choose remote folder", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
state.currentPath,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { if (!vm.navigateUp()) onDismiss() }) {
|
||||
Icon(Icons.Default.ArrowBack, null)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
if (searchActive) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||
placeholder = { Text("Search in folder…") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = {}),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text("Choose Folder", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Select current folder
|
||||
TextButton(onClick = { onSelect(state.currentPath) }) {
|
||||
Text("Select here")
|
||||
IconButton(onClick = { searchActive = !searchActive; if (!searchActive) searchQuery = "" }) {
|
||||
Icon(if (searchActive) Icons.Default.Close else Icons.Default.Search, "Search")
|
||||
}
|
||||
IconButton(onClick = { showNewFolderDialog = true }) {
|
||||
Icon(Icons.Default.CreateNewFolder, "New Folder")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
|
||||
// ── Breadcrumbs ──────────────────────────────────────────────
|
||||
Surface(tonalElevation = 1.dp) {
|
||||
LazyRow(
|
||||
state = breadcrumbState,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
BreadcrumbChip(
|
||||
label = "⌂",
|
||||
isLast = segments.isEmpty(),
|
||||
onClick = { vm.navigateToBreadcrumb("/") },
|
||||
)
|
||||
}
|
||||
itemsIndexed(segments) { idx, seg ->
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val segPath = "/" + segments.take(idx + 1).joinToString("/")
|
||||
BreadcrumbChip(
|
||||
label = seg,
|
||||
isLast = idx == segments.lastIndex,
|
||||
onClick = { vm.navigateToBreadcrumb(segPath) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
state.error != null -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.ErrorOutline, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error)
|
||||
FilledTonalButton(onClick = { vm.retry() }) { Text("Retry") }
|
||||
// ── Content ──────────────────────────────────────────────────
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
state.error != null -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Default.CloudOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
FilledTonalButton(onClick = vm::retry) {
|
||||
Icon(Icons.Default.Refresh, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
|
||||
filtered.isEmpty() && searchQuery.isBlank() -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("This folder is empty", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("You can still select it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f))
|
||||
}
|
||||
|
||||
filtered.isEmpty() -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Default.SearchOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text("No results for \"$searchQuery\"", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(filtered, key = { it.path }) { entry ->
|
||||
FolderItem(
|
||||
file = entry,
|
||||
onClick = {
|
||||
if (entry.isDirectory) vm.navigateTo(entry.path)
|
||||
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
state.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.FolderOff, null, Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Empty folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = { onSelect(state.currentPath) }) { Text("Select this folder anyway") }
|
||||
}
|
||||
}
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.entries, key = { it.path }) { entry ->
|
||||
BrowserEntry(
|
||||
file = entry,
|
||||
onClick = {
|
||||
if (entry.isDirectory) vm.navigateTo(entry.path)
|
||||
else onSelect(entry.path.substringBeforeLast('/').ifBlank { "/" })
|
||||
},
|
||||
onSelectFolder = if (entry.isDirectory) ({ onSelect(entry.path) }) else null,
|
||||
}
|
||||
|
||||
// ── Select button ────────────────────────────────────────────
|
||||
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Button(
|
||||
onClick = { onSelect(state.currentPath) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Select \"$currentFolderName\"",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 56.dp))
|
||||
}
|
||||
Spacer(Modifier.height(bottomInset))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,44 +255,119 @@ fun RemoteBrowserDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrowserEntry(
|
||||
file: RemoteFile,
|
||||
onClick: () -> Unit,
|
||||
onSelectFolder: (() -> Unit)?,
|
||||
) {
|
||||
private fun BreadcrumbChip(label: String, isLast: Boolean, onClick: () -> Unit) {
|
||||
if (isLast) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FolderItem(file: RemoteFile, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = if (file.isDirectory) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
// Colored icon badge
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(46.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (file.isDirectory) Color(0xFF1B5E20).copy(alpha = 0.12f)
|
||||
else Color(0xFF0D47A1).copy(alpha = 0.10f)
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (file.isDirectory) Icons.Default.Folder else Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = if (file.isDirectory) Color(0xFF2E7D32) else Color(0xFF1565C0),
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
file.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = if (file.isDirectory) FontWeight.Medium else FontWeight.Normal,
|
||||
)
|
||||
if (!file.isDirectory) {
|
||||
Text(file.sizeBytes.formatBytes(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
file.sizeBytes.formatBytes(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onSelectFolder != null) {
|
||||
IconButton(onClick = onSelectFolder, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.CheckCircleOutline, "Select this folder", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
if (file.isDirectory) {
|
||||
Icon(Icons.Default.ChevronRight, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 76.dp), thickness = 0.5.dp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewFolderDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.CreateNewFolder, null) },
|
||||
title = { Text("New Folder") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Folder name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (name.isNotBlank()) onConfirm(name.trim()) }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun Long.formatBytes(): String = when {
|
||||
this < 1024 -> "${this}B"
|
||||
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)}KB"
|
||||
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)}MB"
|
||||
else -> "${"%.1f".format(this / 1_073_741_824.0)}GB"
|
||||
this < 1024 -> "${this} B"
|
||||
this < 1_048_576 -> "${"%.1f".format(this / 1024.0)} KB"
|
||||
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
|
||||
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,27 @@ class RemoteBrowserViewModel @Inject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
fun navigateToBreadcrumb(path: String) {
|
||||
val stack = _state.value.pathStack
|
||||
val idx = stack.lastIndexOf(path)
|
||||
val newStack = if (idx >= 0) stack.take(idx + 1) else listOf(path)
|
||||
loadJob?.cancel()
|
||||
_state.update { it.copy(currentPath = path, pathStack = newStack, isLoading = true, entries = emptyList(), error = null) }
|
||||
loadJob = loadPath(_state.value.accountId, path)
|
||||
}
|
||||
|
||||
fun createFolder(name: String) {
|
||||
val s = _state.value
|
||||
val newPath = if (s.currentPath.trimEnd('/') == "") "/$name" else "${s.currentPath.trimEnd('/')}/$name"
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount(s.accountId) ?: return@launch
|
||||
val provider = runCatching { providerFactory.create(account) }.getOrElse { return@launch }
|
||||
provider.createDirectory(newPath)
|
||||
.onSuccess { retry() }
|
||||
.onFailure { e -> _state.update { it.copy(error = "Could not create folder: ${e.message}") } }
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val s = _state.value
|
||||
if (s.accountId == -1L) return
|
||||
|
||||
@@ -40,6 +40,9 @@ class FilesViewModel @Inject constructor(
|
||||
val pairs: StateFlow<List<SyncPairEntity>> = syncPairDao.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val accounts = accountRepository.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedPairId = MutableStateFlow<Long?>(null)
|
||||
|
||||
val selectedPair: StateFlow<SyncPairEntity?> = combine(_selectedPairId, pairs) { id, list ->
|
||||
@@ -158,6 +161,31 @@ class FilesViewModel @Inject constructor(
|
||||
|
||||
fun fileKey(file: SyncFileStateEntity) = "${file.syncPairId}:${file.relativePath}"
|
||||
|
||||
fun openCloudFile(accountId: Long, remotePath: String) {
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount(accountId) ?: run {
|
||||
_fileAction.emit(FileAction.Error("Account not found"))
|
||||
return@launch
|
||||
}
|
||||
val provider = providerFactory.create(account)
|
||||
val fileName = remotePath.substringAfterLast('/')
|
||||
val cacheFile = File(context.cacheDir, "syncflow_open/$fileName")
|
||||
cacheFile.parentFile?.mkdirs()
|
||||
_isDownloading.value = true
|
||||
try {
|
||||
cacheFile.outputStream().use { out ->
|
||||
provider.downloadFile(remotePath, out) { }.getOrThrow()
|
||||
}
|
||||
_fileAction.emit(FileAction.Open(cacheFile))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Cloud open failed: $remotePath")
|
||||
_fileAction.emit(FileAction.Error("Cannot open: ${e.message}"))
|
||||
} finally {
|
||||
_isDownloading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download-then-open/share ──────────────────────────────────────────────
|
||||
|
||||
private fun downloadAndOpen(file: SyncFileStateEntity) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import com.syncflow.ui.shared.SyncProgress
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -38,6 +39,7 @@ fun HomeScreen(
|
||||
vm: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val pairs by vm.syncPairs.collectAsState()
|
||||
val progressMap by vm.syncProgressMap.collectAsState()
|
||||
|
||||
if (pairs.isEmpty()) {
|
||||
EmptyState(modifier = modifier.fillMaxSize(), onAdd = onAddPair)
|
||||
@@ -50,9 +52,11 @@ fun HomeScreen(
|
||||
items(pairs, key = { it.id }) { pair ->
|
||||
SyncPairCard(
|
||||
pair = pair,
|
||||
progress = progressMap[pair.id],
|
||||
onClick = { onPairClick(pair.id) },
|
||||
onSync = { vm.triggerSync(pair) },
|
||||
onToggle = { vm.toggleEnabled(pair) },
|
||||
onPause = { vm.pauseSync(pair) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
@@ -63,9 +67,11 @@ fun HomeScreen(
|
||||
@Composable
|
||||
private fun SyncPairCard(
|
||||
pair: SyncPairEntity,
|
||||
progress: SyncProgress? = null,
|
||||
onClick: () -> Unit,
|
||||
onSync: () -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
onPause: () -> Unit = {},
|
||||
) {
|
||||
val accentColor = pair.lastSyncResult.accentColor
|
||||
|
||||
@@ -170,13 +176,57 @@ private fun SyncPairCard(
|
||||
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing)),
|
||||
label = "cardRotation",
|
||||
)
|
||||
FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Sync, "Sync now",
|
||||
modifier = Modifier.size(18.dp).graphicsLayer {
|
||||
if (pair.lastSyncResult == SyncStatus.SYNCING) rotationZ = syncRotation
|
||||
},
|
||||
)
|
||||
when (pair.lastSyncResult) {
|
||||
SyncStatus.SYNCING -> FilledTonalIconButton(onClick = onPause, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Pause, "Pause sync", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
SyncStatus.PAUSED -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp), enabled = pair.isEnabled) {
|
||||
Icon(Icons.Default.PlayArrow, "Resume sync", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
else -> FilledTonalIconButton(onClick = onSync, modifier = Modifier.size(36.dp)) {
|
||||
Icon(Icons.Default.Sync, "Sync now", modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = syncRotation * 0f })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val displayProgress = when {
|
||||
pair.lastSyncResult == SyncStatus.SYNCING -> progress
|
||||
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
|
||||
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
|
||||
else -> null
|
||||
}
|
||||
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
|
||||
Text(
|
||||
"Starting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
} else if (displayProgress != null) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (displayProgress.uploaded > 0) {
|
||||
Icon(Icons.Default.ArrowUpward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text("${displayProgress.uploaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (displayProgress.downloaded > 0) {
|
||||
Icon(Icons.Default.ArrowDownward, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.secondary)
|
||||
Text("${displayProgress.downloaded}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
if (displayProgress.deleted > 0) {
|
||||
Icon(Icons.Default.DeleteOutline, null, Modifier.size(11.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Text("${displayProgress.deleted}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (displayProgress.bytesTransferred > 0) {
|
||||
Text(
|
||||
"· ${displayProgress.bytesTransferred.toDisplaySize()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +239,7 @@ private fun StatusPill(status: SyncStatus) {
|
||||
val (icon, label) = when (status) {
|
||||
SyncStatus.SUCCESS -> Pair(Icons.Default.CheckCircle, "Synced")
|
||||
SyncStatus.SYNCING -> Pair(Icons.Default.Sync, "Syncing…")
|
||||
SyncStatus.PAUSED -> Pair(Icons.Default.Pause, "Paused")
|
||||
SyncStatus.FAILED -> Pair(Icons.Default.Error, "Failed")
|
||||
SyncStatus.CONFLICT -> Pair(Icons.Default.Warning, "Conflict")
|
||||
SyncStatus.PARTIAL -> Pair(Icons.Default.WarningAmber, "Partial")
|
||||
@@ -245,13 +296,21 @@ private fun EmptyState(modifier: Modifier = Modifier, onAdd: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toDisplaySize(): String = when {
|
||||
this < 1_024 -> "$this B"
|
||||
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
|
||||
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
|
||||
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
|
||||
}
|
||||
|
||||
private val SyncStatus.accentColor: Color
|
||||
@Composable get() = when (this) {
|
||||
SyncStatus.SUCCESS -> Color(0xFF2E7D32) // green — done, healthy
|
||||
SyncStatus.SYNCING -> Color(0xFF1565C0) // blue — in progress
|
||||
SyncStatus.FAILED -> Color(0xFFC62828) // red — error
|
||||
SyncStatus.PARTIAL -> Color(0xFFE65100) // orange — some files failed
|
||||
SyncStatus.CONFLICT -> Color(0xFFF9A825) // amber — needs resolution
|
||||
SyncStatus.SUCCESS -> Color(0xFF2E7D32)
|
||||
SyncStatus.SYNCING -> Color(0xFF1565C0)
|
||||
SyncStatus.PAUSED -> Color(0xFF6A1B9A)
|
||||
SyncStatus.FAILED -> Color(0xFFC62828)
|
||||
SyncStatus.PARTIAL -> Color(0xFFE65100)
|
||||
SyncStatus.CONFLICT -> Color(0xFFF9A825)
|
||||
SyncStatus.IDLE -> MaterialTheme.colorScheme.outline
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,20 @@ import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.ScheduleType
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import com.syncflow.ui.shared.SyncProgress
|
||||
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.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -27,11 +32,33 @@ class HomeViewModel @Inject constructor(
|
||||
val syncPairs = syncPairDao.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val syncProgressMap: kotlinx.coroutines.flow.StateFlow<Map<Long, SyncProgress>> =
|
||||
workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.map { infos ->
|
||||
infos
|
||||
.mapNotNull { info ->
|
||||
val tag = info.tags.firstOrNull { it.startsWith("sync_") } ?: return@mapNotNull null
|
||||
val pairId = tag.removePrefix("sync_").toLongOrNull() ?: return@mapNotNull null
|
||||
val up = info.progress.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0)
|
||||
val down = info.progress.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0)
|
||||
val del = info.progress.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0)
|
||||
val bytes = info.progress.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L)
|
||||
if (up > 0 || down > 0 || del > 0) pairId to SyncProgress(up, down, del, bytes) else null
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
|
||||
|
||||
fun triggerSync(pair: SyncPairEntity) {
|
||||
val req = SyncWorker.buildOneTimeRequest(pair.id, wifiOnly = false, chargingOnly = false)
|
||||
workManager.enqueue(req)
|
||||
}
|
||||
|
||||
fun pauseSync(pair: SyncPairEntity) {
|
||||
workManager.cancelAllWorkByTag("sync_${pair.id}")
|
||||
viewModelScope.launch { syncPairDao.updateStatus(pair.id, SyncStatus.PAUSED) }
|
||||
}
|
||||
|
||||
fun toggleEnabled(pair: SyncPairEntity) {
|
||||
viewModelScope.launch {
|
||||
val nowEnabled = !pair.isEnabled
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.syncflow.data.db.entities.SyncPairEntity
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import com.syncflow.ui.shared.SyncProgress
|
||||
import com.syncflow.ui.shared.SyncEventRow
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -40,6 +41,7 @@ fun PairDetailScreen(
|
||||
val pair by vm.pair.collectAsState()
|
||||
val events by vm.events.collectAsState()
|
||||
val conflictCount by vm.unresolvedConflicts.collectAsState()
|
||||
val syncProgress by vm.syncProgress.collectAsState()
|
||||
var showDelete by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDelete) {
|
||||
@@ -66,7 +68,17 @@ fun PairDetailScreen(
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
||||
actions = {
|
||||
IconButton(onClick = { pair?.let { onEdit(it.id) } }) { Icon(Icons.Default.Edit, "Edit") }
|
||||
IconButton(onClick = { vm.syncNow() }) { Icon(Icons.Default.Sync, "Sync now") }
|
||||
when (pair?.lastSyncResult) {
|
||||
SyncStatus.SYNCING -> IconButton(onClick = { vm.pauseSync() }) {
|
||||
Icon(Icons.Default.Pause, "Pause sync")
|
||||
}
|
||||
SyncStatus.PAUSED -> IconButton(onClick = { vm.syncNow() }, enabled = pair?.isEnabled == true) {
|
||||
Icon(Icons.Default.PlayArrow, "Resume sync")
|
||||
}
|
||||
else -> IconButton(onClick = { vm.syncNow() }) {
|
||||
Icon(Icons.Default.Sync, "Sync now")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { showDelete = true }) { Icon(Icons.Default.Delete, "Delete") }
|
||||
},
|
||||
)
|
||||
@@ -78,7 +90,7 @@ fun PairDetailScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
pair?.let { p -> StatusBanner(p) }
|
||||
pair?.let { p -> StatusBanner(p, syncProgress) }
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -138,10 +150,11 @@ fun PairDetailScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBanner(pair: SyncPairEntity) {
|
||||
private fun StatusBanner(pair: SyncPairEntity, progress: SyncProgress? = null) {
|
||||
val (icon, label, containerColor) = when (pair.lastSyncResult) {
|
||||
SyncStatus.SUCCESS -> Triple(Icons.Default.CheckCircle, "Synced", MaterialTheme.colorScheme.primaryContainer)
|
||||
SyncStatus.SYNCING -> Triple(Icons.Default.Sync, "Syncing…", MaterialTheme.colorScheme.secondaryContainer)
|
||||
SyncStatus.PAUSED -> Triple(Icons.Default.Pause, "Paused — tap ▶ to resume", MaterialTheme.colorScheme.surfaceVariant)
|
||||
SyncStatus.FAILED -> Triple(Icons.Default.Error, "Failed", MaterialTheme.colorScheme.errorContainer)
|
||||
SyncStatus.CONFLICT -> Triple(Icons.Default.Warning, "Conflict", MaterialTheme.colorScheme.tertiaryContainer)
|
||||
SyncStatus.PARTIAL -> Triple(Icons.Default.WarningAmber,"Partial", MaterialTheme.colorScheme.tertiaryContainer)
|
||||
@@ -170,8 +183,39 @@ private fun StatusBanner(pair: SyncPairEntity) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
pair.lastSyncAt?.let {
|
||||
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
|
||||
val displayProgress = when {
|
||||
pair.lastSyncResult == SyncStatus.SYNCING -> progress
|
||||
pair.lastSyncUploaded > 0 || pair.lastSyncDownloaded > 0 || pair.lastSyncDeleted > 0 ->
|
||||
SyncProgress(pair.lastSyncUploaded, pair.lastSyncDownloaded, pair.lastSyncDeleted, pair.lastSyncBytesTransferred)
|
||||
else -> null
|
||||
}
|
||||
if (pair.lastSyncResult == SyncStatus.SYNCING && displayProgress == null) {
|
||||
Text("Starting…", style = MaterialTheme.typography.bodySmall)
|
||||
} else if (displayProgress != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (displayProgress.uploaded > 0) {
|
||||
Icon(Icons.Default.ArrowUpward, null, Modifier.size(12.dp))
|
||||
Text("${displayProgress.uploaded} up", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (displayProgress.downloaded > 0) {
|
||||
Icon(Icons.Default.ArrowDownward, null, Modifier.size(12.dp))
|
||||
Text("${displayProgress.downloaded} down", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (displayProgress.deleted > 0) {
|
||||
Icon(Icons.Default.DeleteOutline, null, Modifier.size(12.dp))
|
||||
Text("${displayProgress.deleted} del", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (displayProgress.bytesTransferred > 0) {
|
||||
Text("· ${displayProgress.bytesTransferred.toDisplaySize()}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pair.lastSyncAt?.let {
|
||||
Text(it.toRelativeString(), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +273,13 @@ private fun InfoRow(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Long.toDisplaySize(): String = when {
|
||||
this < 1_024 -> "$this B"
|
||||
this < 1_048_576 -> "${"%.1f".format(this / 1_024.0)} KB"
|
||||
this < 1_073_741_824 -> "${"%.1f".format(this / 1_048_576.0)} MB"
|
||||
else -> "${"%.1f".format(this / 1_073_741_824.0)} GB"
|
||||
}
|
||||
|
||||
private fun String.toDisplayPath(): String {
|
||||
val decoded = java.net.URLDecoder.decode(this, "UTF-8")
|
||||
return decoded.trimEnd('/').substringAfterLast('/').substringAfterLast(':').ifEmpty { decoded }
|
||||
|
||||
@@ -3,13 +3,17 @@ package com.syncflow.ui.pairdetail
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.syncflow.data.db.SyncConflictDao
|
||||
import com.syncflow.data.db.SyncEventDao
|
||||
import com.syncflow.data.db.SyncPairDao
|
||||
import com.syncflow.domain.model.SyncStatus
|
||||
import com.syncflow.worker.SyncWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import com.syncflow.ui.shared.SyncProgress
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -34,11 +38,30 @@ class PairDetailViewModel @Inject constructor(
|
||||
val unresolvedConflicts = conflictDao.observeUnresolvedCount(pairId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
||||
|
||||
val syncProgress = workManager.getWorkInfosByTagFlow("sync_$pairId")
|
||||
.map { infos ->
|
||||
infos.firstOrNull { it.state == WorkInfo.State.RUNNING }?.progress?.let { data ->
|
||||
SyncProgress(
|
||||
uploaded = data.getInt(SyncWorker.KEY_PROGRESS_UPLOADED, 0),
|
||||
downloaded = data.getInt(SyncWorker.KEY_PROGRESS_DOWNLOADED, 0),
|
||||
deleted = data.getInt(SyncWorker.KEY_PROGRESS_DELETED, 0),
|
||||
bytesTransferred = data.getLong(SyncWorker.KEY_PROGRESS_BYTES, 0L),
|
||||
).takeIf { it.uploaded > 0 || it.downloaded > 0 || it.deleted > 0 }
|
||||
}
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
fun syncNow() {
|
||||
val p = pair.value ?: return
|
||||
workManager.enqueue(SyncWorker.buildOneTimeRequest(p.id, wifiOnly = false, chargingOnly = false))
|
||||
}
|
||||
|
||||
fun pauseSync() {
|
||||
val p = pair.value ?: return
|
||||
workManager.cancelAllWorkByTag("sync_${p.id}")
|
||||
viewModelScope.launch { syncPairDao.updateStatus(p.id, SyncStatus.PAUSED) }
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
viewModelScope.launch {
|
||||
pair.value?.let { syncPairDao.delete(it) }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.syncflow.ui.shared
|
||||
|
||||
data class SyncProgress(val uploaded: Int, val downloaded: Int, val deleted: Int, val bytesTransferred: Long)
|
||||
@@ -47,7 +47,14 @@ class SyncWorker @AssistedInject constructor(
|
||||
return try {
|
||||
val domainPair = pair.toDomain()
|
||||
val provider = providerFactory.create(account)
|
||||
val result = syncEngine.sync(domainPair, provider)
|
||||
val result = syncEngine.sync(domainPair, provider) { up, down, del, bytes ->
|
||||
setProgress(workDataOf(
|
||||
KEY_PROGRESS_UPLOADED to up,
|
||||
KEY_PROGRESS_DOWNLOADED to down,
|
||||
KEY_PROGRESS_DELETED to del,
|
||||
KEY_PROGRESS_BYTES to bytes,
|
||||
))
|
||||
}
|
||||
|
||||
val lines = buildList {
|
||||
if (result.uploaded > 0) add("↑${result.uploaded}")
|
||||
@@ -158,6 +165,10 @@ class SyncWorker @AssistedInject constructor(
|
||||
const val KEY_PAIR_ID = "pair_id"
|
||||
const val KEY_SILENT = "silent"
|
||||
const val KEY_RESULT_SUMMARY = "result_summary"
|
||||
const val KEY_PROGRESS_UPLOADED = "prog_up"
|
||||
const val KEY_PROGRESS_DOWNLOADED = "prog_down"
|
||||
const val KEY_PROGRESS_DELETED = "prog_del"
|
||||
const val KEY_PROGRESS_BYTES = "prog_bytes"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val RESULT_ID_OFFSET = 2000
|
||||
private const val CHANNEL_PROGRESS = "sync_progress"
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 389 KiB After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,36 @@
|
||||
package com.syncflow.domain.sync
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Path-traversal guard: a hostile/compromised remote must not be able to make the engine read
|
||||
* or write outside the sync root via "..", absolute, or separator-smuggled paths.
|
||||
*/
|
||||
class PathSafetyTest {
|
||||
|
||||
@Test fun `normal relative paths are allowed`() {
|
||||
assertFalse(isUnsafeSyncPath("photo.jpg"))
|
||||
assertFalse(isUnsafeSyncPath("sub/dir/photo.jpg"))
|
||||
assertFalse(isUnsafeSyncPath("a.b..c/file.txt")) // ".." only inside a name, not a segment
|
||||
}
|
||||
|
||||
@Test fun `parent-dir traversal is rejected`() {
|
||||
assertTrue(isUnsafeSyncPath(".."))
|
||||
assertTrue(isUnsafeSyncPath("../evil"))
|
||||
assertTrue(isUnsafeSyncPath("a/../../etc/passwd"))
|
||||
assertTrue(isUnsafeSyncPath("sub/../../escape"))
|
||||
}
|
||||
|
||||
@Test fun `backslash traversal is rejected`() {
|
||||
assertTrue(isUnsafeSyncPath("..\\evil"))
|
||||
assertTrue(isUnsafeSyncPath("a\\..\\..\\escape"))
|
||||
}
|
||||
|
||||
@Test fun `absolute and empty paths are rejected`() {
|
||||
assertTrue(isUnsafeSyncPath("/etc/passwd"))
|
||||
assertTrue(isUnsafeSyncPath(""))
|
||||
assertTrue(isUnsafeSyncPath(" "))
|
||||
}
|
||||
}
|
||||
@@ -91,10 +91,19 @@ class SyncDecideTest {
|
||||
decide(local(ts), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
|
||||
}
|
||||
|
||||
@Test fun `1ms difference detected as local change`() {
|
||||
@Test fun `sub-second mtime difference treated as unchanged`() {
|
||||
// Second-precision comparison is intentional: FAT32 has 2s mtime resolution and WebDAV
|
||||
// 1s, so sub-second deltas are phantom changes that caused rewrite loops. A 1ms diff
|
||||
// within the same second must NOT be treated as a change.
|
||||
val ts = 1_716_393_136_789L
|
||||
assertEquals(SyncDecision.SKIP,
|
||||
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
|
||||
}
|
||||
|
||||
@Test fun `mtime change of a full second detected as local change`() {
|
||||
val ts = 1_716_393_136_789L
|
||||
assertEquals(SyncDecision.UPLOAD,
|
||||
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
|
||||
decide(local(ts + 1000), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
|
||||
}
|
||||
|
||||
@Test fun `epoch-second stored value differs from millis comparison`() {
|
||||
@@ -127,12 +136,14 @@ class SyncDecideTest {
|
||||
assertEquals(SyncDecision.SKIP,
|
||||
decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY))
|
||||
|
||||
// ── local deleted, no state record (uploaded in broken version) ──────────
|
||||
// ── remote exists, no state record: never delete on ambiguity ────────────
|
||||
|
||||
@Test fun `local deleted no known state but pair has prior history deletes remote`() =
|
||||
// hasPriorState=true means the pair has been synced before; file has no state
|
||||
// because it was uploaded when getFileMetadata was broken. Should still mirror deletion.
|
||||
assertEquals(SyncDecision.DELETE_REMOTE,
|
||||
@Test fun `remote exists with no state record downloads rather than deleting`() =
|
||||
// known=null can mean a brand-new remote file OR one whose state was lost. The engine
|
||||
// cannot tell them apart, so it downloads rather than risk deleting a real file —
|
||||
// worst case is a re-downloaded file, never a lost one. A file the user genuinely
|
||||
// deleted locally still has its state record, which routes to DELETE_REMOTE.
|
||||
assertEquals(SyncDecision.DOWNLOAD,
|
||||
decide(null, remote(), known = null, delete = DeleteBehavior.MIRROR, hasPriorState = true))
|
||||
|
||||
@Test fun `initial sync remote only no prior state downloads`() =
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.syncflow.domain.sync
|
||||
|
||||
import com.syncflow.data.db.entities.SyncFileStateEntity
|
||||
import com.syncflow.domain.model.ConflictStrategy
|
||||
import com.syncflow.domain.model.DeleteBehavior
|
||||
import com.syncflow.domain.model.RemoteFile
|
||||
import com.syncflow.domain.model.SyncDirection
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* End-to-end decision lifecycle for the backup scenario:
|
||||
*
|
||||
* "I back up my phone to the cloud, then I delete the file on the phone.
|
||||
* It must stay in the cloud."
|
||||
*
|
||||
* These walk the exact multi-cycle state the SyncEngine produces:
|
||||
* - a successful UPLOAD saves state with the local mtime known and remote metadata null
|
||||
* (SyncEngine.buildState(..., remoteAfterTransfer = null)),
|
||||
* - the next sync sees both sides present and unchanged, returns SKIP, and the SKIP branch
|
||||
* reconciles the record by filling in the remote metadata,
|
||||
* - then the local file is deleted and we assert what happens to the cloud copy.
|
||||
*
|
||||
* The decision is driven entirely by deleteBehavior, so each terminal case is asserted for
|
||||
* KEEP, MIRROR, and ARCHIVE.
|
||||
*/
|
||||
class UploadBackupLifecycleTest {
|
||||
|
||||
private val T0 = 1_716_393_136_000L // exact second boundary
|
||||
|
||||
private fun local(ms: Long = T0, size: Long = 100L) =
|
||||
LocalFileInfo("photo.jpg", size, ms)
|
||||
|
||||
private fun remote(ms: Long = T0, etag: String? = "etag1", size: Long = 100L) =
|
||||
RemoteFile("backup/photo.jpg", "photo.jpg", false, size, Instant.ofEpochMilli(ms), etag, null)
|
||||
|
||||
/** Mirrors SyncEngine.buildState right after a successful UPLOAD: remote metadata still null. */
|
||||
private fun stateAfterUpload(ms: Long = T0) = SyncFileStateEntity(
|
||||
syncPairId = 1L, relativePath = "photo.jpg",
|
||||
localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null,
|
||||
remoteModifiedAt = null, remoteSizeBytes = 0L, remoteEtag = null,
|
||||
lastSyncedAt = Instant.now(), syncedHash = null,
|
||||
)
|
||||
|
||||
/** Mirrors the record after the next sync's SKIP reconciliation fills in remote metadata. */
|
||||
private fun stateReconciled(ms: Long = T0, etag: String? = "etag1") = SyncFileStateEntity(
|
||||
syncPairId = 1L, relativePath = "photo.jpg",
|
||||
localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null,
|
||||
remoteModifiedAt = Instant.ofEpochMilli(ms), remoteSizeBytes = 100L, remoteEtag = etag,
|
||||
lastSyncedAt = Instant.now(), syncedHash = null,
|
||||
)
|
||||
|
||||
private fun decide(
|
||||
local: LocalFileInfo?,
|
||||
remote: RemoteFile?,
|
||||
known: SyncFileStateEntity?,
|
||||
delete: DeleteBehavior,
|
||||
) = syncDecide(
|
||||
SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, delete,
|
||||
local, remote, known, hasPriorSyncState = known != null,
|
||||
)
|
||||
|
||||
// ── Cycle 1: first backup uploads the file ───────────────────────────────
|
||||
|
||||
@Test fun `cycle 1 - first backup uploads regardless of delete behavior`() {
|
||||
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.KEEP))
|
||||
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.MIRROR))
|
||||
assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.ARCHIVE))
|
||||
}
|
||||
|
||||
// ── Cycle 2: file present on both sides, unchanged -> SKIP (no deletion) ──
|
||||
|
||||
@Test fun `cycle 2 - unchanged file skips right after upload (remote metadata still null)`() {
|
||||
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.KEEP))
|
||||
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.MIRROR))
|
||||
}
|
||||
|
||||
@Test fun `cycle 2 - unchanged file skips once state is reconciled`() {
|
||||
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.KEEP))
|
||||
assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.MIRROR))
|
||||
}
|
||||
|
||||
// ── Cycle 3: deleted on the phone — THE scenario ─────────────────────────
|
||||
|
||||
@Test fun `KEEP - deleting on phone leaves the cloud copy (correct backup behavior)`() {
|
||||
// Reconciled steady state:
|
||||
assertEquals(SyncDecision.SKIP, decide(null, remote(), stateReconciled(), DeleteBehavior.KEEP))
|
||||
// And even if deletion happens before the reconcile pass ran:
|
||||
assertEquals(SyncDecision.SKIP, decide(null, remote(), stateAfterUpload(), DeleteBehavior.KEEP))
|
||||
}
|
||||
|
||||
@Test fun `MIRROR - deleting on phone DELETES the cloud copy (wrong for a backup)`() {
|
||||
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.MIRROR))
|
||||
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateAfterUpload(), DeleteBehavior.MIRROR))
|
||||
}
|
||||
|
||||
@Test fun `ARCHIVE - deleting on phone moves the cloud copy to _Deleted (preserved)`() {
|
||||
// syncDecide returns DELETE_REMOTE; the engine's DELETE_REMOTE branch MOVEs the file to
|
||||
// <remote>/_Deleted/ instead of removing it when deleteBehavior == ARCHIVE.
|
||||
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.ARCHIVE))
|
||||
}
|
||||
|
||||
// ── After deletion: a brand-new remote file is NOT pulled down (upload-only) ─
|
||||
|
||||
@Test fun `KEEP - a new remote file never comes down to the phone (upload-only)`() {
|
||||
// Remote-only, no state record: in upload-only this must SKIP, not DOWNLOAD.
|
||||
assertEquals(SyncDecision.SKIP, decide(null, remote(), null, DeleteBehavior.KEEP))
|
||||
}
|
||||
|
||||
// ── Re-adding / changing a file after a KEEP deletion still uploads ───────
|
||||
|
||||
@Test fun `KEEP - modifying the file locally still uploads the change`() {
|
||||
val newer = local(T0 + 5_000)
|
||||
assertEquals(SyncDecision.UPLOAD, decide(newer, remote(), stateReconciled(), DeleteBehavior.KEEP))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.syncflow.ui.addpair
|
||||
|
||||
import com.syncflow.domain.model.DeleteBehavior
|
||||
import com.syncflow.domain.model.SyncDirection
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Add-Pair screen's default deletion behaviour must never wipe a backup. One-way directions
|
||||
* default to KEEP so deleting a file on the phone leaves the cloud copy intact; two-way defaults
|
||||
* to MIRROR. (The user can still override to any of the three options.)
|
||||
*/
|
||||
class RecommendedDeleteBehaviorTest {
|
||||
|
||||
@Test fun `upload-only defaults to KEEP so backups are never deleted`() =
|
||||
assertEquals(DeleteBehavior.KEEP, recommendedDeleteBehavior(SyncDirection.UPLOAD_ONLY))
|
||||
|
||||
@Test fun `download-only defaults to KEEP`() =
|
||||
assertEquals(DeleteBehavior.KEEP, recommendedDeleteBehavior(SyncDirection.DOWNLOAD_ONLY))
|
||||
|
||||
@Test fun `two-way defaults to MIRROR`() =
|
||||
assertEquals(DeleteBehavior.MIRROR, recommendedDeleteBehavior(SyncDirection.TWO_WAY))
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME=1.0.40
|
||||
VERSION_CODE=41
|
||||
VERSION_NAME=1.0.73
|
||||
VERSION_CODE=73
|
||||
|
||||