refactor(files): split FileManagerScreen helpers, items and dialog

This commit is contained in:
xXJsonDeruloXx committed 2026-09-27 11:39:57 -04:00
1 parent 7997696706
commit 63c58e94ce
5 files changed
+995 -889

No files matched your search

@@ -0,0 +1,93 @@
package com.droiddeck.launcher.files
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import java.io.File
// Reading and setting a file's read-only and hidden attributes (the DOS attribute xattr).
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
internal data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
internal fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
internal fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
internal fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
@@ -0,0 +1,599 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's rows, tiles, favourites and context menu.
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
internal fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
@@ -0,0 +1,101 @@
package com.droiddeck.launcher.files
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Image
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.style.TextOverflow
import java.io.File
// The File Manager's non-UI helpers: sorting, paths, conflict choices and favourite locations.
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
internal fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
internal fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
internal val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
internal fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
@@ -7,12 +7,7 @@ import androidx.compose.foundation.Image
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -21,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -33,41 +27,25 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -95,217 +73,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.compose.AsyncImage
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/*
* Bannerlator's File Manager (ui/screens/FileManagerScreen.kt), carried over whole: the same
* browsing, grid and list, sorting, search, multi-select, copy/cut/paste with conflicts and
* progress, rename, delete, new folder, properties, favourites and the locations rail, and the
* same pick modes. What this app has no use for is not here: running a file in a Wine container,
* adding it to a container's shortcuts, the Drive C: / Drive Z: locations and the archive
* unpacker that installs into a prefix. Everything else is line for line the same so a fix in
* either app carries to the other.
*/
/**
* What to do when a pasted item already exists at the destination.
*
* OVERWRITE and MERGE resolve to the same call - `copyWithProgress` recurses into an existing
* directory and truncates existing files - but they mean different things to the user, so both are
* offered and the wording is chosen per item type (files overwrite, folders merge).
*/
enum class ConflictChoice { OVERWRITE, MERGE, KEEP_BOTH, SKIP }
/**
* Shortens a path from the LEFT, keeping whole segments.
*
* Compose's TextOverflow can only ellipsise the tail, which for a path throws away the part that
* matters - `/storage/emulated/0/Games/Racing/Dir…` tells you nothing about where you are.
*/
private fun elidePathStart(path: String, max: Int): String {
if (path.length <= max) return path
val parts = path.split('/').filter { it.isNotEmpty() }
val out = StringBuilder()
for (part in parts.asReversed()) {
if (out.length + part.length + 1 > max - 2) break
out.insert(0, "/$part")
}
return if (out.isEmpty()) "…" + path.takeLast(max - 1) else "…$out"
}
/** Folder-first ordering stays fixed across both sort directions. */
private fun comparatorFor(sortBy: String, desc: Boolean): Comparator<File> {
val inner: Comparator<File> = when (sortBy) {
"date" -> compareBy { it.lastModified() }
// Directory length() is meaningless, so folders sort by name within the size ordering
// instead of pretending to have one.
"size" -> compareBy { if (it.isDirectory) -1L else it.length() }
else -> compareBy { it.name.lowercase() }
}
val directed = if (desc) inner.reversed() else inner
return compareBy<File> { if (it.isDirectory) 0 else 1 }.then(directed)
}
// Image extensions that get a real thumbnail (via Coil) instead of the generic file icon.
private val IMAGE_THUMB_EXTS = setOf("jpg", "jpeg", "png", "webp", "bmp", "gif")
// True when [child] is [ancestor] itself or lives anywhere inside it.
private fun isWithin(child: File, ancestor: File): Boolean {
val c = runCatching { child.canonicalPath }.getOrDefault(child.absolutePath)
val a = runCatching { ancestor.canonicalPath }.getOrDefault(ancestor.absolutePath)
return c == a || c.startsWith(a + File.separator)
}
// ── DOS file attributes (Read-only / Hidden) ──
// The in-container file manager (wfm.exe) exposes these in its Properties dialog; this mirrors the
// same two toggles for files browsed from the app side. No root - we only touch permission bits the
// app owns (container files) and the Wine DOS-attribute xattr.
// FILE_ATTRIBUTE_HIDDEN, as Wine encodes it in the user.DOSATTRIB extended attribute.
private const val FILE_ATTRIBUTE_HIDDEN = 0x2
private const val DOSATTRIB_XATTR = "user.DOSATTRIB"
// Snapshot of a file's two toggleable attributes. hiddenSupported is false when the underlying
// filesystem can't store the DOSATTRIB xattr (the FUSE /storage volumes) - the Hidden toggle is then
// disabled while Read-only keeps working.
private data class FileAttrState(
val readOnly: Boolean,
val hidden: Boolean,
val hiddenSupported: Boolean,
)
// Wine (and Samba) store user.DOSATTRIB as an ASCII hex string ("0x22"), sometimes with trailing
// Samba fields after a separator. We only need the leading DOS-attribute hex, so read from the "0x"
// prefix and stop at the first non-hex character.
private fun parseDosAttrib(raw: ByteArray): Int {
val s = String(raw, Charsets.US_ASCII).trim()
val hex = if (s.startsWith("0x") || s.startsWith("0X")) s.substring(2) else s
val digits = hex.takeWhile { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
return digits.toIntOrNull(16) ?: 0
}
// Read the current read-only + Wine-hidden state. Never throws: an xattr-unsupported filesystem
// comes back hiddenSupported=false (Hidden toggle disabled), Read-only always resolves.
private fun readFileAttrs(file: File): FileAttrState {
val path = file.absolutePath
// Read-only reflects the OWNER write bit - that's what Wine maps to FILE_ATTRIBUTE_READONLY.
val readOnly = runCatching {
(android.system.Os.stat(path).st_mode and android.system.OsConstants.S_IWUSR) == 0
}.getOrDefault(!file.canWrite())
var hidden = false
var supported = true
try {
hidden = (parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR)) and FILE_ATTRIBUTE_HIDDEN) != 0
} catch (e: android.system.ErrnoException) {
// ENODATA: xattr namespace works, the attribute just isn't set yet -> still toggleable.
// ENOTSUP/EOPNOTSUPP/anything else: the fs can't store it -> disable the Hidden toggle.
supported = e.errno == android.system.OsConstants.ENODATA
} catch (e: Exception) {
supported = false
}
return FileAttrState(readOnly, hidden, supported)
}
// Toggle read-only by flipping the write bits, preserving every other permission bit. Setting
// read-only clears owner/group/other write (so Wine sees FILE_ATTRIBUTE_READONLY regardless of which
// write bit it checks); clearing it restores owner write. Falls back to File.setWritable if chmod is
// somehow refused. Returns true on success.
private fun setReadOnly(file: File, readOnly: Boolean): Boolean = runCatching {
val path = file.absolutePath
val mode = android.system.Os.stat(path).st_mode
val writeBits = android.system.OsConstants.S_IWUSR or
android.system.OsConstants.S_IWGRP or android.system.OsConstants.S_IWOTH
val newMode = if (readOnly) mode and writeBits.inv()
else mode or android.system.OsConstants.S_IWUSR
android.system.Os.chmod(path, newMode)
true
}.getOrElse { file.setWritable(!readOnly, true) }
// Flip Wine's DOS hidden bit in user.DOSATTRIB, preserving the other DOS-attribute bits
// (archive/system/read-only) already encoded there, and synthesizing a minimal value when absent.
// Writes Wine's own "0x%x" format, which Wine reads back natively. Returns false when the fs can't
// store the xattr, so the caller can disable just the Hidden toggle.
private fun setHidden(file: File, hidden: Boolean): Boolean = try {
val path = file.absolutePath
var attr = try {
parseDosAttrib(android.system.Os.getxattr(path, DOSATTRIB_XATTR))
} catch (e: android.system.ErrnoException) {
if (e.errno == android.system.OsConstants.ENODATA) 0 else throw e
}
attr = if (hidden) attr or FILE_ATTRIBUTE_HIDDEN else attr and FILE_ATTRIBUTE_HIDDEN.inv()
val value = "0x%x".format(attr).toByteArray(Charsets.US_ASCII)
android.system.Os.setxattr(path, DOSATTRIB_XATTR, value, 0)
true
} catch (e: Exception) {
false
}
// ── Favorites: origin resolution ──
enum class FavStorage { INTERNAL, SD, OTHER }
data class FavLocation(
val storage: FavStorage,
val driveLabel: String, // "Internal", "SD card", or "Storage"
val displayPath: String // the unix absolute path
)
// Resolve where [file] lives (storage source + a friendly label) by prefix-matching its path.
fun describeLocation(file: File): FavLocation {
val abs = file.absolutePath
val internal = "/storage/emulated/0"
if (abs == internal || abs.startsWith("$internal/")) {
return FavLocation(FavStorage.INTERNAL, "Internal", abs)
}
if (abs.startsWith("/storage/")) {
val name = abs.removePrefix("/storage/").substringBefore('/')
if (name.isNotEmpty() && name != "emulated" && name != "self") {
return FavLocation(FavStorage.SD, "SD card", abs)
}
}
return FavLocation(FavStorage.OTHER, "Storage", abs)
}
// Semantic identity colours for the favourite-card drive badge. Intentionally NOT theme
// accent colours - they identify the storage source at a glance. Returns (background, foreground).
private fun badgeColors(loc: FavLocation): Pair<Color, Color> {
val white = Color(0xFFFFFFFF)
return when {
loc.storage == FavStorage.INTERNAL -> Color(0xFF2E5FB0) to white // blue
loc.storage == FavStorage.SD -> Color(0xFF2E7D32) to white // green
else -> Color(0xFF555555) to white // grey
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1414,675 +1197,3 @@ fun FileManagerScreen(
} // end rail + content Row
}
}
// The context-menu item list shared by the list rows (FileItemRow) and the grid tiles
// (FileGridTile), so both open the identical menu. Gating (isDir/canRun/isInno/looksLikeArchive) is
// recomputed from [file] here - one source of truth for what each item shows. Every item dismisses
// the menu first, then runs its action.
@Composable
private fun FileContextMenuItems(
file: File,
isFavorite: Boolean,
onSelect: () -> Unit,
onRename: () -> Unit,
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onToggleFavorite: () -> Unit,
onProperties: () -> Unit,
onDismissMenu: () -> Unit,
) {
val isDir = file.isDirectory
DropdownMenuItem(
text = { Text("Select") },
leadingIcon = { Icon(Icons.Filled.Checklist, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onSelect() },
)
MenuItemDivider()
// Properties: basic info + Read-only / Hidden toggles, for ANY file or folder (handy for config
// files like .txt/.cfg/.ini). Kept near the top since it's a common reason to open this menu.
DropdownMenuItem(
text = { Text("Properties") },
leadingIcon = { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onProperties() },
)
MenuItemDivider()
// Favorites are directories - only folders get the pin toggle.
if (isDir) {
DropdownMenuItem(
text = { Text(if (isFavorite) "Remove from Favorites" else "Add to Favorites") },
leadingIcon = {
Icon(
if (isFavorite) Icons.Filled.Star else Icons.Filled.StarBorder,
null,
tint = MaterialTheme.colorScheme.primary,
)
},
onClick = { onToggleFavorite() },
)
MenuItemDivider()
}
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = { Icon(Icons.Filled.Edit, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onRename() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Filled.FileCopy, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCopy() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Cut") },
leadingIcon = { Icon(Icons.Filled.ContentCut, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onCut() },
)
MenuItemDivider()
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = { Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.primary) },
onClick = { onDismissMenu(); onDelete() },
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FileItemRow(
file: File,
showActions: Boolean = true,
compact: Boolean = false,
selectionMode: Boolean = false,
selected: Boolean = false,
onLongPress: () -> Unit = {},
onToggleSelect: () -> Unit = {},
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean,
onDismissMenu: () -> Unit,
onSelect: () -> Unit = {},
onCopy: () -> Unit,
onCut: () -> Unit,
onDelete: () -> Unit,
onRename: () -> Unit,
isFavorite: Boolean = false,
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
val isDir = file.isDirectory
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
// wallpaper/icon). Coil sizes the decode to the 36dp slot and caches it, so scrolling stays smooth.
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
// In selection mode a tap toggles instead of opening, so you can rattle through a
// folder without long-pressing every single row.
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 3.dp else 8.dp),
) {
if (selectionMode) {
androidx.compose.material3.Checkbox(checked = selected, onCheckedChange = { onToggleSelect() })
Spacer(Modifier.width(4.dp))
}
when {
// Show the executable's own embedded icon when we managed to extract one.
exeIcon != null -> Image(
bitmap = exeIcon!!,
contentDescription = null,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isExe -> Icon(
painter = painterResource(R.drawable.icon_menu_container),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
isDir -> Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
// Real image preview. Falls back to the generic file icon while loading or on decode failure.
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
imageVector = Icons.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(if (compact) 24.dp else 36.dp),
)
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = buildString {
if (!isDir) append(FileOps.formatBytes(file.length())).append(" \u2022 ")
append(dateFormat.format(Date(file.lastModified())))
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
if (showActions) Box {
IconButton(onClick = onMenu) {
Icon(Icons.Filled.MoreVert, "Actions", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
}
}
// Dedicated Favorites list that replaces the file list while the star toggle is on.
// Reads the store keyed on [favTick] so it recomposes after any pin/unpin.
@Composable
private fun FavoritesList(
currentDir: File,
favTick: Int,
onPinCurrent: () -> Unit,
onJump: (File) -> Unit,
onUnpin: (File) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val favorites = remember(favTick) {
FavoritesStore.list(context).map(::File).filter { it.exists() }
}
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
FavoritesStore.isFavorite(context, currentDir.absolutePath)
}
LazyColumn(modifier = modifier) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Favorites",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
TextButton(
onClick = onPinCurrent,
enabled = !currentAlreadyPinned,
) {
Icon(Icons.Filled.PushPin, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(
text = "Pin current folder",
color = MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
)
}
}
}
if (favorites.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "No favorites yet - pin a folder with its ⋮ menu to jump back here fast.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}
} else {
items(favorites, key = { it.absolutePath }) { file ->
val loc = remember(file.absolutePath) { describeLocation(file) }
FavoriteCard(
file = file,
loc = loc,
onJump = { onJump(file) },
onUnpin = { onUnpin(file) },
)
}
}
}
}
// A single favourite - matches the FileItemRow card style (surfaceContainer + outline +
// RoundedCornerShape(10.dp)). Shows the folder name, a coloured drive badge + origin text,
// and the full display path; tapping jumps into it, the filled star unpins.
@Composable
private fun FavoriteCard(
file: File,
loc: FavLocation,
onJump: () -> Unit,
onUnpin: () -> Unit,
) {
val (badgeBg, badgeFg) = badgeColors(loc)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp),
)
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
// Origin line: coloured drive badge + source description.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = loc.driveLabel,
color = badgeFg,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(badgeBg)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = when (loc.storage) {
FavStorage.INTERNAL -> "Internal storage"
FavStorage.SD -> "SD card"
FavStorage.OTHER -> "Storage"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.height(2.dp))
Text(
text = loc.displayPath,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onUnpin) {
Icon(
Icons.Filled.Star,
contentDescription = "Remove from favorites",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
)
}
}
}
}
/**
* One entry in the File Manager's grid view: a big thumbnail with the name under it.
*
* Deliberately drops size and date - at this width they truncate to noise. The grid is for
* recognising things by sight (screenshots, covers, game folders); the list stays the view for
* reading details.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FileGridTile(
file: File,
selectionMode: Boolean,
selected: Boolean,
onLongPress: () -> Unit,
onToggleSelect: () -> Unit,
onTap: () -> Unit,
onMenu: () -> Unit,
menuExpanded: Boolean = false,
onDismissMenu: () -> Unit = {},
isFavorite: Boolean = false,
onSelect: () -> Unit = {},
onRename: () -> Unit = {},
onCopy: () -> Unit = {},
onCut: () -> Unit = {},
onDelete: () -> Unit = {},
onToggleFavorite: () -> Unit = {},
onProperties: () -> Unit = {},
) {
val isDir = file.isDirectory
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
if (!isDir && file.name.lowercase().endsWith(".exe")) {
LaunchedEffect(file.absolutePath) {
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
if (bmp != null) exeIcon = bmp.asImageBitmap()
}
}
// The tile has no ⋮ button - long-press opens this menu, anchored to the Box around the Card.
Box {
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(
containerColor = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
else MaterialTheme.colorScheme.surfaceContainer,
),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
Box(modifier = Modifier.size(56.dp), contentAlignment = Alignment.Center) {
when {
exeIcon != null -> Image(bitmap = exeIcon!!, contentDescription = null, modifier = Modifier.size(48.dp))
isDir -> Icon(
Icons.Filled.Folder, null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
isImage -> AsyncImage(
model = file,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = rememberVectorPainter(Icons.Filled.InsertDriveFile),
error = rememberVectorPainter(Icons.Filled.InsertDriveFile),
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)),
)
else -> Icon(
Icons.Filled.InsertDriveFile, null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(44.dp),
)
}
if (selectionMode) {
androidx.compose.material3.Checkbox(
checked = selected,
onCheckedChange = { onToggleSelect() },
modifier = Modifier.align(Alignment.TopStart),
)
}
}
Spacer(Modifier.height(4.dp))
// Always reserve two lines (issue #475): a one-line name used to make its card shorter than
// its neighbours, so grid rows had ragged heights. Fixed min/max keeps every tile the same size.
Text(
file.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
minLines = 2,
maxLines = 2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
overflow = TextOverflow.Ellipsis,
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = onDismissMenu,
modifier = Modifier.outlinedMenuCard(),
) {
FileContextMenuItems(
file = file,
isFavorite = isFavorite,
onSelect = onSelect,
onRename = onRename,
onCopy = onCopy,
onCut = onCut,
onDelete = onDelete,
onToggleFavorite = onToggleFavorite,
onProperties = onProperties,
onDismissMenu = onDismissMenu,
)
}
}
}
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
private fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -0,0 +1,202 @@
package com.droiddeck.launcher.files
import android.widget.Toast
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// The File Manager's properties dialog.
/**
* Properties sheet for a single file or folder: basic info plus the two Windows/Wine file attributes
* the in-container file manager (wfm.exe) also exposes - Read-only and Hidden. Each toggle applies
* immediately (off the main thread) and refreshes the listing via [onChanged].
*
* State is keyed on the file so it always reflects the entry it was opened for. On a filesystem that
* can't store Wine's DOSATTRIB xattr (the FUSE /storage volumes) only the Hidden toggle is disabled;
* Read-only keeps working everywhere.
*/
@Composable
internal fun FilePropertiesDialog(
file: File,
onDismiss: () -> Unit,
onChanged: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
// Guards against a second toggle landing while the first is still being applied off-thread.
var busy by remember(file.absolutePath) { mutableStateOf(false) }
LaunchedEffect(file.absolutePath) {
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
}
OutlinedAlertDialog(
onDismissRequest = onDismiss,
title = { Text("Properties") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
// ── Basic info ──
Text(
text = file.name,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(6.dp))
PropertyLine("Location", file.parent ?: "-")
PropertyLine(
"Type",
if (file.isDirectory) "Folder"
else file.extension.uppercase().let { if (it.isBlank()) "File" else "$it file" },
)
if (!file.isDirectory) PropertyLine("Size", FileOps.formatBytes(file.length()))
PropertyLine("Modified", dateFormat.format(Date(file.lastModified())))
Spacer(Modifier.height(10.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(Modifier.height(6.dp))
val state = attrs
// ── Read-only ── checked when the owner can't write (Wine's FILE_ATTRIBUTE_READONLY).
AttributeToggleRow(
label = "Read-only",
description = "Stops games (and Wine) from overwriting or deleting this file.",
checked = state?.readOnly == true,
enabled = state != null && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(readOnly = want)
onChanged()
} else {
Toast.makeText(context, "Couldn't change Read-only", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(2.dp))
// ── Hidden ── Wine's DOS hidden bit in the user.DOSATTRIB xattr.
val hiddenSupported = state?.hiddenSupported == true
AttributeToggleRow(
label = "Hidden",
description = if (state != null && !hiddenSupported)
"This storage can't store the hidden flag."
else "Marks the file hidden in Windows (Wine's hidden attribute).",
checked = state?.hidden == true,
enabled = state != null && hiddenSupported && !busy,
onToggle = { want ->
busy = true
scope.launch {
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
busy = false
if (ok) {
attrs = attrs?.copy(hidden = want)
onChanged()
} else {
// The write failed after all - disable the toggle rather than lie.
attrs = attrs?.copy(hiddenSupported = false)
Toast.makeText(context, "Couldn't change Hidden on this storage", Toast.LENGTH_SHORT).show()
}
}
},
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } },
)
}
// One "label: value" line in the Properties info block.
@Composable
private fun PropertyLine(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
modifier = Modifier.width(76.dp),
)
Text(
text = value,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// A labelled attribute switch with a one-line description, used by the Properties sheet.
@Composable
private fun AttributeToggleRow(
label: String,
description: String,
checked: Boolean,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
Spacer(Modifier.width(8.dp))
androidx.compose.material3.Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onToggle,
)
}
}