Compare commits

...

2 Commits

Author SHA1 Message Date
amir 683169e8b7 v1.0.54: ARCHIVE delete behavior + storage root upload-only allowance
New delete behavior option: "Archive deleted" — when a file is deleted
from the phone in a TWO_WAY pair, it moves to _Deleted/<path> on remote
instead of being permanently deleted from the backup.

Also allows storage root (/storage/emulated/0) for UPLOAD_ONLY pairs so
whole-phone backup syncs work; only blocks root when sync direction would
write files locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:06:28 +00:00
amir 77d56ee6be v1.0.53: block storage-root sync paths
Android 11+ denies writes to /storage/emulated/0 directly. SyncEngine
now catches this early and returns FAILED with an actionable message
instead of silently logging PARTIAL. LocalBrowserDialog disables the
Select button at the storage root with an inline warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:56:45 +00:00
5 changed files with 52 additions and 20 deletions
@@ -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) {
@@ -34,6 +34,17 @@ class SyncEngine @Inject constructor(
@ApplicationContext private val context: Context,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
if (!pair.localPath.startsWith("content://") &&
pair.syncDirection != SyncDirection.UPLOAD_ONLY) {
val canonical = runCatching { File(pair.localPath).canonicalPath }.getOrElse { pair.localPath }
if (canonical == "/storage/emulated/0") {
val msg = "Local folder is the storage root — Android blocks writes here. Use Upload Only direction, or select a subfolder."
syncPairDao.updateSyncResult(pair.id, Instant.now(), SyncStatus.FAILED, 0)
logEvent(pair.id, SyncEventType.SYNC_FAILED, null, msg, 0)
return SyncResult(failedFiles = 1, error = Exception(msg))
}
}
syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
@@ -156,10 +167,20 @@ class SyncEngine @Inject constructor(
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 {
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)
}
FileOutcome(deleted = 1)
}
SyncDecision.CONFLICT -> {
@@ -263,22 +263,32 @@ fun LocalBrowserDialog(
}
// ── Select button ────────────────────────────────────────────
val isStorageRoot = currentPath.absolutePath == STORAGE_ROOT.absolutePath
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))
if (isStorageRoot) {
Text(
"Android blocks writes to the storage root — please open a subfolder first",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
Button(
onClick = { onSelect(currentPath.absolutePath) },
enabled = !isStorageRoot,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 4.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))
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.52
VERSION_CODE=53
VERSION_NAME=1.0.54
VERSION_CODE=55