diff --git a/CHANGELOG.md b/CHANGELOG.md index 1155b6e..4661039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **M5 — analysis, archives and viewers.** `domain/UsageAnalysis` rolls up recursive folder + sizes and finds the largest files below a folder, which the new storage-usage screen + shows as a size and a share bar per row. `domain/ArchiveEngine` creates ZIP archives and + extracts ZIP, TAR, TAR.GZ, GZ, BZ2 and XZ using `java.util.zip` and Commons Compress — + all pure Java, so the app still ships no native code; RAR and 7z are recognised and + refused with a clear message rather than half-supported. Every archive entry name is + passed through `core/ArchivePath` before it becomes a path, so a crafted `../` entry + cannot write outside the destination. The browser gains Coil thumbnails in the list and + grid, a built-in image viewer and text editor, and compress/extract actions. 26 new JVM + tests (141 total), including a zip-slip archive that is refused while the same archive's + safe entry still extracts. + - **M4 — the browser UI.** The app is now usable end to end. `StorageLocation` (with a real `StorageProvider` and a fake for tests) decides which backend is in effect; `BrowserViewModel` owns navigation, sorting, hidden files, list/grid, search, selection diff --git a/README.md b/README.md index 2bb8350..9e53187 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,10 @@ no advertising or tracking SDKs. ## Status -**M4 — browser UI: done.** The file manager is usable: browse volumes and folders, search, -sort, multi-select, copy/cut/paste, rename, delete, create folders, view details, open and -share — plus the "All files access" and SAF-folder permission flows. Covered by 115 JVM -unit tests (16 added here). See [docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone -plan. +**M5 — analysis, archives & viewers: done.** Recursive storage usage, ZIP creation and +ZIP/TAR/GZ/BZ2/XZ extraction (with zip-slip protection), Coil thumbnails, an image viewer +and a text editor. Covered by 141 JVM unit tests (26 added here). See +[docs/ROADMAP.md](docs/ROADMAP.md) for the full milestone plan. ## Tech stack diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchiveFormats.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchiveFormats.kt new file mode 100644 index 0000000..1b67611 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchiveFormats.kt @@ -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 = 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 +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchivePath.kt b/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchivePath.kt new file mode 100644 index 0000000..af12596 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchivePath.kt @@ -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//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() + 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("/") + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt new file mode 100644 index 0000000..26036dc --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt @@ -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, + destination: StoragePath, + onProgress: (OperationProgress) -> Unit = {}, + ): OperationResult { + val planned = LinkedHashMap() + 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, + ) { + 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() + + 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 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt index e109a19..b3578da 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/OperationModels.kt @@ -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. diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt new file mode 100644 index 0000000..e89a23e --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/UsageAnalysis.kt @@ -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 = + 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 { it.sizeBytes }.thenBy { it.name }) + + /** The largest files anywhere below [directory], biggest first. */ + suspend fun largestFiles(directory: StoragePath, limit: Int = LARGEST_LIMIT): List { + val found = ArrayList() + collectFiles(directory, found) + return found.sortedByDescending { it.sizeBytes }.take(limit) + } + + /** Total bytes and file count below [directory]. */ + suspend fun total(directory: StoragePath): Pair = measure(directory) + + private suspend fun measure(path: StoragePath): Pair { + 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) { + 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 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt index 4aed1f4..dab22ed 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/text/TextRes.kt @@ -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 } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt index b11ba55..15f06ca 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt @@ -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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index 93eb145..4456f36 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -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 -> diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt index c317eb4..cbb3501 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserTopBar.kt @@ -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) }, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index 9d6bdeb..1824b29 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -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, + 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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 29c0048..b219631 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -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) { + 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, cut: Boolean) { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt index 94f771b..3a77eb3 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt @@ -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)) } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/usage/UsageScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/usage/UsageScreen.kt new file mode 100644 index 0000000..d05b5a4 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/usage/UsageScreen.kt @@ -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, + 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), + ) + } + } + }, + ) +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ImageViewerScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ImageViewerScreen.kt new file mode 100644 index 0000000..0494d6d --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/ImageViewerScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt new file mode 100644 index 0000000..d618364 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/viewer/TextEditorScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5403f3c..5f5ef46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ Copying files Moving files Deleting files + Creating the archive + Extracting files File operations Shows progress while files are being copied, moved or deleted @@ -127,4 +129,30 @@ %1$d items could not be processed Cancelled + Saved + The file could not be saved + This archive format is not supported + + Compressed %1$d file + Compressed %1$d files + + + Extracted %1$d file + Extracted %1$d files + + + + Compress to ZIP + Extract here + Storage usage + + + Save + Close + Storage usage + Nothing to measure here + + %1$d file + %1$d files + diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/core/ArchivePathTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/core/ArchivePathTest.kt new file mode 100644 index 0000000..9a71bc7 --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/ArchivePathTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt new file mode 100644 index 0000000..f2c75fb --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt @@ -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() + + 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) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt new file mode 100644 index 0000000..e6cb86a --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/UsageAnalysisTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt index af951bd..92c77cd 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/testutil/InMemoryStorageSource.kt @@ -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 } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 287bfd5..11d8312 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,7 +14,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M2 | Storage layer | ✅ | `StorageSource` with two backends — `AllFilesSource` (`java.io.File` under all-files access) and `SafSource` (`DocumentsContract` over a persisted tree URI), the latter split so the tree↔path mapping (`SafDocumentIds`) is exhaustively testable; `SafGrant` for the picker and its persistence; `VolumeRepository` (`StorageManager`/`StorageVolume` + `StatFs`); `StorageAccess` + `StorageAccessPolicy` for the all-files/SAF decision. **76 JVM tests** total (32 added here) | | M3 | Operations engine + service | ✅ | `domain/FileOperations` composes the backend primitives into copy/move/delete/rename with recursive walking, an honest progress total, cooperative cancellation between entries and per-item failure reporting; conflicts are decided through the M1 `TransferPlanner` (`ASK` is surfaced for the UI to resolve). `service/FileOpService` is a `dataSync` foreground service mirroring progress into a notification (`FileOpNotifications` + `text/TextRes`). **99 JVM tests** total (23 added here) | | M4 | Compose UI | ✅ | `BrowserViewModel` + `BrowserUiState` over `StorageLocation`; browser screen with list/grid, breadcrumbs, in-folder search, a sort/hidden-files/view sheet, multi-select action bar, details sheet, and new-folder/rename/delete/conflict dialogs; a roots view and the "All files access" + SAF-folder prompts; `data/MediaStoreDeleter` routes a delete through the platform's own dialog where it can and falls back to a direct delete; a `FileProvider` backs "open with" and share. **115 JVM tests** total (16 added here) | -| M5 | Analysis, archives, viewers | ⏳ | disk-usage analysis, large-file finder, ZIP/TAR/GZ via Commons Compress, image viewer, text editor | +| M5 | Analysis, archives, viewers | ✅ | `domain/UsageAnalysis` measures recursive folder sizes and finds the largest files, shown by a bar-per-row storage-usage screen; `domain/ArchiveEngine` creates ZIP and extracts ZIP/TAR/TAR.GZ/GZ/BZ2/XZ using `java.util.zip` + Commons Compress (no native code), with **every entry name filtered through `core/ArchivePath` against zip-slip**; a Coil-backed image viewer, thumbnails in the list and grid, a text editor, and compress/extract in the selection bar. **141 JVM tests** total (26 added here) | | M6 | Release config & Play readiness | ⏳ | R8/minify, optional signing, `.aab`; [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) (incl. the **Permissions Declaration Form**), [PRIVACY.md](../PRIVACY.md), README | | M7 | On-device validation & hardening | ⏳ | AVD + real-device checks, per-operation error boundaries, edge-case fixtures | | M8 | Localisation groundwork | ⏳ | every user-visible string in `res/values/strings.xml` with real ``; `text/` mapping |