Versioned save files, assets, migrations and explicit storage guarantees for Kotlin applications.
If your application saves a document — a notebook, a diagram, a project file — you eventually need all of this: a container that holds structured data and binary assets, integrity checks, a way to open last year's file, and a save that does not destroy the previous version when it fails. DocumentKit is that layer, extracted from a real editor and made general.
The library and its format are implemented and covered by tests on Linux, Windows and macOS, with an independent consumer build per platform proving the published artifacts resolve.
documentkit-androidis verified by an Android consumer build and tested under Robolectric at API 24 and 36 — real framework code on the JVM, not a device. Anything not documented below is not built; see Roadmap.
Which module? Depend on one; the others come with it.
| Depend on | When |
|---|---|
documentkit-io |
Desktop or server JVM. Brings in documentkit-core. |
documentkit-android |
Android. Brings in both of the above. |
documentkit-core |
Only if you are writing your own I/O layer and want the format types, codec and migrations alone. |
settings.gradle.kts:
dependencyResolutionManagement {
repositories {
mavenCentral()
google() // only if you are building for Android
}
}build.gradle.kts:
plugins {
kotlin("jvm") version "2.2.0"
// Required. Your document model is @Serializable, and a library cannot
// supply a compiler plugin on your behalf.
kotlin("plugin.serialization") version "2.2.0"
}
dependencies {
implementation("io.github.masterplaycoding.documentkit:documentkit-io:0.5.1")
}You do not need to declare kotlinx-coroutines or
kotlinx-serialization-json yourself — both arrive transitively, and
consumer-check/ is a separate build that fails if they ever
stop doing so.
Requirements: JDK 17, Kotlin 2.2.0, Android API 24+ (compiled against SDK 36).
Every I/O entry point is a
suspendfunction and runs onDispatchers.IO. The snippets below userunBlockingto stay short; in an application, call them from whatever scope you already have.
import io.github.masterplaycoding.documentkit.AssetId
import io.github.masterplaycoding.documentkit.DocumentCodec
import io.github.masterplaycoding.documentkit.io.AssetSource
import io.github.masterplaycoding.documentkit.io.DocumentStore
import io.github.masterplaycoding.documentkit.io.SaveReceipt
import java.io.File
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
@Serializable
data class Notebook(val title: String, val notes: List<Note> = emptyList())
@Serializable
data class Note(val text: String, val imageAssetId: String? = null)
val codec = DocumentCodec(
applicationId = "example.notebook",
schemaVersion = 1,
serializer = Notebook.serializer(),
referencedAssets = { notebook ->
notebook.notes.mapNotNull { note -> note.imageAssetId?.let(AssetId::of) }.toSet()
},
validate = { notebook ->
if (notebook.title.isBlank()) "a notebook needs a title" else null
},
)
fun main() = runBlocking {
val store = DocumentStore()
val directory = File(System.getProperty("user.home"), "Documents")
val coverImage = File(directory, "cover.png")
val notebook = Notebook(
title = "Field notes",
notes = listOf(Note("first"), Note("with a picture", imageAssetId = "cover")),
)
val receipt = store.save(
destination = File(directory, "field-notes.dkit"),
document = notebook,
documentId = "9c1f-4a2e-…", // yours; stable across saves
codec = codec,
assets = mapOf(AssetId.of("cover") to AssetSource.ofFile(coverImage)),
)
check(receipt is SaveReceipt.AtomicReplace)
}val documentFile = File(directory, "field-notes.dkit")
store.open(documentFile, codec).use { opened ->
println(opened.document.title)
println(opened.migrationsApplied) // e.g. ["notes-become-objects"]
opened.copyAssetTo(AssetId.of("cover"), File(directory, "extracted-cover.png"))
}use { } matters: the handle owns the open archive, and on Android any staging
copy, until it is closed.
Add a migration per schema version you have ever shipped, and raise
schemaVersion to match:
import io.github.masterplaycoding.documentkit.documentMigration
import kotlinx.serialization.json.*
// Version 1 stored notes as bare strings; version 2 stores objects, so a note
// can carry an image.
val notesBecomeObjects = documentMigration("notes-become-objects", fromVersion = 1) { document ->
buildJsonObject {
put("title", document["title"] ?: JsonPrimitive("Untitled"))
putJsonArray("notes") {
document["notes"]?.jsonArray?.forEach { note ->
add(buildJsonObject { put("text", note.jsonPrimitive.content) })
}
}
}
}
val codec = DocumentCodec(
applicationId = "example.notebook",
schemaVersion = 2,
serializer = Notebook.serializer(),
migrations = listOf(notesBecomeObjects),
)Opening a version-1 file migrates it in memory. The file on disk is not rewritten — upgrading a user's document because they looked at it is not a decision a library gets to make. Saving is what writes.
import io.github.masterplaycoding.documentkit.DocumentError
import io.github.masterplaycoding.documentkit.DocumentException
val message = try {
store.open(suspiciousFile, codec).use { opened -> opened.document.title }
} catch (failure: DocumentException) {
when (val error = failure.error) {
is DocumentError.IntegrityMismatch -> "'${error.entry}' does not match its digest"
is DocumentError.UnsupportedSchema -> "written by a newer version of this app"
is DocumentError.MissingMigration -> "too old for this build to read"
else -> error.detail
}
}Errors are structured and name the entry or migration step involved. Nothing DocumentKit writes carries document contents — an error message ends up in a log or a bug report, and the user's document is theirs. That is enforced by tests that plant a sentinel value in a document and assert it appears in no error, message or stack trace.
The one thing passed through unchanged is a migration's own exception
message. If your migration throws error("cannot convert note '$title'"),
that title reaches MigrationFailed.reason verbatim, because the text is
yours, about your document, and replacing it with a placeholder would discard
the only detail saying why the step failed. It is your call what goes in it —
and worth remembering before pasting a migration failure into a public issue.
import io.github.masterplaycoding.documentkit.android.DocumentTransfer
val transfer = DocumentTransfer(context)
// A Uri you already obtained from the Storage Access Framework. This library
// does not request permissions or launch pickers — those are your UI.
transfer.import(uri, codec).use { opened -> render(opened.document) }
val receipt = transfer.exportCopy(uri, notebook, documentId, codec, assets)Export returns ProviderManagedExport, not AtomicReplace, and the difference
is real — see the write-up.
./gradlew :documentkit-cli:installDistThat puts a launcher script under the build directory; it is not on your PATH:
documentkit-cli/build/install/documentkit/bin/documentkit inspect field-notes.dkitfield-notes.dkit
container version 1
application example.notebook
schema version 1
document id consumer-check-1
archive size 1 KiB
entries 3
document.json 183 B (declared)
assets 1, 8 KiB declared
cover 8 KiB
Sizes above are declared, not verified. Run `validate` to check them.
validate streams every entry and checks actual lengths and SHA-256 digests:
damaged.dkit — example.notebook schema 1
✓ document.json
✗ IntegrityMismatch: entry 'assets/cover' expected 8192 bytes but found 8318 bytes
invalid — 1 problem
checked: container structure and integrity; application schema not checked
Exit codes are 0 valid, 1 invalid document, 2 bad invocation — kept
distinct because a build that cannot tell "your document is corrupt" from "you
typed the wrong flag" teaches people to ignore both. --json gives CI a
machine-readable form.
Both commands work on a container belonging to an application this build knows nothing about, and neither claims more than it checked: without your codec, the CLI verifies the container and says so in as many words.
It is safe to point at a file someone sent you. Ids, media types and entry names
come from the file, so control, invisible-formatting and separator characters
in them are printed as \uXXXX escapes rather than passed to your terminal,
where an escape sequence could rewrite what the output appears to say. The same
characters are escaped in --json, which decodes to identical strings.
| What DocumentKit guarantees | Where that stops |
|---|---|
A local save replaces the destination atomically, or fails with AtomicReplaceUnsupported. There is no silent fallback to a copy. |
Atomic visibility on that filesystem. Not power-loss durability, not directory metadata (Java has no portable directory fsync), and nothing about network filesystems. |
| An interrupted save leaves the previous document byte-for-byte unchanged. | Until the atomic move. After it commits, the save succeeded, even if cancellation arrived during the commit. |
| Opening verifies every declared byte against its length and SHA-256 before returning. | Digests detect corruption and mismatched content. They do not authenticate an author: whoever rewrites content can rewrite the manifest. |
| No error DocumentKit writes contains document content - enforced by tests that plant a sentinel value and assert it reaches no message or stack trace. | A migration you wrote is passed through verbatim, content and all. That text is yours; what goes in it is your decision, not the library's. |
A damaged or hostile container is rejected with a named DocumentException — never another exception, a hang, or an allocation the file's own headers control. Checked by a mutation fuzzer: 1,500 inputs on every build, 200,000 on a fresh seed every week, and every input that ever found a bug replayed as a regression. |
A fuzzer finds what its mutations reach. It is not coverage-guided, and a clean sweep is evidence, not proof. The Lantr importer sample is swept field by field on every build, not fuzzed. |
| Every release opens every container earlier releases wrote — checked against files written by the released 0.2.0, 0.3.0, 0.4.0, 0.5.0 and 0.5.1 binaries, fetched from Maven Central. | Semantic, not byte-level: ZIP metadata differs between saves. And forwards only: an older release refuses a container_version it does not know, by design. |
| Limits bound bytes actually streamed, so a decompression bomb costs the limit rather than the bomb. | Limits are configurable, and a writer applies the same ones as its reader — so raising them on one side alone produces files that will not reopen. |
| Assets stream in both directions; nothing buffers a whole archive. Enforced by a test that saves, reads and validates a 512 MiB asset in a JVM given a 192 MiB heap. | An AssetSource is read twice per save (measure, then write). It must return a fresh stream each time. And readAsset returns a ByteArray by definition — use openAsset or copyAssetTo for anything you would not want in memory. |
An opened document owns its resources and releases them on close - enforced on Windows, where a leaked archive handle locks the file and the next save cannot replace it. |
One handle is not safe for concurrent use. Independent handles on the same file are fine. |
| A cancelled open, import or export leaves none of DocumentKit's own resources behind: no open archive, no staging copy, no half-delivered handle. | It does not undo what already reached a destination: an export cancelled mid-copy has written what it wrote. And cancellation is not process death - a killed process can leave staging files, which DocumentTransfer.cleanStagingDirectory() removes on the next start. |
| Reading is unaffected by bytes sitting before or after the archive: the content returned is the container's own, not a shifted misread of it. | DocumentKit does not certify that a file is only a container. A ZIP polyglot — a file that is simultaneously a valid container and a script or an image — opens as the document it holds. "Opened successfully" is not a statement that the file is inert. |
| Migrations run through a complete, gap-free chain, validated when the codec is built. | Migrations transform JSON, one version per step, and never touch assets. Asset conversion is the application's job. |
| Android export builds and verifies the archive privately before opening the destination, and truncates it when it does. Tested at API 24 and 36. | The provider owns the destination. A ProviderManagedExport is a copy, not a crash-safe overwrite; an interruption mid-copy can leave partial content, and the error says so. Tested against the framework's provider plumbing under Robolectric, not against real provider apps. |
| Module | Contents |
|---|---|
documentkit-core |
Format types, codec, migrations, errors, limits. No Java, no UI toolkit. |
documentkit-io |
Shared JVM/Android archive implementation and local-file save. |
documentkit-android |
Storage Access Framework import and export. |
documentkit-cli |
inspect and validate for any container. |
Not published, and not part of the supported surface:
| Sample | What it is |
|---|---|
samples/lantr-import |
Converts a Lantr .ltrn presentation into a DocumentKit container, and reports everything it did not carry across. |
The archive implementation lives in one intermediate source set compiled for both JVM and Android. Two copies is how they drift.
Nothing depends on Compose. A codec is constructible from a plain serialisable model, which is what lets the same document type serve a desktop app, an Android app, a CLI and a test.
| Milestone | Contents | State |
|---|---|---|
0.1 |
Container format v1, codec, migration chain, JVM/Android archives, streamed assets, limits, validation, atomic local replacement, SAF import/export | implemented |
0.2 |
Inspect/validate CLI, integrity reporting, Lantr legacy importer | released |
0.3 |
Hostile-input hardening: manifest validation coverage, archive-level and command-line corpora, overflow fixes | released |
0.4 |
Android import/export tested at API 24 and 36 under Robolectric; cancellation that leaves nothing behind | released |
1.0 |
Stable API and format, compatibility policy, fuzz regressions | in progress — the policy, API checking, the compatibility corpus and fuzz regressions are implemented; 1.0 itself is the promise that the API stops changing |
0.4 originally said instrumented tests — on an emulator. They became
Robolectric tests: the same framework code for each API level, run on the JVM
on every push and gating every release, where an emulator job would be slow
and intermittently red.
The cost is stated in the guarantees table: no real provider apps, storage or
process death. On-device tests remain open work in
CONTRIBUTING.
0.4 also listed benchmarks. They were dropped in favour of ceiling tests:
memoryCeilingTest saves, reads and validates a 512 MiB asset in a 192 MiB
heap and fails with OutOfMemoryError if any stage stops streaming. A
benchmark reports a number nobody is obliged to act on; a ceiling test fails
the build, and it runs on every CI job.
- Container format, version 1 — the normative specification.
- Compatibility — what stays stable between versions, and what enforces each promise.
- Troubleshooting — what each structured error means and what to do.
- Extracting a reusable persistence layer from Lantr
- Atomic replacement versus Android provider export
- Validating archive contents without trusting size metadata
- Source provenance — what came from Lantr, and what was replaced.
See CONTRIBUTING.md. Parser and archive-handling security reports go through SECURITY.md.
MIT. See LICENSE; Lantr's notice is retained in PROVENANCE.md.