
Small runtime utilities: fills common gaps with ioDispatcher, asyncWithProgress, WeakRef, Platform.current and WebWorker; plus ~450 handy helpers for text, collections, flows, coroutines, math, encoding, time, randomness.
The small runtime pieces that commonMain does not have, for Kotlin
Multiplatform: an IO dispatcher, weak references, platform identity, progress
futures and String.format, plus a few coroutine helpers that are easy to get wrong.
AI tools wrote much of the code, tests and documentation in this project, under the direction and review of its author.
Documentation · a page for each API with examples, plus the generated API reference.
Every Kotlin Multiplatform project writes the same few expect/actual helpers again,
each one a little differently. KiteCore writes them once and tests them on every target.
It is one artifact, and its only dependency is kotlinx-coroutines-core.
|
|
|
|
|
|
|
Coroutine helpers |
Here are four of them together:
import io.github.yuroyami.kitecore.Platform
import io.github.yuroyami.kitecore.asyncWithProgress
import io.github.yuroyami.kitecore.ioDispatcher
import io.github.yuroyami.kitecore.text.format
val download = scope.asyncWithProgress {
withContext(ioDispatcher) {
repeat(100) { i ->
readChunkBlocking(i)
report(done = i + 1L, total = 100)
}
}
"done"
}
launch { download.progress.collect { println("%.0f%%".format((it.fraction ?: 0f) * 100)) } }
download.await()
println(Platform.current) // "Android 15 (API 35) (Google Pixel 8)"[!NOTE] KiteCore is not at 1.0 yet, so the API can still change between minor versions. Every change is in CHANGELOG.md.
KiteCore is on Maven Central at 0.2.0:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.yuroyami:kitecore:0.2.0")
}
}
}With the Kotlin Toolchain, add it to module.yaml:
dependencies:
- io.github.yuroyami:kitecore:0.2.0Good to know:
kotlinx-coroutines-core comes with KiteCore, so you do not add it yourself.implementation(...) line in your usual
dependencies { } block.[!IMPORTANT] 0.2.0 removed most of the helpers that 0.1.0 had. For each removed helper, the migration table names the replacement in the standard library or in kotlinx-coroutines.
import io.github.yuroyami.kitecore.ioDispatcher
val bytes = withContext(ioDispatcher) { readFileBlocking(path) }| Target |
ioDispatcher is |
|---|---|
| JVM, Android, iOS, macOS | Dispatchers.IO |
| JS, Wasm | the dispatcher that you pass to installIoDispatcher(...)
|
[!WARNING] Browsers and Node have no thread pool for blocking work. Without setup,
ioDispatcherfalls back toDispatchers.Defaultand logs one warning. On JS, that dispatcher is the main thread. CallinstallIoDispatcher(...)once at startup with a dispatcher that runs off the main thread, such as one backed by a Web Worker.
asyncWithProgress returns a ProgressFuture. It holds the running operation as
deferred, and its progress as progress: StateFlow<Progress>. A Progress has a
fraction from 0 to 1 (null while the total is unknown) and a phase label.
import io.github.yuroyami.kitecore.asyncWithProgress
val open = scope.asyncWithProgress {
val parse = slice(0.4f, "parsing") // the first 40% of the bar
val header = parseHeader { parse.report(it) }
val load = slice(0.6f, "loading") // the last 60%, taken when the step starts
loadPages(header) { done, total -> load.report(done, total) }
}
launch { open.progress.collect { render(it.fraction, it.phase) } } // or collectAsState() in Compose
val document = open.await()slice when its step starts. A named slice reports its start at once, so
slices taken up front make the bar jump ahead and then back.await() and cancel() are on the future. For awaitAll and select, use open.deferred.import io.github.yuroyami.kitecore.WeakRef
val ref = WeakRef(bitmap)
ref.get() // null after the garbage collector takes the bitmapThe JVM, Android, Apple and JS (ES2021 and later) hold the object weakly. Wasm has no
weak reference, so there WeakRef holds the object strongly and WeakRef.isWeakSupported
is false.
import io.github.yuroyami.kitecore.Platform
import io.github.yuroyami.kitecore.PlatformFamily
Platform.current.name // "iOS"
Platform.current.osVersion // "18.2"
Platform.current.deviceModel // "iPhone16,1", also on the simulator
when (Platform.current.family) {
PlatformFamily.IOS, PlatformFamily.MACOS -> useApplePath()
else -> useDefaultPath()
}A value is empty when the host does not report it. Use family in code, and use the
strings for logs and bug reports.
import io.github.yuroyami.kitecore.text.format
"%-8s|%6.2f|%,d".format("total", 3.14159, 1234567) // "total | 3.14|1,234,567"
String.format("%2\$s: %1\$08X", 255, "mask") // "mask: 000000FF"The syntax is the syntax of java.util.Formatter, with flags, width, precision, argument
indexes and the numeric conversions. The output does not depend on the device locale. A bad
pattern throws StringFormatException. Date and time conversions (%t) are not supported.
import io.github.yuroyami.kitecore.coroutines.*
val user = retryWithBackoff(times = 5, initialDelay = 200.milliseconds) {
withTimeout(2.seconds) { api.fetchUser(id) } // a timeout here is retried
}
val profiles = userIds.parallelMap(concurrency = 8) { api.fetchProfile(it) }
val tokens = SingleFlight<String, Token>()
val token = tokens.run("session") { api.refreshToken() } // callers at the same time share one call
val config = suspendLazy { loadConfigFromDisk() }
config.get()| API | What it does |
|---|---|
retryWithBackoff |
Runs a block again after a failure, with a pause that grows each time. |
runCatchingCancellable |
Like runCatching, but the cancellation of the caller still stops the caller. |
parallelMap, parallelForEach
|
Run a block for each element, at most concurrency at a time. |
SingleFlight |
Callers that ask for the same key at the same time share one run of the block. |
suspendLazy |
A lazy value with a suspending initializer. A failure is not cached. |
The cancellation of the caller always stops these helpers. A CancellationException
from inside the block, such as a withTimeout timeout, counts as an ordinary failure. So
retryWithBackoff retries it, and runCatchingCancellable returns it as a failure.
import io.github.yuroyami.kitecore.flow.*
val total: StateFlow<Int> = cart.mapState { items -> items.sumOf { it.price } }
val canPay: StateFlow<Boolean> = combineStates(total, balance) { t, b -> b >= t }
clicks.throttleFirst(500.milliseconds) // one click per half second
urls.mapAsync(concurrency = 4) { download(it) } // four downloads at a time, in ordermapState and combineStates need no CoroutineScope. They compute the value when you
read it.
| Target | Notes |
|---|---|
| Android | minSdk 21 |
| JVM | bytecode 11, so JDK 11 and later |
| iOS |
iosArm64, iosSimulatorArm64, iosX64
|
| macOS |
macosArm64. Kotlin 2.4 deprecated macosX64, so KiteCore does not build it. |
| JS | browser and Node |
| Wasm |
wasmJs, browser and Node |
| Topic | What to expect |
|---|---|
ioDispatcher on the web |
It helps only after you call installIoDispatcher(...). KiteCore does not ship a worker-backed dispatcher. |
WeakRef |
It holds strongly on Wasm and on JS runtimes older than ES2021. Check WeakRef.isWeakSupported. |
Platform |
The strings come from the host and can be empty. Do not parse them for version checks. |
String.format |
No %t date and time conversions, and no locale other than the neutral one. |
| API stability | Explicit API mode and a committed ABI dump make every signature change visible. They do not make the API stable before 1.0. |
92 shared tests run on each of five targets: the JVM, the Android host, the iOS simulator, macOS, and Wasm in the browser. 14 more tests cover cases that only the JVM or Wasm has. The JS target compiles, but its tests do not run, because the Kotlin Toolchain cannot run JS tests yet. Every public function and class has a test.
String.format is checked against java.util.Formatter itself. A seeded test compares
60,000 random patterns on every JVM run. Before 0.2.0, a longer run compared about 2.3
million patterns and found no difference.
The cancellation tests cover the cases that often go wrong: a timeout inside the block, a
caller that is cancelled while it waits, and a block that calls its own SingleFlight key.
See CONTRIBUTING.md. A change to the public API also needs a new ABI
dump. Make it with ./kotlin do updateAbi. Every change comes with tests.
Run them on the web targets too, because most subtle bugs show up there.
Apache-2.0. See NOTICE.
Part of the Kite family: KitePDF, ImageKodec, KiteQR.
The small runtime pieces that commonMain does not have, for Kotlin
Multiplatform: an IO dispatcher, weak references, platform identity, progress
futures and String.format, plus a few coroutine helpers that are easy to get wrong.
AI tools wrote much of the code, tests and documentation in this project, under the direction and review of its author.
Documentation · a page for each API with examples, plus the generated API reference.
Every Kotlin Multiplatform project writes the same few expect/actual helpers again,
each one a little differently. KiteCore writes them once and tests them on every target.
It is one artifact, and its only dependency is kotlinx-coroutines-core.
|
|
|
|
|
|
|
Coroutine helpers |
Here are four of them together:
import io.github.yuroyami.kitecore.Platform
import io.github.yuroyami.kitecore.asyncWithProgress
import io.github.yuroyami.kitecore.ioDispatcher
import io.github.yuroyami.kitecore.text.format
val download = scope.asyncWithProgress {
withContext(ioDispatcher) {
repeat(100) { i ->
readChunkBlocking(i)
report(done = i + 1L, total = 100)
}
}
"done"
}
launch { download.progress.collect { println("%.0f%%".format((it.fraction ?: 0f) * 100)) } }
download.await()
println(Platform.current) // "Android 15 (API 35) (Google Pixel 8)"[!NOTE] KiteCore is not at 1.0 yet, so the API can still change between minor versions. Every change is in CHANGELOG.md.
KiteCore is on Maven Central at 0.2.0:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.yuroyami:kitecore:0.2.0")
}
}
}With the Kotlin Toolchain, add it to module.yaml:
dependencies:
- io.github.yuroyami:kitecore:0.2.0Good to know:
kotlinx-coroutines-core comes with KiteCore, so you do not add it yourself.implementation(...) line in your usual
dependencies { } block.[!IMPORTANT] 0.2.0 removed most of the helpers that 0.1.0 had. For each removed helper, the migration table names the replacement in the standard library or in kotlinx-coroutines.
import io.github.yuroyami.kitecore.ioDispatcher
val bytes = withContext(ioDispatcher) { readFileBlocking(path) }| Target |
ioDispatcher is |
|---|---|
| JVM, Android, iOS, macOS | Dispatchers.IO |
| JS, Wasm | the dispatcher that you pass to installIoDispatcher(...)
|
[!WARNING] Browsers and Node have no thread pool for blocking work. Without setup,
ioDispatcherfalls back toDispatchers.Defaultand logs one warning. On JS, that dispatcher is the main thread. CallinstallIoDispatcher(...)once at startup with a dispatcher that runs off the main thread, such as one backed by a Web Worker.
asyncWithProgress returns a ProgressFuture. It holds the running operation as
deferred, and its progress as progress: StateFlow<Progress>. A Progress has a
fraction from 0 to 1 (null while the total is unknown) and a phase label.
import io.github.yuroyami.kitecore.asyncWithProgress
val open = scope.asyncWithProgress {
val parse = slice(0.4f, "parsing") // the first 40% of the bar
val header = parseHeader { parse.report(it) }
val load = slice(0.6f, "loading") // the last 60%, taken when the step starts
loadPages(header) { done, total -> load.report(done, total) }
}
launch { open.progress.collect { render(it.fraction, it.phase) } } // or collectAsState() in Compose
val document = open.await()slice when its step starts. A named slice reports its start at once, so
slices taken up front make the bar jump ahead and then back.await() and cancel() are on the future. For awaitAll and select, use open.deferred.import io.github.yuroyami.kitecore.WeakRef
val ref = WeakRef(bitmap)
ref.get() // null after the garbage collector takes the bitmapThe JVM, Android, Apple and JS (ES2021 and later) hold the object weakly. Wasm has no
weak reference, so there WeakRef holds the object strongly and WeakRef.isWeakSupported
is false.
import io.github.yuroyami.kitecore.Platform
import io.github.yuroyami.kitecore.PlatformFamily
Platform.current.name // "iOS"
Platform.current.osVersion // "18.2"
Platform.current.deviceModel // "iPhone16,1", also on the simulator
when (Platform.current.family) {
PlatformFamily.IOS, PlatformFamily.MACOS -> useApplePath()
else -> useDefaultPath()
}A value is empty when the host does not report it. Use family in code, and use the
strings for logs and bug reports.
import io.github.yuroyami.kitecore.text.format
"%-8s|%6.2f|%,d".format("total", 3.14159, 1234567) // "total | 3.14|1,234,567"
String.format("%2\$s: %1\$08X", 255, "mask") // "mask: 000000FF"The syntax is the syntax of java.util.Formatter, with flags, width, precision, argument
indexes and the numeric conversions. The output does not depend on the device locale. A bad
pattern throws StringFormatException. Date and time conversions (%t) are not supported.
import io.github.yuroyami.kitecore.coroutines.*
val user = retryWithBackoff(times = 5, initialDelay = 200.milliseconds) {
withTimeout(2.seconds) { api.fetchUser(id) } // a timeout here is retried
}
val profiles = userIds.parallelMap(concurrency = 8) { api.fetchProfile(it) }
val tokens = SingleFlight<String, Token>()
val token = tokens.run("session") { api.refreshToken() } // callers at the same time share one call
val config = suspendLazy { loadConfigFromDisk() }
config.get()| API | What it does |
|---|---|
retryWithBackoff |
Runs a block again after a failure, with a pause that grows each time. |
runCatchingCancellable |
Like runCatching, but the cancellation of the caller still stops the caller. |
parallelMap, parallelForEach
|
Run a block for each element, at most concurrency at a time. |
SingleFlight |
Callers that ask for the same key at the same time share one run of the block. |
suspendLazy |
A lazy value with a suspending initializer. A failure is not cached. |
The cancellation of the caller always stops these helpers. A CancellationException
from inside the block, such as a withTimeout timeout, counts as an ordinary failure. So
retryWithBackoff retries it, and runCatchingCancellable returns it as a failure.
import io.github.yuroyami.kitecore.flow.*
val total: StateFlow<Int> = cart.mapState { items -> items.sumOf { it.price } }
val canPay: StateFlow<Boolean> = combineStates(total, balance) { t, b -> b >= t }
clicks.throttleFirst(500.milliseconds) // one click per half second
urls.mapAsync(concurrency = 4) { download(it) } // four downloads at a time, in ordermapState and combineStates need no CoroutineScope. They compute the value when you
read it.
| Target | Notes |
|---|---|
| Android | minSdk 21 |
| JVM | bytecode 11, so JDK 11 and later |
| iOS |
iosArm64, iosSimulatorArm64, iosX64
|
| macOS |
macosArm64. Kotlin 2.4 deprecated macosX64, so KiteCore does not build it. |
| JS | browser and Node |
| Wasm |
wasmJs, browser and Node |
| Topic | What to expect |
|---|---|
ioDispatcher on the web |
It helps only after you call installIoDispatcher(...). KiteCore does not ship a worker-backed dispatcher. |
WeakRef |
It holds strongly on Wasm and on JS runtimes older than ES2021. Check WeakRef.isWeakSupported. |
Platform |
The strings come from the host and can be empty. Do not parse them for version checks. |
String.format |
No %t date and time conversions, and no locale other than the neutral one. |
| API stability | Explicit API mode and a committed ABI dump make every signature change visible. They do not make the API stable before 1.0. |
92 shared tests run on each of five targets: the JVM, the Android host, the iOS simulator, macOS, and Wasm in the browser. 14 more tests cover cases that only the JVM or Wasm has. The JS target compiles, but its tests do not run, because the Kotlin Toolchain cannot run JS tests yet. Every public function and class has a test.
String.format is checked against java.util.Formatter itself. A seeded test compares
60,000 random patterns on every JVM run. Before 0.2.0, a longer run compared about 2.3
million patterns and found no difference.
The cancellation tests cover the cases that often go wrong: a timeout inside the block, a
caller that is cancelled while it waits, and a block that calls its own SingleFlight key.
See CONTRIBUTING.md. A change to the public API also needs a new ABI
dump. Make it with ./kotlin do updateAbi. Every change comes with tests.
Run them on the web targets too, because most subtle bugs show up there.
Apache-2.0. See NOTICE.
Part of the Kite family: KitePDF, ImageKodec, KiteQR.