M5: storage usage, ZIP/TAR/GZ archives with zip-slip guard, thumbnails, viewers + 26 tests
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user