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>
This commit is contained in:
2026-05-26 01:21:35 +00:00
parent 69d4257a18
commit 0ba4fd7eb9
3 changed files with 42 additions and 24 deletions
@@ -34,17 +34,6 @@ class SyncEngine @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
suspend fun sync(pair: SyncPair, provider: CloudProvider): SyncResult { 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) syncPairDao.updateStatus(pair.id, SyncStatus.SYNCING)
logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0) logEvent(pair.id, SyncEventType.SYNC_STARTED, null, null, 0)
@@ -34,6 +34,12 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -131,6 +137,10 @@ fun LocalBrowserDialog(
else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) } else entries.filter { it.file.name.contains(searchQuery, ignoreCase = true) }
val currentFolderName = currentPath.name.ifBlank { "Internal Storage" } 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( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -192,6 +202,35 @@ fun LocalBrowserDialog(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), 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 ────────────────────────────────────────────── // ── Breadcrumbs ──────────────────────────────────────────────
Surface(tonalElevation = 1.dp) { Surface(tonalElevation = 1.dp) {
LazyRow( LazyRow(
@@ -263,23 +302,13 @@ fun LocalBrowserDialog(
} }
// ── Select button ──────────────────────────────────────────── // ── Select button ────────────────────────────────────────────
val isStorageRoot = currentPath.absolutePath == STORAGE_ROOT.absolutePath
Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) { Surface(tonalElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column { 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( Button(
onClick = { onSelect(currentPath.absolutePath) }, onClick = { onSelect(currentPath.absolutePath) },
enabled = !isStorageRoot,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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), .height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
) { ) {
+2 -2
View File
@@ -1,2 +1,2 @@
VERSION_NAME=1.0.55 VERSION_NAME=1.0.56
VERSION_CODE=56 VERSION_CODE=57