Ketoy
Manual setup

Set up Ketoy by hand

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.

Most people should use the CLI

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.

Alpha status - 0.3.4-alpha

The 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.

Requirements

Ketoy 0.3.4-alpha
Kotlin2.0.21
Android Gradle Plugin8.0+ (Iguana or newer)
JDK17
min SDK26
compile SDK35+
Jetpack ComposeBOM 2024.10.00

Kotlin version is pinned during alpha

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.

Hilt is optional

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.

Step 01

Install Ketoy

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.

app/build.gradle.ktsHilt
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")
}
app/build.gradle.ktsNo Hilt
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.

Configure the ketoy { } block

Configure 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.

app/build.gradle.kts · ketoy {} DSL
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:

terminal
$ echo '{"version": 1, "capabilities": [], "allowedStdlibFqNames": []}' \
    > app/ketoy-capabilities.json

Optional - generate a signing key

Bundle 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:

terminal · generate 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.key

Then add the private key to .gitignore:

.gitignore
**/keys/*-private.key

The 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.

Signature verification is opt-in

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.

Step 02

Wire the runtime

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:

AppCapabilityProvider.kt
@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:

KetoyConfigModule.kt
@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:

MyApplication.kt · MainActivity.kt
@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.

MyApplication.kt
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.

MainActivity.kt
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.

Step 03

Write your first KBC screen and mount it

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.

app/src/main/kotlin/.../ketoyscreens/HelloScreen.kt@KetoyEntryPoint
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.

Mount it in your NavHost

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.

AppNavGraph.kt
@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()
            }
        }
    }
}
Step 04

Build, install, and run

Two Gradle commands. The ketoyBundle task attaches to compileReleaseKotlin and emits the signed .ktx into your assets directory.

terminal
# 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:installDebug

You should see a summary line during compilation:

build outputsigned
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.

Roadmap

What ships next - cloud delivery

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.

Continue reading

Architecture overview

The five-layer stack and why KBC bytecode - not JSON - is the wire format.

Read the spec

Supported features

Every Material3 adapter, every capability, every Compose token Ketoy ships with.

See coverage

Custom adapters

Add your own components beyond Material3 - the KSP processor handles the rest.

Read the guide

Need help?

Join the Ketoy community on Discord, or open an issue on GitHub. We respond within a day.