Compressing and decompressing files with ZIP on Android
Android apps regularly produce files that need to move as one unit: exported projects, offline help packs, diagnostic logs, or user-selected documents. The platform’s standard library can create and read ZIP archives without adding a large dependency, which is useful when an app must support modest hardware and several Android versions.
A ZIP file is a container, not an encryption mechanism. Each entry has a name, optional metadata, and compressed bytes, so the main design choices are which Java ZIP API to use, where an archive can legally live on current Android, and how malformed or oversized content is handled. These Kotlin patterns cover internal files, document-provider URIs, and safe restoration.
Creating a ZIP archive in Kotlin
Use ZipOutputStream when the app assembles an archive from files or directories. A buffered output stream reduces small writes, while walkTopDown() makes it easy to include an entire tree. Give every entry a relative path because an absolute source path can expose device-specific folders and break when the archive is opened elsewhere.
private fun createZip(sourceDir: File, destination: File) {
val sources = sourceDir.walkTopDown().toList()
destination.parentFile?.mkdirs()
ZipOutputStream(
BufferedOutputStream(FileOutputStream(destination))
).use { zip ->
sources.forEach { source ->
val name = source.relativeTo(sourceDir)
.invariantSeparatorsPath
if (source.isDirectory) {
if (name.isNotEmpty()) {
zip.putNextEntry(ZipEntry("$name/"))
zip.closeEntry()
}
} else {
zip.putNextEntry(ZipEntry(name))
source.inputStream().use { input ->
input.copyTo(zip)
}
zip.closeEntry()
}
}
}
}
putNextEntry() starts one member and closeEntry() finishes it. Directory entries are optional for many readers, but including them preserves empty folders in tools such as Windows Explorer. Kotlin’s use closes the stream when an exception occurs. Keep the destination outside the source tree and validate any untrusted entry names before writing them.
Extracting files without path traversal
Reading an archive requires more than calling copyTo(). A malicious entry can contain ../ segments and direct extraction outside the chosen folder. Resolve the final file with canonicalFile, compare it with the canonical extraction root, and reject anything that is not a child of that root.
val root = outputDir.canonicalFile
val target = File(root, entry.name).canonicalFile
if (!target.path.startsWith(root.path + File.separator)) {
throw SecurityException("Unsafe archive entry")
}
The destination should also be written separately so a large member is not loaded into memory at once. Add counters for entry count, individual file size, and total expanded size; otherwise a tiny archive can expand into gigabytes and exhaust storage. Delete partial output when a limit is exceeded or an I/O error interrupts the operation.
ZipInputStream processes members sequentially, which suits one-pass extraction. ZipFile provides indexed access when the archive is already a local file and several members must be read repeatedly. Neither standard API creates or opens password-protected ZIP archives, so encrypted material needs a separate, reviewed encryption design.
Using scoped storage and document pickers
Android 10 and later apply scoped storage, and an app should not assume that a shared external folder is writable. context.filesDir is private and persistent, while context.cacheDir is suitable for temporary archives. Both work without broad storage permission. Place a final export under filesDir only when it is app-owned; otherwise let the user choose a destination.
The Storage Access Framework provides OpenDocument for reading an existing archive and CreateDocument("application/zip") for writing one. Both return a content:// URI, not necessarily a File. Open it through ContentResolver.openInputStream() or openOutputStream(), retain read access when appropriate, and account for the permission’s lifetime.
File managers and some enterprise document systems may label ZIPs as application/octet-stream, so accept a reasonable MIME fallback where the product allows it. Many Australian users first move a sizeable export over Telstra or Optus mobile data; showing compressed and estimated expanded sizes helps them avoid a costly or failed transfer.
Comparing the main ZIP workflows
Choose the workflow according to how data enters and leaves the app rather than from one preferred class. The standard library covers conventional DEFLATE compression, while ZIP packaging adds independent entries and metadata.
| Workflow | Input | Output | Best suited to | Main limitation |
|---|---|---|---|---|
ZipOutputStream |
Files or directories | One archive stream | Generated exports and offline bundles | Entry names must be controlled |
ZipInputStream |
Archive stream | Extracted files | Sequential, memory-conscious restoration | No random entry access |
ZipFile |
Local archive file | Indexed entries | Searches and repeated reads | The archive must be locally accessible |
| SAF contracts | Content-provider URI | User-selected file | Importing or exporting documents | Permission and provider behaviour vary |
Compression gains depend on the payload. Text, XML, and uncompressed bitmaps usually shrink well; JPEG, PNG, MP4, and many already-compressed packages gain little. DEFLATE offers no password protection, and choosing a smaller archive can increase battery use because the receiver must spend CPU expanding it.
Practical routines for Australian Android developers
Archive creation follows a compact checklist:
- Put temporary inputs in
cacheDirand completed app-owned archives infilesDir. - Convert each member name to a relative path with forward slashes.
- Limit member count and total source bytes before writing.
- Test empty folders, nested paths, Unicode names, and binary files.
Restoration needs a different checklist:
- Resolve every canonical destination beneath the extraction root.
- Write each member to temporary storage until its size checks pass.
- Track expanded bytes and abort cleanly when a configured limit is reached.
Australian teams should test on the lower-memory Samsung and Motorola hardware common in the local Google Play market rather than restricting checks to a flagship handset. For a user in Sydney or Melbourne moving between work and home, an export should survive an interruption cleanly. For someone near Alice Springs using a mobile hotspot, progress and byte estimates matter. Friendly wording such as “no worries” can accompany an error message, but it should not hide the failure or the recovery action.
Testing and protecting archive data
Automated tests should create a small fixture, read it back, compare entry names and bytes, and attempt extraction with a traversal name such as ../../private.txt. Device tests should cover a cancelled document picker, insufficient space, a revoked URI grant, and an interrupted download. Keeping sample archives in test resources makes these cases repeatable in continuous integration.
When an archive contains personal information, combine narrow access controls with current device security guidance and Android’s own platform advice. Never treat ZIP compression as secrecy, and avoid placing credentials, access tokens, or raw Keystore material in an archive. HTTPS protects data in transit, but it does not remove the need for safe storage on the receiving device.
For confidential payloads, an organisation can encrypt the archive or its contents with an Android Keystore-backed key and a vetted cryptographic library, then define key rotation and recovery before release. Standard java.util.zip does not implement the traditional ZIP password scheme. Predictable exports and verified restores are the behaviours users trust when an archive crosses app boundaries.