Compare commits

...

5 Commits

Author SHA1 Message Date
amir cb9fa1d3db v1.0.58: Files tab → dual-mode file explorer (Phone + Cloud)
Replace the synced-files list with a proper file explorer:
- Phone tab: browse all of internal storage with quick-access shortcuts
  (Camera, Downloads, Documents, Pictures, Music, Videos), breadcrumb
  navigation, search, tap folder to enter, tap file to open/share
- Cloud tab: browse connected cloud accounts, account switcher chips for
  multiple accounts, breadcrumb navigation, search, tap file to
  download+open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:46:35 +00:00
amir e59564ac07 v1.0.57: restore custom browser as primary local folder picker
Tap on local folder opens the custom browser again (not system picker).
The custom browser already shows the All files access banner if needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:24:35 +00:00
amir 0ba4fd7eb9 v1.0.56: allow root folder selection + MANAGE_EXTERNAL_STORAGE prompt
Remove root folder block from the browser — user can now select
/storage/emulated/0 exactly like Autosync. If MANAGE_EXTERNAL_STORAGE
is not granted a red banner appears with a direct "Grant" button that
opens the Android All files access settings screen. Root guard removed
from SyncEngine; individual file failures (e.g. root-level writes) are
already caught and logged per-file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:21:35 +00:00
amir 69d4257a18 v1.0.55: SAF system folder picker (same as Autosync)
Tapping the local folder field now opens Android's native folder picker
via ACTION_OPEN_DOCUMENT_TREE. The picked content:// URI gets persistent
read/write permission and is stored as-is; the existing Saf backend
handles all sync I/O through it. "Browse manually" link kept for the
raw-path custom browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:12:42 +00:00
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
7 changed files with 646 additions and 431 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,16 +34,6 @@ class SyncEngine @Inject constructor(
@ApplicationContext private val context: Context,
) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult {
if (!pair.localPath.startsWith("content://")) {
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. Edit the pair and 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)
@@ -166,10 +156,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 -> {
@@ -1,5 +1,8 @@
package com.syncflow.ui.addpair
import android.content.Intent
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.layout.*
@@ -12,6 +15,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -28,9 +32,20 @@ fun AddPairScreen(onDone: () -> Unit, vm: AddPairViewModel = hiltViewModel()) {
val s by vm.state.collectAsState()
LaunchedEffect(s.done) { if (s.done) onDone() }
val context = LocalContext.current
var showRemoteBrowser by remember { mutableStateOf(false) }
var showLocalBrowser by remember { mutableStateOf(false) }
val safLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
vm.update { copy(localPath = uri.toString()) }
}
}
if (showLocalBrowser) {
LocalBrowserDialog(
initialPath = s.localPath.ifBlank { "" },
@@ -34,6 +34,12 @@ 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
@@ -131,6 +137,10 @@ fun LocalBrowserDialog(
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,
@@ -192,6 +202,35 @@ fun LocalBrowserDialog(
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(
@@ -263,23 +302,13 @@ fun LocalBrowserDialog(
}
// ── Select button ────────────────────────────────────────────
val isStorageRoot = currentPath.absolutePath == STORAGE_ROOT.absolutePath
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column {
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)
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp)
.height(52.dp),
shape = RoundedCornerShape(14.dp),
) {
File diff suppressed because it is too large Load Diff
@@ -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) {
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.53
VERSION_CODE=54
VERSION_NAME=1.0.58
VERSION_CODE=59