0ba4fd7eb9
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>
392 lines
20 KiB
Kotlin
392 lines
20 KiB
Kotlin
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)
|
|
}
|