The long-form, no-CLI path. From an empty Android app to a working KBC screen in four steps. Author Jetpack Compose screens, compile them to portable KBC bytecode, and ship them inside your APK as a signed .ktx asset that the Ketoy runtime executes natively.
The ketoy CLI automates everything below - surgical edits to build.gradle.kts, key generation, Application/MainActivity wiring, the sample screen - in a single ketoy init command. This page exists for people who want to understand every line, or who need to integrate Ketoy into a non-standard project layout where the CLI's anchors don't apply.
0.3.4-alphaThe local-asset path is fully supported: you author screens in your :app module, the compiler plugin emits a signed .ktx into your assets, and the runtime executes it at startup.
Cloud delivery - pushing a .ktx to a CDN so users get updates without going through the Play Store - is under active development and ships shortly. Track progress on the GitHub releases page.
The Compose compiler plugin and the Ketoy compiler plugin are both pinned to 2.0.21 for the alpha. Broader Kotlin version support - letting you pick up newer Kotlin releases without waiting on a matched Ketoy build - is on the roadmap for the next update.
Each step below has two parallel paths. Pick Hilt if your app already uses it (less wiring, recommended). Pick No Hilt to drop KBC screens into a plain Compose app - no DI framework required.
Ketoy ships a BOM, so you pin once and the rest of the modules line up. Add the plugin and dependencies to :app/build.gradle.kts.
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
id("dev.ketoy.compiler") version "0.3.4-alpha"
}
dependencies {
implementation(platform("dev.ketoy.vm:ketoy-bom:0.3.4-alpha"))
implementation("dev.ketoy.vm:ketoy-runtime")
implementation("dev.ketoy.vm:ketoy-hilt")
implementation("dev.ketoy.vm:ketoy-annotations")
implementation("dev.ketoy.vm:ketoy-capabilities-core")
implementation("dev.ketoy.vm:ketoy-capabilities-navigation")
implementation("dev.ketoy.vm:ketoy-adapters-material3")
implementation(platform("androidx.compose:compose-bom:2024.10.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose")
implementation("androidx.navigation:navigation-compose:2.8.0")
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-compiler:2.52")
}plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("dev.ketoy.compiler") version "0.3.4-alpha"
}
dependencies {
implementation(platform("dev.ketoy.vm:ketoy-bom:0.3.4-alpha"))
implementation("dev.ketoy.vm:ketoy-runtime")
implementation("dev.ketoy.vm:ketoy-annotations")
implementation("dev.ketoy.vm:ketoy-capabilities-core")
implementation("dev.ketoy.vm:ketoy-capabilities-navigation")
implementation("dev.ketoy.vm:ketoy-adapters-material3")
implementation(platform("androidx.compose:compose-bom:2024.10.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose")
implementation("androidx.navigation:navigation-compose:2.8.0")
}No ketoy-hilt, no hilt-android, no KSP processor. You’ll construct the runtime objects by hand in step 2.
ketoy { } blockConfigure the plugin in the same :app/build.gradle.kts. This turns on inline-source app bundle mode - the compiler attaches to compile<variant>Kotlin and emits one signed .ktx containing every declaration reachable from your annotated entry points.
ketoy {
// Required: turns on inline-source app bundle mode.
exportFromAppModule.set(true)
// Output filename. Bundle is written to
// :app/src/main/assets/ketoy/<bundleId>.ktx
bundleId.set("main")
// Which Android variant the compiler attaches to.
// Default = "release". The release-signed .ktx is loaded by
// both debug and release APKs for production parity.
bundleVariant.set("release")
// Capability registry - empty file is fine to start.
capabilityRegistryFile.set(file("ketoy-capabilities.json"))
// Optional: minimum host APK versionCode for this bundle.
minAppVersion.set(0)
// Optional: emit source line numbers for the dev overlay.
debugMode.set(true)
// Optional but recommended for production: sign the bundle.
// Without a key, the plugin emits an unsigned bundle gracefully.
val signingKey = file("keys/release-private.key")
if (signingKey.exists()) {
signingKeyFile.set(signingKey)
}
}Create a stub registry file so the build can start:
$ echo '{"version": 1, "capabilities": [], "allowedStdlibFqNames": []}' \
> app/ketoy-capabilities.jsonBundle signing is optional during alpha bring-up; the plugin produces unsigned bundles gracefully when no key is configured. For signed builds, generate an Ed25519 keypair:
$ mkdir -p app/keys app/src/main/assets/ketoy/keys
# Private key - 32 raw bytes, gitignored,
# used by the Gradle plugin at build time.
$ openssl genpkey -algorithm Ed25519 -outform DER \
| tail -c 32 > app/keys/release-private.key
# Public key - 32 raw bytes, committed alongside the APK
# so the runtime can verify the signature at load time.
$ openssl pkey -in app/keys/release-private.key -inform DER \
-pubout -outform DER \
| tail -c 32 > app/src/main/assets/ketoy/keys/release-public.keyThen add the private key to .gitignore:
**/keys/*-private.keyThe ketoy {} block above already wires signingKeyFile when the file exists. To complete the loop - making the runtime verify the signature against the embedded public key - see step 2.
KetoyConfig.enableSignatureVerification defaults to false so unsigned local-dev builds just work. For production, set it to true and pass publicKey. KetoyConfig validates this at construction - enabling verification without a 32-byte Ed25519 key throws IllegalArgumentException immediately, so misconfiguration fails fast at app startup, not buried inside the first bundle load.
This is the only step where the two paths diverge meaningfully - Hilt does the wiring for you, the manual path is two short blocks.
Implement KetoyCapabilityProvider:
@Singleton
class AppCapabilityProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : KetoyCapabilityProvider {
override fun buildRegistry(): CapabilityRegistry =
CapabilityRegistry().apply {
registerCoreCapabilities(context = context)
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AppHiltModule {
@Binds
abstract fun bindCapabilityProvider(
impl: AppCapabilityProvider,
): KetoyCapabilityProvider
}If you generated a signing keypair in step 1, also wire the public key via a KetoyConfigCustomizer binding so the Hilt-managed KetoyRuntime verifies signatures against your key:
@Module
@InstallIn(SingletonComponent::class)
object KetoyConfigModule {
@Provides @Singleton
fun provideKetoyConfigCustomizer(
@ApplicationContext context: Context,
): KetoyConfigCustomizer = KetoyConfigCustomizer { default ->
val publicKey =
KetoyKeystore.loadFromAsset(context, "ketoy/keys/release-public.key")
default.copy(
enableSignatureVerification = true,
publicKey = publicKey,
)
}
}Register Material3 adapters on the singleton KetoyRuntime in Application.onCreate, then publish runtime + loader in your activity via KetoyHiltProvider:
@HiltAndroidApp
class MyApplication : Application() {
@Inject lateinit var ketoyRuntime: KetoyRuntime
override fun onCreate() {
super.onCreate()
registerGeneratedAdapters(ketoyRuntime.adapterRegistry)
registerGeneratedConstructors(ketoyRuntime.constructorRegistry)
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var factoryBuilder: KetoyViewModelFactoryBuilder
@Inject lateinit var bundleLoader: KetoyBundleLoader
@Inject lateinit var runtime: KetoyRuntime
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KetoyHiltProvider(
factoryBuilder = factoryBuilder,
bundleLoader = bundleLoader,
runtime = runtime,
) { AppNavGraph() }
}
}
}KetoyHiltProvider internally publishes LocalKetoyRuntime and LocalKetoyCapabilityRegistry, so every KetoyScreen in the tree resolves its adapter and constructor registries from the same singleton.
Don’t forget android:name=".MyApplication" in AndroidManifest.xml.
Construct KetoyRuntime by hand. Hold it and the loader as instance fields on a custom Application subclass - not on a Kotlin object. Static fields that hold a Context (KetoyBundleLoader does) trip lint’s StaticFieldLeak warning. Instance fields on Application are tied to the process lifecycle and don’t leak.
class MyApplication : Application() {
lateinit var ketoyRuntime: KetoyRuntime private set
lateinit var ketoyBundleLoader: KetoyBundleLoader private set
override fun onCreate() {
super.onCreate()
val capabilityRegistry = CapabilityRegistry().apply {
registerCoreCapabilities(context = this@MyApplication)
}
// For production, load your Ed25519 public key from assets.
// For local dev with unsigned bundles, KetoyConfig() works as-is.
val config = KetoyConfig(
enableSignatureVerification = true,
publicKey = KetoyKeystore.loadFromAsset(
this, "ketoy/keys/release-public.key",
),
)
ketoyRuntime = KetoyRuntime(
capabilityRegistry = capabilityRegistry,
config = config,
)
// Composable + constructor adapters go on dedicated registries
// hanging off the runtime - not on CapabilityRegistry.
registerGeneratedAdapters(ketoyRuntime.adapterRegistry)
registerGeneratedConstructors(ketoyRuntime.constructorRegistry)
ketoyBundleLoader = KetoyBundleLoader(ketoyRuntime, this)
}
}Publish the runtime and loader via composition locals in MainActivity. KetoyScreen auto-derives the CapabilityRegistry, KBCAdapterRegistry, and KBCConstructorRegistry from LocalKetoyRuntime - no factory boilerplate needed.
import dev.ketoy.runtime.compose.LocalKetoyBundleLoader
import dev.ketoy.runtime.compose.LocalKetoyRuntime
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val app = application as MyApplication
setContent {
CompositionLocalProvider(
LocalKetoyRuntime provides app.ketoyRuntime,
LocalKetoyBundleLoader provides app.ketoyBundleLoader,
) { AppNavGraph() }
}
}
}Don’t forget android:name=".MyApplication" in AndroidManifest.xml. Publishing LocalKetoyRuntime is the only thing required for KetoyScreen to work without Hilt - KetoyScreen raises IllegalStateException with explicit remediation guidance when neither LocalKetoyRuntime nor a KetoyViewModelFactoryBuilder is published. Misconfiguration fails fast and clearly.
Same for both paths. Create the file directly inside your :app module - no separate Gradle module needed. The convention is to group KBC screens under <yourapp>.ketoyscreens to telegraph intent.
package com.example.myapp.ketoyscreens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.ketoy.annotations.KetoyComposable
import dev.ketoy.annotations.KetoyEntryPoint
@KetoyEntryPoint
@KetoyComposable
@Composable
fun HelloScreen() {
Column(modifier = Modifier.padding(24.dp)) {
Text(text = "Hello from KetoyVM")
Spacer(Modifier.height(12.dp))
Text(text = "Shipped as KBC bytecode, executed natively.")
}
}That’s the entire KBC source. Native code in the same module - your existing screens, repositories, navigation graph - compiles to DEX as usual. Only the closure reachable from HelloScreen gets lowered to KBC.
Every KetoyScreen call site requires a trailing nativeFallback lambda - the steady-state render when the bundle is absent, still loading, or the entry point can’t be found. It should render identically to the KBC screen, so adopting Ketoy can never introduce a regression in any non-active-bundle case.
@Composable
fun AppNavGraph() {
val nav = rememberNavController()
NavHost(nav, startDestination = "home") {
composable("home") {
KetoyScreen(
entryPoint = "HelloScreen",
bundleSource = KetoyBundleSource.Asset("ketoy/main.ktx"),
) {
// Native fallback - same UI, plain Compose.
HelloScreen()
}
}
}
}Two Gradle commands. The ketoyBundle task attaches to compileReleaseKotlin and emits the signed .ktx into your assets directory.
# Compile the bundle into app/src/main/assets/ketoy/main.ktx.
$ ./gradlew :app:ketoyBundle
# Build and install. assembleDebug rebuilds the bundle
# automatically when the source closure changes.
$ ./gradlew :app:installDebugYou should see a summary line during compilation:
KetoyBC: Compilation complete - 4 functions emitted, 1 composables,
0 view models, 1 entry points. Bundle ID: main. Wrote 1832 bytes to
app/src/main/assets/ketoy/main.ktx (signed)Launch the app - the KBC version of HelloScreen renders. Delete the .ktx, change the entry point to "DoesNotExist", or toggle airplane mode - the native fallback renders identically. That’s the production contract.
You can commit the .ktx alongside source, or gitignore it and rebuild on CI - it’s regenerated deterministically.
What you just built is the local-asset path: the .ktx ships inside the APK at compile time, and updates roll out through the Play Store like any other asset.
Cloud delivery - pushing a new .ktx to a CDN where connected devices fetch updates at the next process start (Ed25519-verified, ETag-cached, rolled back on minAppVersion mismatch) - uses KetoyBundleSource.Remote(url). The wire format, signature verification, ETag cache, and runtime activation order are already implemented. The hosted infrastructure (signing pipeline, signed-URL distribution, telemetry) is under active development and ships shortly.
Keep checking this page and the release announcements for the rollout.
The five-layer stack and why KBC bytecode - not JSON - is the wire format.
Read the specEvery Material3 adapter, every capability, every Compose token Ketoy ships with.
See coverageAdd your own components beyond Material3 - the KSP processor handles the rest.
Read the guide