Skip to content

Latest commit

 

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

DocumentKit

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-android is 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.


Getting started

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 suspend function and runs on Dispatchers.IO. The snippets below use runBlocking to stay short; in an application, call them from whatever scope you already have.


Saving a document

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)
}

Opening one

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.

Opening an older one

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.

Opening a damaged one

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.

Android

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.


Inspecting a file from the command line

./gradlew :documentkit-cli:installDist

That puts a launcher script under the build directory; it is not on your PATH:

documentkit-cli/build/install/documentkit/bin/documentkit inspect field-notes.dkit
field-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.

Guarantees, each with its limit

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.

Modules

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.

Roadmap

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.

Documentation

Contributing

See CONTRIBUTING.md. Parser and archive-handling security reports go through SECURITY.md.

Licence

MIT. See LICENSE; Lantr's notice is retained in PROVENANCE.md.

About

Versioned save files, assets, migrations and explicit storage guarantees for Kotlin applications.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages