M5: storage usage, ZIP/TAR/GZ archives with zip-slip guard, thumbnails, viewers + 26 tests

This commit is contained in:
2026-10-14 16:29:08 +02:00
parent 2c86f3dc67
commit 3e39fc9b86
23 changed files with 1302 additions and 35 deletions
@@ -0,0 +1,72 @@
package de.softwareapp_hb.filemanager.core
/**
* Which archive formats the app can handle, by file name.
*
* The list is deliberately honest: ZIP, TAR, the compression wrappers, and single-file
* gzip/bzip2/xz all come from `java.util.zip` and Commons Compress, both of which are
* pure Java. RAR and 7z are recognised so the UI can say so instead of failing with a
* confusing error — they need native code, which this project does not ship.
*/
enum class ArchiveFormat {
ZIP,
TAR,
TAR_GZIP,
GZIP,
BZIP2,
XZ,
/** Recognised but not supported (needs native code). */
UNSUPPORTED,
/** Not an archive name we know. */
UNKNOWN,
}
object ArchiveFormats {
/** The formats this app can create. Writing is ZIP only, as everywhere else. */
val creatable: Set<ArchiveFormat> = setOf(ArchiveFormat.ZIP)
private val ZIP_NAMES = setOf("zip", "jar", "cbz")
private val UNSUPPORTED_NAMES = setOf("7z", "rar", "cab", "iso", "wim")
fun formatOf(fileName: String): ArchiveFormat {
val lower = fileName.lowercase()
return when {
lower.endsWith(".tar.gz") || lower.endsWith(".tgz") -> ArchiveFormat.TAR_GZIP
lower.endsWith(".bz2") -> ArchiveFormat.BZIP2
lower.endsWith(".xz") -> ArchiveFormat.XZ
lower.endsWith(".tar") -> ArchiveFormat.TAR
lower.endsWith(".gz") -> ArchiveFormat.GZIP
else -> {
val extension = FileKind.extensionOf(lower)
when {
extension in ZIP_NAMES -> ArchiveFormat.ZIP
extension in UNSUPPORTED_NAMES -> ArchiveFormat.UNSUPPORTED
else -> ArchiveFormat.UNKNOWN
}
}
}
}
fun isRecognised(fileName: String): Boolean = formatOf(fileName) != ArchiveFormat.UNKNOWN
/** `true` when [fileName] can actually be extracted. */
fun isExtractable(fileName: String): Boolean = when (formatOf(fileName)) {
ArchiveFormat.UNSUPPORTED, ArchiveFormat.UNKNOWN -> false
else -> true
}
/** `file.txt.gz` -> `file.txt`, so a single-file wrapper loses its own suffix. */
fun innerName(fileName: String, format: ArchiveFormat): String = when (format) {
ArchiveFormat.TAR_GZIP -> fileName.removeSuffixIgnoreCase(".tgz").removeSuffixIgnoreCase(".tar.gz") + ".tar"
ArchiveFormat.GZIP -> fileName.removeSuffixIgnoreCase(".gz")
ArchiveFormat.BZIP2 -> fileName.removeSuffixIgnoreCase(".bz2")
ArchiveFormat.XZ -> fileName.removeSuffixIgnoreCase(".xz")
else -> fileName
}
private fun String.removeSuffixIgnoreCase(suffix: String): String =
if (endsWith(suffix, ignoreCase = true)) dropLast(suffix.length) else this
}
@@ -0,0 +1,43 @@
package de.softwareapp_hb.filemanager.core
/**
* Guards archive extraction against writing outside the destination folder.
*
* This is the "zip slip" class of attack: an archive can contain an entry called
* `../../data/data/<package>/shared_prefs/session.xml`, and a naive extractor that joins
* it onto the destination silently overwrites the app's own files — or, with a symlink,
* anything the app can reach. Every entry name therefore has to be checked before it
* becomes a path.
*/
object ArchivePath {
/**
* The destination-relative path an entry may be written to, or `null` when the entry
* is unsafe and must be skipped.
*
* Rejected: absolute paths (`/etc/passwd`), Windows drive letters and UNC paths
* (`C:\x`, `\\\\server\\share`), and anything that climbs out of the destination with
* `..`. Names are normalized, so `a/./b` becomes `a/b` and `a/../b` becomes `b`.
*/
fun safeRelativePath(entryName: String): String? {
val unified = entryName.trim().replace('\\', '/')
if (unified.isEmpty()) return null
if (unified.startsWith("/")) return null
if (unified.length >= 2 && unified[1] == ':') return null
val segments = ArrayList<String>()
for (segment in unified.split('/')) {
when (segment) {
"", "." -> Unit
// Climbing out of the destination is exactly what must not happen, and
// "above the destination" is not a place an entry can legitimately go.
".." -> if (segments.isEmpty()) return null else segments.removeAt(segments.lastIndex)
else -> segments += segment
}
}
return if (segments.isEmpty()) null else segments.joinToString("/")
}
}
@@ -0,0 +1,238 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.ArchiveFormat
import de.softwareapp_hb.filemanager.core.ArchiveFormats
import de.softwareapp_hb.filemanager.core.ArchivePath
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageSource
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import org.apache.commons.compress.archivers.ArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
import org.apache.commons.compress.compressors.xz.XZCompressorInputStream
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlin.coroutines.cancellation.CancellationException
/**
* Creates and extracts archives, on top of the storage backends.
*
* Everything here is pure Java — `java.util.zip` for writing, Commons Compress for
* reading — so the app ships no native code and all of this is testable on the JVM. RAR
* and 7z are recognised but refused (see [ArchiveFormats]) rather than half-supported.
*
* The security-critical part is that every entry name goes through
* [ArchivePath.safeRelativePath] before it becomes a path: an archive must never be able
* to write outside the folder it is being extracted into.
*/
class ArchiveEngine(private val source: StorageSource) {
/** Write [entries] into a new ZIP at [destination]. Folders are included recursively. */
suspend fun createZip(
entries: List<StoragePath>,
destination: StoragePath,
onProgress: (OperationProgress) -> Unit = {},
): OperationResult {
val planned = LinkedHashMap<StoragePath, String>()
for (entry in entries) collect(entry, entry.name, planned)
val state = ArchiveState(OperationKind.COMPRESS, planned.size, onProgress)
try {
destination.parent?.let { source.createDirectory(it) }
source.createFile(destination)
source.openWrite(destination).use { raw ->
ZipOutputStream(BufferedOutputStream(raw)).use { zip ->
for ((file, entryName) in planned) {
if (!currentCoroutineContext().isActive) {
state.cancel()
break
}
zip.putNextEntry(ZipEntry(entryName))
source.openRead(file).use { it.copyTo(zip, BUFFER_BYTES) }
zip.closeEntry()
state.advance(file)
}
}
}
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
state.failure(destination, failure.message ?: "The archive could not be created")
}
return state.result()
}
/** Depth-first list of files, each with the name it will carry inside the archive. */
private suspend fun collect(
path: StoragePath,
entryName: String,
out: MutableMap<StoragePath, String>,
) {
val entry = source.stat(path) ?: return
if (entry.isDirectory) {
for (child in source.list(path)) {
collect(child.path, "$entryName/${child.name}", out)
}
} else {
out[path] = entryName
}
}
/**
* Extract [archive] into [destination].
*
* A single-file wrapper (`.gz`, `.bz2`, `.xz`) produces one file named after the
* archive with the suffix removed; everything else is read as a container.
*/
suspend fun extract(
archive: StoragePath,
destination: StoragePath,
onProgress: (OperationProgress) -> Unit = {},
): OperationResult {
val format = ArchiveFormats.formatOf(archive.name)
val state = ArchiveState(OperationKind.EXTRACT, 0, onProgress)
if (format == ArchiveFormat.UNKNOWN || format == ArchiveFormat.UNSUPPORTED) {
state.failure(archive, "This archive format is not supported")
return state.result()
}
try {
source.createDirectory(destination)
source.openRead(archive).use { raw ->
BufferedInputStream(raw).use { input ->
when (format) {
ArchiveFormat.ZIP -> readArchive(ZipArchiveInputStream(input), destination, state)
ArchiveFormat.TAR -> readArchive(TarArchiveInputStream(input), destination, state)
ArchiveFormat.TAR_GZIP -> readArchive(
TarArchiveInputStream(GzipCompressorInputStream(input)),
destination,
state,
)
ArchiveFormat.GZIP -> readSingle(
GzipCompressorInputStream(input),
ArchiveFormats.innerName(archive.name, format),
destination,
state,
)
ArchiveFormat.BZIP2 -> readSingle(
BZip2CompressorInputStream(input),
ArchiveFormats.innerName(archive.name, format),
destination,
state,
)
ArchiveFormat.XZ -> readSingle(
XZCompressorInputStream(input),
ArchiveFormats.innerName(archive.name, format),
destination,
state,
)
else -> state.failure(archive, "This archive format is not supported")
}
}
}
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
state.failure(archive, failure.message ?: "The archive could not be extracted")
}
return state.result()
}
private suspend fun readArchive(
archive: ArchiveInputStream<*>,
destination: StoragePath,
state: ArchiveState,
) {
archive.use { stream ->
while (currentCoroutineContext().isActive) {
val entry = stream.nextEntry ?: break
val relative = ArchivePath.safeRelativePath(entry.name)
if (relative == null) {
// Recorded rather than thrown: one hostile or malformed entry must not
// abandon the rest of the archive.
state.failure(destination, "Skipped an unsafe entry: ${entry.name}")
continue
}
val target = destination.resolve(relative)
if (entry.isDirectory) {
source.createDirectory(target)
} else {
target.parent?.let { source.createDirectory(it) }
source.createFile(target)
source.openWrite(target).use { output -> stream.copyTo(output, BUFFER_BYTES) }
state.advance(target)
}
}
if (!currentCoroutineContext().isActive) state.cancel()
}
}
private suspend fun readSingle(
decompressed: InputStream,
name: String,
destination: StoragePath,
state: ArchiveState,
) {
val relative = ArchivePath.safeRelativePath(name)
if (relative == null) {
state.failure(destination, "Skipped an unsafe entry: $name")
return
}
val target = destination.resolve(relative)
decompressed.use { input ->
source.createFile(target)
source.openWrite(target).use { output -> input.copyTo(output, BUFFER_BYTES) }
}
state.advance(target)
}
/** Progress bookkeeping, matching the one in [FileOperations]. */
private class ArchiveState(
private val kind: OperationKind,
private val total: Int,
private val onProgress: (OperationProgress) -> Unit,
) {
private var cancelled = false
private var completed = 0
private var current: StoragePath? = null
private val failures = mutableListOf<OperationFailure>()
fun advance(path: StoragePath) {
completed++
current = path
emit()
}
fun failure(path: StoragePath, reason: String) {
failures += OperationFailure(path, reason)
}
fun cancel() {
cancelled = true
}
fun result(): OperationResult = OperationResult(kind, completed, failures.toList(), cancelled)
private fun emit() = onProgress(OperationProgress(kind, completed, total, current))
}
companion object {
private const val BUFFER_BYTES = 64 * 1024
}
}
@@ -3,7 +3,7 @@ package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.StoragePath
/** The kind of long-running operation a progress notification can describe. */
enum class OperationKind { COPY, MOVE, DELETE }
enum class OperationKind { COPY, MOVE, DELETE, COMPRESS, EXTRACT }
/**
* A snapshot of a running operation.
@@ -0,0 +1,79 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageSource
/**
* One row of the storage-usage view: a child of the folder being measured, with everything
* below it rolled up.
*/
data class UsageNode(
val path: StoragePath,
val name: String,
val isDirectory: Boolean,
val sizeBytes: Long,
val fileCount: Int,
)
/**
* Works out where the space went.
*
* Sizes are *recursive*: a folder reports the total of everything beneath it, which is
* what the question "what is eating my storage?" actually means. The walk is metadata
* only, so it is far cheaper than reading any file.
*/
class UsageAnalysis(private val source: StorageSource) {
/** The children of [directory] with their recursive sizes, largest first. */
suspend fun childrenBySize(directory: StoragePath): List<UsageNode> =
source.list(directory)
.map { entry ->
val measured = measure(entry.path)
UsageNode(
path = entry.path,
name = entry.name,
isDirectory = entry.isDirectory,
sizeBytes = measured.first,
fileCount = measured.second,
)
}
.sortedWith(compareByDescending<UsageNode> { it.sizeBytes }.thenBy { it.name })
/** The largest files anywhere below [directory], biggest first. */
suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List<FileEntry> {
val found = ArrayList<FileEntry>()
collectFiles(directory, found)
return found.sortedByDescending { it.sizeBytes }.take(limit)
}
/** Total bytes and file count below [directory]. */
suspend fun total(directory: StoragePath): Pair<Long, Int> = measure(directory)
private suspend fun measure(path: StoragePath): Pair<Long, Int> {
val entry = source.stat(path) ?: return 0L to 0
if (!entry.isDirectory) return entry.sizeBytes to 1
var size = 0L
var count = 0
for (child in source.list(path)) {
val measured = measure(child.path)
size += measured.first
count += measured.second
}
return size to count
}
private suspend fun collectFiles(path: StoragePath, out: MutableList<FileEntry>) {
val entry = source.stat(path) ?: return
if (entry.isDirectory) {
for (child in source.list(path)) collectFiles(child.path, out)
} else {
out += entry
}
}
companion object {
const val LARGEST_LIMIT = 50
}
}
@@ -16,4 +16,6 @@ fun OperationKind.titleRes(): Int = when (this) {
OperationKind.COPY -> R.string.operation_copy_title
OperationKind.MOVE -> R.string.operation_move_title
OperationKind.DELETE -> R.string.operation_delete_title
OperationKind.COMPRESS -> R.string.operation_compress_title
OperationKind.EXTRACT -> R.string.operation_extract_title
}
@@ -30,14 +30,51 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.FileSize
import java.io.File
/**
* The leading visual for an entry: a real thumbnail for an image we can actually read,
* otherwise the category icon.
*
* Coil needs a filesystem path, so this only attempts a thumbnail in the all-files mode —
* a SAF path is relative to the granted tree and would silently fail.
*/
@Composable
fun EntryIcon(entry: FileEntry, size: Dp) {
if (entry.kind == FileKind.IMAGE && entry.path.value.startsWith("/")) {
AsyncImage(
model = File(entry.path.value),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(6.dp)),
)
} else {
Icon(
imageVector = iconFor(entry.kind),
contentDescription = null,
modifier = Modifier.size(size),
tint = if (entry.isDirectory) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
/** The icon that stands for a file category. */
fun iconFor(kind: FileKind): ImageVector = when (kind) {
@@ -71,18 +108,7 @@ fun EntryRow(
} else {
ListItemDefaults.colors()
},
leadingContent = {
Icon(
imageVector = iconFor(entry.kind),
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = if (entry.isDirectory) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
leadingContent = { EntryIcon(entry = entry, size = 28.dp) },
headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(text = entry.subtitle(), style = MaterialTheme.typography.bodySmall) },
trailingContent = if (selected) {
@@ -119,16 +145,7 @@ fun EntryGridCell(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = iconFor(entry.kind),
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = if (entry.isDirectory) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
EntryIcon(entry = entry, size = 40.dp)
Spacer(Modifier.height(8.dp))
Text(
text = entry.name,
@@ -63,6 +63,9 @@ import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.usage.UsageScreen
import de.softwareapp_hb.filemanager.ui.viewer.ImageViewerScreen
import de.softwareapp_hb.filemanager.ui.viewer.TextEditorScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -112,6 +115,36 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
state.pendingDeleteRequest?.let { deleteLauncher.launch(it) }
}
// A viewer, editor or the usage view takes over the whole screen.
when (val overlay = state.overlay) {
is Overlay.Image -> {
ImageViewerScreen(entry = overlay.entry, onClose = viewModel::closeOverlay)
return
}
is Overlay.Text -> {
TextEditorScreen(
entry = overlay.entry,
content = overlay.content,
onSave = viewModel::saveText,
onClose = viewModel::closeOverlay,
)
return
}
is Overlay.Usage -> {
UsageScreen(
nodes = overlay.nodes,
loading = overlay.loading,
onClose = viewModel::closeOverlay,
)
return
}
Overlay.None -> Unit
}
val message = state.message
val messageText = message?.let {
it.pluralQuantity?.let { quantity ->
@@ -152,6 +185,7 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
onSort = { showSort = true },
onPaste = viewModel::paste,
onRefresh = viewModel::refresh,
onUsage = viewModel::openUsage,
)
},
floatingActionButton = {
@@ -173,6 +207,10 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
onDelete = { confirmDelete = state.selection.toList() },
onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
onRename = { renameTarget = state.selectedEntries.singleOrNull() },
onCompress = { viewModel.compress(state.selection.toList()) },
onExtract = {
state.selectedEntries.singleOrNull()?.let(viewModel::extract)
},
onShare = {
state.selectedEntries.singleOrNull()?.let { entry ->
shareIntent(context, entry)?.let { intent ->
@@ -6,6 +6,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PieChart
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
@@ -44,6 +45,7 @@ fun BrowserTopBar(
onSort: () -> Unit,
onPaste: () -> Unit,
onRefresh: () -> Unit,
onUsage: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
@@ -102,6 +104,11 @@ fun BrowserTopBar(
onClick = { menuOpen = false; onPaste() },
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_disk_usage)) },
leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) },
onClick = { menuOpen = false; onUsage() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_refresh)) },
leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
@@ -8,6 +8,7 @@ import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.SortSpec
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageAccessMode
import de.softwareapp_hb.filemanager.domain.UsageNode
enum class ViewMode { LIST, GRID }
@@ -48,6 +49,21 @@ data class RootItem(
val freeBytes: Long? = null,
)
/** A full-screen view the browser hands over to. */
sealed interface Overlay {
data object None : Overlay
data class Image(val entry: FileEntry) : Overlay
data class Text(val entry: FileEntry, val content: String) : Overlay
data class Usage(
val path: StoragePath,
val nodes: List<UsageNode>,
val loading: Boolean,
) : Overlay
}
/**
* One immutable description of everything the browser shows.
*
@@ -77,6 +93,8 @@ data class BrowserUiState(
/** Set when the system should confirm a delete; the screen launches it and clears it. */
val pendingDeleteRequest: IntentSenderRequest? = null,
val conflict: ConflictPrompt? = null,
/** A viewer, editor or the storage-usage view taking over the screen. */
val overlay: Overlay = Overlay.None,
) {
val isRootView: Boolean get() = path == null
@@ -9,8 +9,11 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.softwareapp_hb.filemanager.FileManagerApplication
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.ArchiveFormats
import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.NameAllocator
import de.softwareapp_hb.filemanager.core.NameMatcher
import de.softwareapp_hb.filemanager.core.SortDirection
import de.softwareapp_hb.filemanager.core.SortKey
@@ -20,10 +23,12 @@ import de.softwareapp_hb.filemanager.core.TransferAction
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
import de.softwareapp_hb.filemanager.data.StorageAccessMode
import de.softwareapp_hb.filemanager.data.StorageLocation
import de.softwareapp_hb.filemanager.domain.ArchiveEngine
import de.softwareapp_hb.filemanager.domain.FileOperations
import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier
import de.softwareapp_hb.filemanager.domain.OperationResult
import de.softwareapp_hb.filemanager.domain.UsageAnalysis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -231,12 +236,13 @@ class BrowserViewModel(
// ------------------------------------------------------------------ opening
/** Open an entry: folders navigate, files are handed to the system chooser. */
/** Open an entry: folders navigate, images and text open in the built-in viewers. */
fun open(entry: FileEntry) {
if (entry.isDirectory) {
openDirectory(entry.path)
} else {
_state.update { it.copy(pendingOpen = entry) }
when {
entry.isDirectory -> openDirectory(entry.path)
entry.kind == FileKind.IMAGE -> _state.update { it.copy(overlay = Overlay.Image(entry)) }
entry.kind == FileKind.TEXT -> editText(entry)
else -> _state.update { it.copy(pendingOpen = entry) }
}
}
@@ -244,6 +250,112 @@ class BrowserViewModel(
_state.update { it.copy(pendingOpen = null) }
}
// ------------------------------------------------------------------ overlays
fun editText(entry: FileEntry) {
val source = storage.activeSource() ?: return
viewModelScope.launch {
val content = runCatching {
source.openRead(entry.path).use { it.readBytes().decodeToString() }
}.getOrDefault("")
_state.update { it.copy(overlay = Overlay.Text(entry, content)) }
}
}
fun saveText(content: String) {
val editing = _state.value.overlay as? Overlay.Text ?: return
val source = storage.activeSource() ?: return
viewModelScope.launch {
val saved = runCatching {
source.openWrite(editing.entry.path).use { it.write(content.toByteArray()) }
}.isSuccess
message(if (saved) R.string.message_saved else R.string.message_save_failed)
_state.update { it.copy(overlay = Overlay.None) }
_state.value.path?.let { load(it) }
}
}
fun openUsage() {
val path = _state.value.path ?: return
val source = storage.activeSource() ?: return
_state.update { it.copy(overlay = Overlay.Usage(path, emptyList(), loading = true)) }
viewModelScope.launch {
val nodes = runCatching { UsageAnalysis(source).childrenBySize(path) }
.getOrDefault(emptyList())
_state.update { it.copy(overlay = Overlay.Usage(path, nodes, loading = false)) }
}
}
fun closeOverlay() {
_state.update { it.copy(overlay = Overlay.None) }
}
// ---------------------------------------------------------------- archives
/** Compress [paths] into a new ZIP beside them, under a name that is still free. */
fun compress(paths: List<StoragePath>) {
val target = _state.value.path ?: return
val source = storage.activeSource() ?: return
if (paths.isEmpty()) return
val base = if (paths.size == 1) "${paths.first().name}.zip" else "archive.zip"
val taken = _state.value.entries.mapTo(HashSet()) { it.name }
val name = NameAllocator.uniqueName(base) { it in taken }
viewModelScope.launch {
_state.update { it.copy(loading = true, selection = emptySet()) }
notifier.start(OperationKind.COMPRESS)
val result = try {
ArchiveEngine(source).createZip(paths, target.child(name)) { notifier.update(it) }
} finally {
notifier.stop()
}
_state.update { it.copy(loading = false) }
if (result.succeeded) {
messagePlural(R.plurals.message_compressed, result.completed)
} else {
report(result)
}
load(target)
}
}
/** Extract [entry] into a new folder beside it, named after the archive. */
fun extract(entry: FileEntry) {
val target = _state.value.path ?: return
val source = storage.activeSource() ?: return
if (!ArchiveFormats.isExtractable(entry.name)) {
message(R.string.message_format_unsupported)
return
}
val inner = ArchiveFormats.innerName(entry.name, ArchiveFormats.formatOf(entry.name))
val folder = inner.substringBeforeLast('.', inner)
val taken = _state.value.entries.mapTo(HashSet()) { it.name }
val folderName = NameAllocator.uniqueName(folder) { it in taken }
viewModelScope.launch {
_state.update { it.copy(loading = true, selection = emptySet()) }
notifier.start(OperationKind.EXTRACT)
val result = try {
ArchiveEngine(source).extract(entry.path, target.child(folderName)) { notifier.update(it) }
} finally {
notifier.stop()
}
_state.update { it.copy(loading = false) }
if (result.succeeded) {
messagePlural(R.plurals.message_extracted, result.completed)
} else {
report(result)
}
load(target)
}
}
// ---------------------------------------------------------------- clipboard
fun copyToClipboard(paths: List<StoragePath>, cut: Boolean) {
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentCut
@@ -14,6 +15,7 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
@@ -27,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.ArchiveFormats
/** Shown when the app has nothing it is allowed to browse yet. */
@Composable
@@ -76,6 +79,8 @@ fun SelectionBar(
onDetails: () -> Unit,
onRename: () -> Unit,
onShare: () -> Unit,
onCompress: () -> Unit,
onExtract: () -> Unit,
onSelectAll: () -> Unit,
onClear: () -> Unit,
) {
@@ -106,6 +111,14 @@ fun SelectionBar(
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.browser_action_delete))
}
IconButton(onClick = onCompress) {
Icon(Icons.Filled.Archive, contentDescription = stringResource(R.string.browser_action_compress))
}
if (state.selectedEntries.singleOrNull()?.let { ArchiveFormats.isExtractable(it.name) } == true) {
IconButton(onClick = onExtract) {
Icon(Icons.Filled.Unarchive, contentDescription = stringResource(R.string.browser_action_extract))
}
}
IconButton(onClick = onSelectAll) {
Icon(Icons.Filled.SelectAll, contentDescription = stringResource(R.string.browser_action_select_all))
}
@@ -0,0 +1,146 @@
package de.softwareapp_hb.filemanager.ui.usage
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileSize
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.domain.UsageNode
import de.softwareapp_hb.filemanager.ui.browser.iconFor
/**
* Where the space went: each child of the current folder with its recursive size, biggest
* first, and a bar showing its share of the folder.
*
* A per-row bar rather than a chart keeps it readable on a phone and needs no extra
* drawing code — the numbers are the point, the bar is the comparison.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UsageScreen(
nodes: List<UsageNode>,
loading: Boolean,
onClose: () -> Unit,
) {
val total = nodes.sumOf { it.sizeBytes }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.usage_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.browser_action_close),
)
}
},
)
},
) { innerPadding ->
if (nodes.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
if (loading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
Text(
text = stringResource(R.string.usage_empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
items(items = nodes, key = { it.path.value }) { node ->
UsageRow(node = node, total = total)
}
}
}
}
@Composable
private fun UsageRow(node: UsageNode, total: Long) {
val share = if (total > 0L) node.sizeBytes.toFloat() / total.toFloat() else 0f
ListItem(
leadingContent = {
Icon(
imageVector = if (node.isDirectory) {
iconFor(FileKind.DIRECTORY)
} else {
Icons.Filled.InsertDriveFile
},
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
},
headlineContent = { Text(node.name) },
supportingContent = {
Column {
Text(
text = "${FileSize.format(node.sizeBytes)} · " +
pluralStringResource(R.plurals.usage_file_count, node.fileCount, node.fileCount),
style = MaterialTheme.typography.bodySmall,
)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
Box(
modifier = Modifier
.fillMaxWidth(share)
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.primary),
)
}
}
},
)
}
@@ -0,0 +1,64 @@
package de.softwareapp_hb.filemanager.ui.viewer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import coil3.compose.AsyncImage
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileEntry
import java.io.File
/**
* The built-in image viewer.
*
* It loads from the real file path through Coil, so it works in the all-files mode only —
* a SAF path is not a filesystem path. When the image cannot be rendered the frame simply
* stays empty rather than the screen failing.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageViewerScreen(entry: FileEntry, onClose: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.browser_action_close),
)
}
},
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = File(entry.path.value),
contentDescription = entry.name,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -0,0 +1,69 @@
package de.softwareapp_hb.filemanager.ui.viewer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileEntry
/** A plain text editor for the text formats the browser classifies as [FileKind.TEXT]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TextEditorScreen(
entry: FileEntry,
content: String,
onSave: (String) -> Unit,
onClose: () -> Unit,
) {
var text by remember(entry.path.value) { mutableStateOf(content) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.browser_action_close),
)
}
},
actions = {
TextButton(onClick = { onSave(text) }) {
Text(stringResource(R.string.browser_action_save))
}
},
)
},
) { innerPadding ->
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(8.dp),
textStyle = MaterialTheme.typography.bodySmall,
)
}
}
+28
View File
@@ -14,6 +14,8 @@
<string name="operation_copy_title">Copying files</string>
<string name="operation_move_title">Moving files</string>
<string name="operation_delete_title">Deleting files</string>
<string name="operation_compress_title">Creating the archive</string>
<string name="operation_extract_title">Extracting files</string>
<string name="notification_channel_name">File operations</string>
<string name="notification_channel_description">Shows progress while files are being copied, moved or deleted</string>
<!-- Both numbers are counts of entries, so this stays a plain substitution. -->
@@ -127,4 +129,30 @@
<item quantity="other">%1$d items could not be processed</item>
</plurals>
<string name="message_cancelled">Cancelled</string>
<string name="message_saved">Saved</string>
<string name="message_save_failed">The file could not be saved</string>
<string name="message_format_unsupported">This archive format is not supported</string>
<plurals name="message_compressed">
<item quantity="one">Compressed %1$d file</item>
<item quantity="other">Compressed %1$d files</item>
</plurals>
<plurals name="message_extracted">
<item quantity="one">Extracted %1$d file</item>
<item quantity="other">Extracted %1$d files</item>
</plurals>
<!-- ===== Archive + usage actions ===== -->
<string name="browser_action_compress">Compress to ZIP</string>
<string name="browser_action_extract">Extract here</string>
<string name="browser_action_disk_usage">Storage usage</string>
<!-- ===== Viewer / editor / usage ===== -->
<string name="browser_action_save">Save</string>
<string name="browser_action_close">Close</string>
<string name="usage_title">Storage usage</string>
<string name="usage_empty">Nothing to measure here</string>
<plurals name="usage_file_count">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>
</resources>
@@ -0,0 +1,118 @@
package de.softwareapp_hb.filemanager.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ArchivePathTest {
@Test
fun `plain names are kept as they are`() {
assertEquals("a.txt", ArchivePath.safeRelativePath("a.txt"))
assertEquals("dir/a.txt", ArchivePath.safeRelativePath("dir/a.txt"))
}
@Test
fun `redundant segments are normalized away`() {
assertEquals("a/b", ArchivePath.safeRelativePath("./a/./b"))
assertEquals("b", ArchivePath.safeRelativePath("a/../b"))
assertEquals("a/b", ArchivePath.safeRelativePath("a//b"))
}
@Test
fun `absolute paths are rejected`() {
assertNull(ArchivePath.safeRelativePath("/etc/passwd"))
assertNull(ArchivePath.safeRelativePath("//server/share/file"))
}
@Test
fun `windows drive letters are rejected`() {
assertNull(ArchivePath.safeRelativePath("C:\\Windows\\evil.dll"))
assertNull(ArchivePath.safeRelativePath("c:/users/evil.txt"))
}
@Test
fun `climbing out of the destination is rejected`() {
assertNull(ArchivePath.safeRelativePath("../evil.txt"))
assertNull(ArchivePath.safeRelativePath("a/../../evil.txt"))
assertNull(ArchivePath.safeRelativePath(".."))
}
@Test
fun `backslashes are treated as separators`() {
assertEquals("dir/file.txt", ArchivePath.safeRelativePath("dir\\file.txt"))
assertNull(ArchivePath.safeRelativePath("..\\evil.txt"))
}
@Test
fun `empty and dot-only names are rejected`() {
assertNull(ArchivePath.safeRelativePath(""))
assertNull(ArchivePath.safeRelativePath(" "))
assertNull(ArchivePath.safeRelativePath("."))
assertNull(ArchivePath.safeRelativePath("./"))
}
@Test
fun `a climb that stays inside the destination is allowed`() {
assertEquals("b/c.txt", ArchivePath.safeRelativePath("a/../b/c.txt"))
}
@Test
fun `an unsafe name never yields a path with a parent segment`() {
val names = listOf("../x", "/x", "a/../../x", "C:\\x", "..\\x")
assertTrue(names.all { ArchivePath.safeRelativePath(it) == null })
assertFalse(names.any { ArchivePath.safeRelativePath(it)?.contains("..") == true })
}
}
class ArchiveFormatsTest {
@Test
fun `detects the formats it can extract`() {
assertEquals(ArchiveFormat.ZIP, ArchiveFormats.formatOf("photos.zip"))
assertEquals(ArchiveFormat.TAR, ArchiveFormats.formatOf("backup.tar"))
assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormats.formatOf("backup.tar.gz"))
assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormats.formatOf("backup.tgz"))
assertEquals(ArchiveFormat.GZIP, ArchiveFormats.formatOf("notes.txt.gz"))
assertEquals(ArchiveFormat.BZIP2, ArchiveFormats.formatOf("a.bz2"))
assertEquals(ArchiveFormat.XZ, ArchiveFormats.formatOf("a.xz"))
}
@Test
fun `recognises but refuses formats that would need native code`() {
assertEquals(ArchiveFormat.UNSUPPORTED, ArchiveFormats.formatOf("pack.7z"))
assertEquals(ArchiveFormat.UNSUPPORTED, ArchiveFormats.formatOf("pack.rar"))
assertFalse(ArchiveFormats.isExtractable("pack.rar"))
assertFalse(ArchiveFormats.isExtractable("pack.7z"))
assertTrue(ArchiveFormats.isRecognised("pack.rar"))
}
@Test
fun `an ordinary file is not an archive`() {
assertEquals(ArchiveFormat.UNKNOWN, ArchiveFormats.formatOf("holiday.jpg"))
assertFalse(ArchiveFormats.isRecognised("holiday.jpg"))
assertFalse(ArchiveFormats.isExtractable("holiday.jpg"))
}
@Test
fun `detection is case-insensitive and tgz wins over gz`() {
assertEquals(ArchiveFormat.ZIP, ArchiveFormats.formatOf("A.ZIP"))
assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormats.formatOf("A.TGZ"))
assertEquals(ArchiveFormat.GZIP, ArchiveFormats.formatOf("A.GZ"))
}
@Test
fun `single-file wrappers lose their own suffix`() {
assertEquals("notes.txt", ArchiveFormats.innerName("notes.txt.gz", ArchiveFormat.GZIP))
assertEquals("a", ArchiveFormats.innerName("a.xz", ArchiveFormat.XZ))
assertEquals("data.tar", ArchiveFormats.innerName("data.tar.gz", ArchiveFormat.TAR_GZIP))
assertEquals("data.tar", ArchiveFormats.innerName("data.tgz", ArchiveFormat.TAR_GZIP))
}
@Test
fun `only zip can be created`() {
assertEquals(setOf(ArchiveFormat.ZIP), ArchiveFormats.creatable)
}
}
@@ -0,0 +1,120 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
import kotlinx.coroutines.runBlocking
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class ArchiveEngineTest {
private val src = StoragePath.of("/src")
private val out = StoragePath.of("/out")
private val zip = StoragePath.of("/src/photos.zip")
private fun storage() = InMemoryStorageSource().apply {
addDirectory(src)
addDirectory(out)
addDirectory(src.child("folder"))
addFile(src.child("folder/a.txt"), "alpha")
addFile(src.child("b.txt"), "beta")
}
@Test
fun `a zip round-trips folders and files`() = runBlocking {
val storage = storage()
val engine = ArchiveEngine(storage)
val created = engine.createZip(listOf(src.child("folder"), src.child("b.txt")), zip)
assertTrue(created.succeeded)
assertTrue(storage.contains(zip))
val extracted = engine.extract(zip, out)
assertTrue(extracted.succeeded)
assertEquals("alpha", storage.contentOf(out.child("folder/a.txt")))
assertEquals("beta", storage.contentOf(out.child("b.txt")))
assertEquals(2, extracted.completed)
}
@Test
fun `createZip reports progress over the files it will write`() = runBlocking {
val storage = storage()
val seen = mutableListOf<OperationProgress>()
ArchiveEngine(storage).createZip(listOf(src.child("folder"), src.child("b.txt")), zip) { seen += it }
assertTrue(seen.isNotEmpty())
assertEquals(OperationKind.COMPRESS, seen.last().kind)
assertEquals(2, seen.last().total)
assertEquals(2, seen.last().completed)
}
@Test
fun `a zip-slip entry cannot escape the destination`() = runBlocking {
val storage = storage()
val crafted = ByteArrayOutputStream().also { bytes ->
ZipOutputStream(bytes).use { stream ->
stream.putNextEntry(ZipEntry("../evil.txt"))
stream.write("pwned".toByteArray())
stream.closeEntry()
stream.putNextEntry(ZipEntry("good.txt"))
stream.write("fine".toByteArray())
stream.closeEntry()
}
}.toByteArray()
val archive = StoragePath.of("/src/crafted.zip")
storage.putBytes(archive, crafted)
val result = ArchiveEngine(storage).extract(archive, out)
// The hostile entry is refused, and it did not land anywhere outside the folder.
assertFalse(storage.contains(StoragePath.of("/evil.txt")))
assertFalse(storage.contains(StoragePath.of("/src/evil.txt")))
assertEquals(1, result.failures.size)
// ...while the sane entry in the same archive is still extracted.
assertEquals("fine", storage.contentOf(out.child("good.txt")))
}
@Test
fun `a single-file gzip is unwrapped to its inner name`() = runBlocking {
val storage = storage()
val compressed = ByteArrayOutputStream().also { bytes ->
GzipCompressorOutputStream(bytes).use { it.write("hello gzip".toByteArray()) }
}.toByteArray()
val archive = StoragePath.of("/src/notes.txt.gz")
storage.putBytes(archive, compressed)
val result = ArchiveEngine(storage).extract(archive, out)
assertTrue(result.succeeded)
assertEquals("hello gzip", storage.contentOf(out.child("notes.txt")))
}
@Test
fun `an unsupported format is refused rather than guessed at`() = runBlocking {
val storage = storage()
val archive = StoragePath.of("/src/pack.7z")
storage.addFile(archive, "not really a 7z")
val result = ArchiveEngine(storage).extract(archive, out)
assertFalse(result.succeeded)
assertEquals(1, result.failures.size)
assertEquals(0, result.completed)
}
@Test
fun `extracting something that is not there is reported`() = runBlocking {
val result = ArchiveEngine(storage()).extract(StoragePath.of("/src/ghost.zip"), out)
assertFalse(result.succeeded)
}
}
@@ -0,0 +1,68 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class UsageAnalysisTest {
private val root = StoragePath.of("/root")
private fun storage() = InMemoryStorageSource().apply {
addDirectory(root)
addDirectory(root.child("big"))
addDirectory(root.child("small"))
addDirectory(root.child("empty"))
addFile(root.child("big/one.bin"), "x".repeat(1000))
addFile(root.child("big/two.bin"), "y".repeat(500))
addFile(root.child("small/three.bin"), "z".repeat(10))
addFile(root.child("top.bin"), "t".repeat(100))
}
@Test
fun `folder sizes are recursive and sorted largest first`() = runBlocking {
val nodes = UsageAnalysis(storage()).childrenBySize(root)
assertEquals(listOf("big", "top.bin", "small", "empty"), nodes.map { it.name })
assertEquals(1500L, nodes[0].sizeBytes)
assertEquals(2, nodes[0].fileCount)
assertTrue(nodes[0].isDirectory)
assertEquals(100L, nodes[1].sizeBytes)
assertEquals(10L, nodes[2].sizeBytes)
assertEquals(0L, nodes[3].sizeBytes)
}
@Test
fun `the largest files are found anywhere below the folder`() = runBlocking {
val files = UsageAnalysis(storage()).largestFiles(root, limit = 2)
assertEquals(listOf("one.bin", "two.bin"), files.map { it.name })
assertEquals(1000L, files.first().sizeBytes)
}
@Test
fun `the total rolls up the whole tree`() = runBlocking {
val (size, count) = UsageAnalysis(storage()).total(root)
assertEquals(1610L, size)
assertEquals(4, count)
}
@Test
fun `an empty folder measures zero`() = runBlocking {
val (size, count) = UsageAnalysis(storage()).total(root.child("empty"))
assertEquals(0L, size)
assertEquals(0, count)
assertTrue(UsageAnalysis(storage()).childrenBySize(root.child("empty")).isEmpty())
}
@Test
fun `a folder that is gone measures nothing rather than blowing up`() = runBlocking {
val (size, count) = UsageAnalysis(storage()).total(root.child("ghost"))
assertEquals(0L, size)
assertEquals(0, count)
}
}
@@ -40,7 +40,11 @@ class InMemoryStorageSource(
}
fun addFile(path: StoragePath, content: String) {
val bytes = content.toByteArray()
putBytes(path, content.toByteArray())
}
/** Seed a file from raw bytes — used to plant a crafted archive in a test. */
fun putBytes(path: StoragePath, bytes: ByteArray) {
entries[path] = FileEntry(path, isDirectory = false, sizeBytes = bytes.size.toLong(), lastModifiedMillis = 0L)
contents[path] = bytes
}