Support 7z extraction, and fix .xz extraction which would have crashed at runtime
Commons Compress declares its LZMA/LZMA2 support against org.tukaani:xz as an *optional* dependency, so nothing pulled it in transitively and nothing failed at build time. The app nevertheless advertised .xz as extractable, so the first user to open one would have got NoClassDefFoundError: org/tukaani/xz/FilterOptions. XZCompressorInputStream references org/tukaani/xz/XZInputStream directly. The library is now declared, and a round-trip test covers the path that was silently missing. That same library is what 7z needs, and Commons Compress already ships a pure-Java 7z reader, so 7z extraction costs no native code and no further dependency. The docs had assumed the format was off limits and said so in three places, including the customer-facing store copy. 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 advertises, StorageSource.openSeekable: the all-files backend answers with a real FileChannel, the SAF fallback with null, so folder mode reports needs-a-seekable-file instead of failing obscurely. Entry names go through the same ArchivePath.safeRelativePath zip-slip guard. 7z creation stays deferred.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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-<locale>/`.
|
||||
|
||||
- **"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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<ArchiveFormat> = 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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,6 +109,22 @@ class ArchiveEngine(private val source: StorageSource) {
|
||||
|
||||
try {
|
||||
source.createDirectory(destination)
|
||||
|
||||
// 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) {
|
||||
@@ -143,6 +163,7 @@ class ArchiveEngine(private val source: StorageSource) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, String>): 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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<StoragePath>()
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-2
@@ -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 `<plurals>`, 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user