diff --git a/CHANGELOG.md b/CHANGELOG.md index ebfb788..e06dc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ upload needs a **higher `versionCode`** than every previous upload. ### Fixed +- **`.xz` extraction would have crashed on a real device.** Commons Compress declares its + LZMA/LZMA2 support — the `.xz` compressor and the 7z format — against `org.tukaani:xz` as + an **optional** dependency. Optional means it is not pulled in transitively, so nothing + warned at build time, and the app went on advertising `.xz` as extractable. The first user + to open one would have got `NoClassDefFoundError: org/tukaani/xz/FilterOptions`. The + library is now declared, and a round-trip test covers the path that was silently missing. + - **The storage-usage screen stayed blank for ~45–60 s** on a real 55 GB volume, which reads as a hang even though the work was off the main thread. `UsageAnalysis` now passes the `FileEntry` down the recursion instead of re-`stat`ing every node — the parent's @@ -31,6 +38,19 @@ upload needs a **higher `versionCode`** than every previous upload. ### Added +- **7z extraction.** Commons Compress already contained a pure-Java 7z reader + (`SevenZFile`), so this needed no new native code and no library beyond the XZ one the + `.xz` fix above required — the docs had simply assumed the format was off-limits and said + so in two places. The one real constraint is that 7z is the only format here that cannot + arrive through a stream: its header sits at the end of the file, so the reader has to + seek. That is now a capability the backend can advertise, `StorageSource.openSeekable`: + the all-files backend answers with a real `FileChannel`, and the SAF fallback answers + `null`, so a 7z in folder mode is refused with "needs a seekable file, which this storage + mode cannot provide" rather than failing obscurely. Entry names go through the same + `ArchivePath.safeRelativePath` zip-slip guard as every other format, with a test that + plants `../evil.txt` in a real 7z and asserts it lands nowhere. 7z *creation* stays + deferred, so ZIP is still the only writable format. + - **A two-pane layout for large screens.** A window at or above Material's "expanded" breakpoint — 840 dp, which a 10-inch tablet in landscape at 1280 dp clears — now splits into a master pane and a detail pane. Tapping a row highlights it, and the second pane diff --git a/README.md b/README.md index e4358f3..ba50e02 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ holds length-checked listing copy. The screenshots are captured: 5 phone (1080× | UI | Jetpack Compose, BOM 2026.06.01, Material 3 | | Build | Gradle 9.3.1, Android Gradle Plugin 9.1.0 | | Storage access | `MANAGE_EXTERNAL_STORAGE` ("All files access") with a Storage Access Framework fallback | -| Archives | Apache Commons Compress (pure Java — no NDK) | +| Archives | Apache Commons Compress + XZ for Java (both pure Java — no NDK) | | SDK levels | `compileSdk`/`targetSdk` 36, `minSdk` 26 | `targetSdk` is 36 because Google Play requires new apps and updates to target @@ -141,10 +141,13 @@ UI.** Translated UI text has to be supplied in `values-/`. - **"Open with" and Share only work in the all-files mode.** A SAF path is relative to the granted tree rather than a filesystem path, so it cannot be handed to `FileProvider`. -- **RAR and 7z are refused**, not supported. The stated reason is only half right: RAR - genuinely needs native code (the Commons Compress jar this app ships contains no RAR - classes at all), but **7z does not** — `SevenZFile`/`SevenZOutputFile` are already inside - that same jar, so 7z read, and AES-256-encrypted 7z write, is an unpack away. +- **RAR, CAB, ISO and WIM are refused**, not supported — they need native code, which this + project deliberately does not ship. (The Commons Compress jar contains no RAR classes at + all.) **7z is different**: Commons Compress reads it in pure Java, so it *is* supported — + but the format needs a seekable channel rather than a stream, because its header lives at + the end of the file. All-files access can supply one; the SAF fallback cannot, so 7z + extraction reports that plainly in folder mode instead of half-working. +- **7z files can be opened but not created** — writing stays ZIP-only. - **Archive extraction does not restore original timestamps or permissions**, and there is no archive encryption or password support. - **Cancellation happens between entries**, so a single very large file finishes before a diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 099c1f6..ed262c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,9 @@ dependencies { // ZIP/TAR/GZ archive support. Pure Java — no native code, no NDK. implementation(libs.commons.compress) + // Commons Compress's JVM-only dependencies for LZMA/LZMA2: `.xz` and 7z. Optional + // upstream, so it has to be declared here or those formats fail at runtime. + implementation(libs.xz) debugImplementation(libs.androidx.compose.ui.tooling) 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 index 1b67611..1a3afe8 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchiveFormats.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/core/ArchiveFormats.kt @@ -3,10 +3,16 @@ 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. + * The list is deliberately honest about what the pure-Java libraries underneath can + * actually do. ZIP, TAR, the compression wrappers and 7z all come from `java.util.zip` + * and Commons Compress; RAR, CAB, ISO and WIM are recognised so the UI can say they are + * not supported instead of failing with a confusing error. + * + * 7z is worth calling out, because it does **not** need native code — Commons Compress + * ships a pure-Java 7z reader, `SevenZFile`. What it does need is **seekable** input: + * unlike ZIP, a 7z header sits at the *end* of the file and the reader has to seek to it. + * So 7z is only extractable on a backend that can supply a channel, and the engine says + * so plainly when one cannot rather than failing obscurely. */ enum class ArchiveFormat { ZIP, @@ -15,8 +21,9 @@ enum class ArchiveFormat { GZIP, BZIP2, XZ, + SEVEN_ZIP, - /** Recognised but not supported (needs native code). */ + /** Recognised but not supported — these really do need native code. */ UNSUPPORTED, /** Not an archive name we know. */ @@ -29,7 +36,8 @@ object ArchiveFormats { val creatable: Set = setOf(ArchiveFormat.ZIP) private val ZIP_NAMES = setOf("zip", "jar", "cbz") - private val UNSUPPORTED_NAMES = setOf("7z", "rar", "cab", "iso", "wim") + private val SEVEN_ZIP_NAMES = setOf("7z") + private val UNSUPPORTED_NAMES = setOf("rar", "cab", "iso", "wim") fun formatOf(fileName: String): ArchiveFormat { val lower = fileName.lowercase() @@ -43,6 +51,7 @@ object ArchiveFormats { val extension = FileKind.extensionOf(lower) when { extension in ZIP_NAMES -> ArchiveFormat.ZIP + extension in SEVEN_ZIP_NAMES -> ArchiveFormat.SEVEN_ZIP extension in UNSUPPORTED_NAMES -> ArchiveFormat.UNSUPPORTED else -> ArchiveFormat.UNKNOWN } diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt index 6265d00..518c8c8 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/AllFilesSource.kt @@ -10,6 +10,9 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStream +import java.nio.channels.FileChannel +import java.nio.channels.SeekableByteChannel +import java.nio.file.StandardOpenOption /** * The "All files access" backend: plain `java.io.File` over absolute paths. @@ -76,6 +79,17 @@ class AllFilesSource( FileInputStream(File(path.value)) } + /** + * A real `FileChannel`, because this backend works on actual filesystem paths. + * + * This is what makes 7z extractable in all-files mode, and what the SAF fallback + * cannot offer. + */ + override suspend fun openSeekable(path: StoragePath): SeekableByteChannel? = withContext(Dispatchers.IO) { + val file = File(path.value) + if (!file.isFile) null else FileChannel.open(file.toPath(), StandardOpenOption.READ) + } + @SuppressLint("Recycle") override suspend fun openWrite(path: StoragePath, append: Boolean): OutputStream = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt index e1345ef..75f9ac5 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/data/StorageSource.kt @@ -4,6 +4,7 @@ import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath import java.io.InputStream import java.io.OutputStream +import java.nio.channels.SeekableByteChannel /** A backend operation failed for a reason worth surfacing to the user. */ class StorageException(message: String, cause: Throwable? = null) : Exception(message, cause) @@ -57,6 +58,18 @@ interface StorageSource { /** Open [path] for reading. The caller closes the stream. */ suspend fun openRead(path: StoragePath): InputStream + /** + * Open [path] as a random-access channel, or `null` when this backend cannot. + * + * Only 7z needs this. Unlike ZIP, a 7z header sits at the *end* of the file, so it + * cannot be read from a forward-only stream — the reader has to seek. [AllFilesSource] + * hands out a real `FileChannel`; [SafSource] returns `null`, because a document URI + * has no seekable representation. The default here is that honest `null`, so a new + * backend without seekable support degrades to "7z is not extractable here" rather + * than to a corrupt read. + */ + suspend fun openSeekable(path: StoragePath): SeekableByteChannel? = null + /** Open [path] for writing, optionally appending. The caller closes the stream. */ suspend fun openWrite(path: StoragePath, append: Boolean = false): OutputStream 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 index 26036dc..5238465 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/ArchiveEngine.kt @@ -8,6 +8,7 @@ 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.sevenz.SevenZFile import org.apache.commons.compress.archivers.tar.TarArchiveInputStream import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream @@ -16,6 +17,7 @@ import org.apache.commons.compress.compressors.xz.XZCompressorInputStream import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.InputStream +import java.nio.channels.SeekableByteChannel import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.coroutines.cancellation.CancellationException @@ -24,8 +26,10 @@ 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. + * reading — so the app ships no native code and all of this is testable on the JVM. RAR is + * recognised but refused (see [ArchiveFormats]) rather than half-supported. 7z *is* read, + * and needs no native code either, but it is the one format that cannot arrive through a + * stream: its header lives at the end of the file, so it needs a seekable channel. * * The security-critical part is that every entry name goes through * [ArchivePath.safeRelativePath] before it becomes a path: an archive must never be able @@ -105,41 +109,58 @@ class ArchiveEngine(private val source: StorageSource) { 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) + // 7z is the one format that cannot come in through a stream, so it is handled + // before the stream is even opened. Only a backend with real filesystem paths + // can hand out a channel; the SAF fallback cannot, and says so plainly rather + // than failing with something obscure. + if (format == ArchiveFormat.SEVEN_ZIP) { + val channel = source.openSeekable(archive) + if (channel == null) { + state.failure( + archive, + "7z needs a seekable file, which this storage mode cannot provide", + ) + } else { + readSevenZip(channel, destination, state) + } + } else { + source.openRead(archive).use { raw -> + BufferedInputStream(raw).use { input -> + when (format) { + ArchiveFormat.ZIP -> readArchive(ZipArchiveInputStream(input), destination, state) - ArchiveFormat.TAR_GZIP -> readArchive( - TarArchiveInputStream(GzipCompressorInputStream(input)), - destination, - state, - ) + ArchiveFormat.TAR -> readArchive(TarArchiveInputStream(input), destination, state) - ArchiveFormat.GZIP -> readSingle( - GzipCompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) + ArchiveFormat.TAR_GZIP -> readArchive( + TarArchiveInputStream(GzipCompressorInputStream(input)), + destination, + state, + ) - ArchiveFormat.BZIP2 -> readSingle( - BZip2CompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) + ArchiveFormat.GZIP -> readSingle( + GzipCompressorInputStream(input), + ArchiveFormats.innerName(archive.name, format), + destination, + state, + ) - ArchiveFormat.XZ -> readSingle( - XZCompressorInputStream(input), - ArchiveFormats.innerName(archive.name, format), - destination, - state, - ) + ArchiveFormat.BZIP2 -> readSingle( + BZip2CompressorInputStream(input), + ArchiveFormats.innerName(archive.name, format), + destination, + state, + ) - else -> state.failure(archive, "This archive format is not supported") + ArchiveFormat.XZ -> readSingle( + XZCompressorInputStream(input), + ArchiveFormats.innerName(archive.name, format), + destination, + state, + ) + + else -> state.failure(archive, "This archive format is not supported") + } } } } @@ -182,6 +203,60 @@ class ArchiveEngine(private val source: StorageSource) { } } + /** + * Read a 7z container from [channel]. + * + * 7z entries are not exposed as an [ArchiveInputStream]: the format decompresses in + * solid blocks that can span entry boundaries, so the entry loop drives [SevenZFile] + * directly and reads each entry's bytes from whatever the current one is. + * + * Every name still goes through [ArchivePath.safeRelativePath] before it becomes a + * path, exactly as the streaming readers do — a 7z entry name is no more trustworthy + * than a ZIP one, and this is the same zip-slip defence. + */ + private suspend fun readSevenZip( + channel: SeekableByteChannel, + destination: StoragePath, + state: ArchiveState, + ) { + // Both are closed deliberately. SevenZFile does close a channel it was handed, but + // a second close on a FileChannel is a documented no-op, so relying on both means + // the channel cannot leak whichever way that behaves. + channel.use { seekable -> + SevenZFile.builder().setSeekableByteChannel(seekable).get().use { seven -> + val buffer = ByteArray(BUFFER_BYTES) + while (currentCoroutineContext().isActive) { + val entry = seven.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 -> + while (true) { + val read = seven.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + } + } + state.advance(target) + } + } + if (!currentCoroutineContext().isActive) state.cancel() + } + } + } + private suspend fun readSingle( decompressed: InputStream, name: String, 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 index 9a71bc7..9f0be91 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/core/ArchivePathTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/core/ArchivePathTest.kt @@ -80,12 +80,20 @@ class ArchiveFormatsTest { assertEquals(ArchiveFormat.XZ, ArchiveFormats.formatOf("a.xz")) } + @Test + fun `detects 7z, which is readable without native code`() { + assertEquals(ArchiveFormat.SEVEN_ZIP, ArchiveFormats.formatOf("pack.7z")) + assertTrue(ArchiveFormats.isExtractable("pack.7z")) + assertTrue(ArchiveFormats.isRecognised("pack.7z")) + } + @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")) + assertEquals(ArchiveFormat.UNSUPPORTED, ArchiveFormats.formatOf("disc.iso")) + assertEquals(ArchiveFormat.UNSUPPORTED, ArchiveFormats.formatOf("setup.cab")) assertFalse(ArchiveFormats.isExtractable("pack.rar")) - assertFalse(ArchiveFormats.isExtractable("pack.7z")) + assertFalse(ArchiveFormats.isExtractable("disc.iso")) assertTrue(ArchiveFormats.isRecognised("pack.rar")) } @@ -101,6 +109,7 @@ class ArchiveFormatsTest { assertEquals(ArchiveFormat.ZIP, ArchiveFormats.formatOf("A.ZIP")) assertEquals(ArchiveFormat.TAR_GZIP, ArchiveFormats.formatOf("A.TGZ")) assertEquals(ArchiveFormat.GZIP, ArchiveFormats.formatOf("A.GZ")) + assertEquals(ArchiveFormat.SEVEN_ZIP, ArchiveFormats.formatOf("A.7Z")) } @Test 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 index f2c75fb..53455af 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/ArchiveEngineTest.kt @@ -3,7 +3,11 @@ 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.archivers.sevenz.SevenZArchiveEntry +import org.apache.commons.compress.archivers.sevenz.SevenZOutputFile import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -99,11 +103,33 @@ class ArchiveEngineTest { assertEquals("hello gzip", storage.contentOf(out.child("notes.txt"))) } + /** + * LZMA — and therefore `.xz` and 7z — needs Commons Compress's *optional* `xz` + * library, which is not pulled in transitively. Without it declared, this fails with + * `NoClassDefFoundError: org/tukaani/xz/FilterOptions` at runtime, on a device, with + * nothing warning at build time. This test is what notices. + */ + @Test + fun `a single-file xz is unwrapped, which needs the optional xz library`() = runBlocking { + val storage = storage() + val compressed = ByteArrayOutputStream().also { bytes -> + XZCompressorOutputStream(bytes).use { it.write("hello xz".toByteArray()) } + }.toByteArray() + + val archive = StoragePath.of("/src/notes.txt.xz") + storage.putBytes(archive, compressed) + + val result = ArchiveEngine(storage).extract(archive, out) + + assertTrue(result.succeeded) + assertEquals("hello xz", 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 archive = StoragePath.of("/src/pack.rar") + storage.addFile(archive, "not really a rar") val result = ArchiveEngine(storage).extract(archive, out) @@ -112,9 +138,81 @@ class ArchiveEngineTest { assertEquals(0, result.completed) } + @Test + fun `a 7z round-trips its files`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/photos.7z") + storage.putBytes(archive, sevenZip("b.txt" to "beta", "folder/a.txt" to "alpha")) + + val result = ArchiveEngine(storage).extract(archive, out) + + assertTrue(result.succeeded) + assertEquals("alpha", storage.contentOf(out.child("folder/a.txt"))) + assertEquals("beta", storage.contentOf(out.child("b.txt"))) + assertEquals(2, result.completed) + } + + @Test + fun `a 7z-slip entry cannot escape the destination`() = runBlocking { + val storage = storage() + val archive = StoragePath.of("/src/crafted.7z") + storage.putBytes(archive, sevenZip("../evil.txt" to "pwned", "good.txt" to "fine")) + + 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 `7z is refused with a readable reason when the backend cannot seek`() = runBlocking { + // This is the shape of the SAF fallback, which has no seekable channel to offer. + val storage = storage().apply { seekableSupported = false } + val archive = StoragePath.of("/src/photos.7z") + storage.putBytes(archive, sevenZip("b.txt" to "beta")) + + val result = ArchiveEngine(storage).extract(archive, out) + + assertFalse(result.succeeded) + assertEquals(1, result.failures.size) + assertTrue(result.failures.single().reason.contains("seekable")) + } + @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) } + + /** + * A genuine 7z, built in memory rather than hand-written as bytes. + * + * Writing the fixture with the same library that reads it means it cannot drift from + * the format the way a byte literal would. + */ + private fun sevenZip(vararg entries: Pair): ByteArray = + SeekableInMemoryByteChannel().let { channel -> + SevenZOutputFile(channel).use { archive -> + for ((name, content) in entries) { + val body = content.toByteArray() + val entry = SevenZArchiveEntry().apply { + this.name = name + isDirectory = false + size = body.size.toLong() + } + archive.putArchiveEntry(entry) + archive.write(body) + archive.closeArchiveEntry() + } + archive.finish() + // Read inside the `use`: SevenZOutputFile closes the channel it was handed, + // and finish() has already written the header the reader looks for. + channel.array().copyOf(channel.size().toInt()) + } + } } 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 92c77cd..ca3ca1f 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 @@ -4,10 +4,12 @@ import de.softwareapp_hb.filemanager.core.FileEntry import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.data.StorageException import de.softwareapp_hb.filemanager.data.StorageSource +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.OutputStream +import java.nio.channels.SeekableByteChannel /** * A complete [StorageSource] backed by maps, for exercising the operations engine @@ -32,6 +34,9 @@ class InMemoryStorageSource( /** When `false`, [rename] refuses, the way the SAF backend does across directories. */ var renameSupported: Boolean = true + /** When `false`, [openSeekable] returns `null`, the way the SAF backend always does. */ + var seekableSupported: Boolean = true + /** Paths whose [openRead] throws, to exercise the per-item failure path. */ val unreadable = mutableSetOf() @@ -100,6 +105,18 @@ class InMemoryStorageSource( return ByteArrayInputStream(bytes) } + /** + * Seekable access over the stored bytes, which is what 7z extraction needs. + * + * Every archive here already lives in memory, so seeking is free — which is why this + * fake can exercise 7z end to end while the real SAF backend cannot. + */ + override suspend fun openSeekable(path: StoragePath): SeekableByteChannel? { + if (!seekableSupported) return null + val bytes = contents[path] ?: return null + return SeekableInMemoryByteChannel(bytes) + } + /** * Writes are committed on [OutputStream.close], which is how the engine uses it * (`openWrite(...).use { ... }`), so a partial write never looks like a complete one. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d0cbab..50f96f6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,12 +14,12 @@ 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 | ✅ | `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) | +| 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/7z using `java.util.zip` + Commons Compress + XZ for Java (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 | ✅ | Upload keystore generated (4096-bit RSA, `CN=Hadrian Burkhardt`) and wired through the git-ignored `keystore.properties`; `bundleRelease` + `assembleRelease` verified — **4 682 814-byte `.aab`** (`sha256 9014627a…`), 2 081 955-byte APK signed by the upload key, R8 mapping kept. [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) (incl. the **Permissions Declaration Form** for "All files access", the Data safety answers and the shipped-permission audit) and [PRIVACY.md](../PRIVACY.md) written for a restricted-permission app. Shipped permission set audited: **no `INTERNET`** | | M7 | On-device validation & hardening | ✅ | Run on a real **Xiaomi M2101K7AG** (Android 12 / MIUI V140) — see [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md). Verified on hardware: the first-run permission prompt, granting "All files access" while the app is alive (picked up on resume), browsing real storage, the text editor's read **and** write path (independently checked with `adb shell cat`), the storage-usage screen, landscape layout, and **graceful degradation when access is revoked mid-use**. Found and fixed two real defects: misleading first-run copy ("Full access was declined" on a device where nothing had been declined), and a usage screen that stayed blank for **~45–60 s** on a 55 GB volume — now one listing per directory instead of a `stat` per node, with rows streamed out as they are measured. **142 JVM tests** | | M8 | Localisation groundwork | ✅ | Every user-visible string is in `res/values/strings.xml` with real ``, and `text/TextRes.kt` maps **both** `OperationKind` and `FileKind` onto resources so the screen, the notification and the details sheet cannot drift. The audit found a genuine miss: the details sheet rendered `FileKind.name`, i.e. untranslatable English — now a resource. `isPseudoLocalesEnabled = true` on the debug build makes "did every string come from resources?" mechanically checkable; verified `en-rXA` is present in the debug APK and the flag is debug-only. Byte units and the timestamp pattern are deliberately unlocalised, and the README says why. **142 JVM tests** | | M9 | Monetisation | ✅ | **Decided: one-time paid — €4.49 base with a 14-day launch price of €2.29**, both set in the Play Console (*Products › App pricing*), so there is **no Billing client, no `com.android.vending.BILLING` permission and no purchase code**. Rationale, market anchors and Console steps: [MONETISATION](MONETISATION.md). Verified against the release APK: zero billing and zero `INTERNET` references. **The price must be set before the first upload** — Play allows paid → free but never free → paid | -| M10 | Store assets | ✅ | Name decided: **Plain Files** (one string; `applicationId` already locked). `tools/make-store-assets.sh` renders the 512×512 **RGBA** icon and the 1024×500 **RGB** feature graphic from the app's own vectors, so the store icon *is* the launcher icon; `tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). [STORE_LISTING](STORE_LISTING.md) carries length-checked copy (name 11/30, short 67/80, full 1881/4000), the permissions paragraph the `MANAGE_EXTERNAL_STORAGE` declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. **Screenshots captured:** 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing | +| M10 | Store assets | ✅ | Name decided: **Plain Files** (one string; `applicationId` already locked). `tools/make-store-assets.sh` renders the 512×512 **RGBA** icon and the 1024×500 **RGB** feature graphic from the app's own vectors, so the store icon *is* the launcher icon; `tools/make-store-screenshots.sh` normalises `adb` captures to Play's 2:1 cap per form factor (phone crop default 93/130 px, tablets 0). [STORE_LISTING](STORE_LISTING.md) carries length-checked copy (name 11/30, short 67/80, full 2192/4000), the permissions paragraph the `MANAGE_EXTERNAL_STORAGE` declaration requires, and the graphics table. The feature graphic's copy was verified to survive Play's centre-square crop by cropping the rendered PNG. **Screenshots captured:** 5 phone (1080×2160), 4 on a 7-inch tablet (1200×1920) and 4 on a 10-inch tablet (2560×1600), all 24-bit RGB, inside the 2:1 cap. The tablet sets came from AOSP AVDs. The normaliser refuses a capture whose app area is blank, which is how a pure-background capture was caught before it reached the listing | ## Key decisions diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 9192407..094ca40 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -91,6 +91,12 @@ BROWSE ANYTHING • Breadcrumb navigation and an in-folder search • Hidden files on request +ANY SIZE OF SCREEN +• On a tablet or a wide window you get two panes: the folder list on the left, and what is + inside the folder you pick on the right — no jumping back and forth +• Pictures preview at full size beside the list, with their details +• The same layout on a phone, a 7-inch tablet and a 10-inch tablet + MANAGE YOUR FILES • Copy, move, rename and delete, one item or many at once • Create folders and edit text files in the built-in editor @@ -102,9 +108,9 @@ FIND OUT WHERE THE SPACE WENT • Spot the folder that is actually eating your storage, then act on it ARCHIVES -• Create ZIP archives, and extract ZIP, TAR, TAR.GZ, GZ, BZ2 and XZ -• RAR and 7z are not supported — they need native code this app does not ship, and it says - so rather than failing with a strange error +• Create ZIP archives, and extract ZIP, TAR, TAR.GZ, GZ, BZ2, XZ and 7z +• RAR is not supported — it needs native code this app does not ship, and it says so rather + than failing with a strange error PRIVATE BY DEFAULT • No INTERNET permission. The app cannot send your data anywhere, because it has no way to. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9017282..d65750e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,12 @@ coroutines = "1.9.0" # Archives. Commons Compress is pure Java (zip/tar/gzip/bzip2/xz, 7z read), # so it adds no native code and therefore no NDK dependency to the build. commonsCompress = "1.27.1" +# Commons Compress declares its LZMA/LZMA2 support — the `.xz` compressor and the 7z +# format — against this library, as an *optional* dependency. Optional means it is NOT +# pulled in transitively, so leaving it out breaks `.xz` and `.7z` at **runtime** with +# NoClassDefFoundError rather than failing the build. 1.10 is the version Commons +# Compress 1.27.1 itself compiles against. +xz = "1.10" # Testing junit = "4.13.2" @@ -51,6 +57,8 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress" } +# Commons Compress's LZMA/LZMA2 support: the `.xz` compressor and the 7z format. +xz = { group = "org.tukaani", name = "xz", version.ref = "xz" } junit = { group = "junit", name = "junit", version.ref = "junit" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }