Ketoy
Guides

DataStore

DataStore-backed key-value storage is exposed to KBC through six built-in capabilities. Higher-typed flows (e.g. Flow<Boolean> for a "dark mode" toggle) go through custom app-specific capabilities.


Built-in KV_* capabilities

Registered by registerCoreCapabilities(context, dataStore, ...) when you pass a non-null DataStore<Preferences>:

IDNameSignature
0x0600KV_GETsuspend (key: String): Any?
0x0601KV_SETsuspend (key: String, value: Any?): Unit
0x0602KV_DELETEsuspend (key: String): Unit
0x0603KV_OBSERVE(key: String): Flow<Any?>
0x0604KV_GET_ALLsuspend (): Map<String, Any?>
0x0605KV_CLEARsuspend (): Unit

Supported value types: String, Int, Long, Float, Double, Boolean. Anything else throws KetoyException on KV_SET.


Host setup

kotlin
@Module
@InstallIn(SingletonComponent::class)
abstract class AppHiltModule {
    companion object {
        @Provides @Singleton
        fun provideDataStore(@ApplicationContext ctx: Context): DataStore<Preferences> =
            PreferenceDataStoreFactory.create {
                ctx.preferencesDataStoreFile("app_prefs")
            }
    }
}

Then in your KetoyCapabilityProvider:

kotlin
override fun buildRegistry(): CapabilityRegistry = CapabilityRegistry().apply {
    registerCoreCapabilities(
        context = context,
        dataStore = preferences,    // ← required to get KV_* registered
    )
}

If dataStore = null, the KV_* capabilities are skipped, calls from KBC throw KetoyMissingCapabilityException at runtime.


Use from KBC

Declare the stubs:

kotlin
@KetoyCapabilityStub(id = 0x0600, name = "KV_GET")
suspend fun kvGet(key: String): Any? = error(STUB_MSG)

@KetoyCapabilityStub(id = 0x0601, name = "KV_SET")
suspend fun kvSet(key: String, value: Any?): Unit = error(STUB_MSG)

@KetoyCapabilityStub(id = 0x0603, name = "KV_OBSERVE")
fun kvObserve(key: String): Flow<Any?> = error(STUB_MSG)

And in your ViewModel:

kotlin
@KetoyViewModel
class PreferencesViewModel : ViewModel() {
    private val _name = MutableStateFlow("")
    val name: StateFlow<String> = _name.asStateFlow()

    init {
        // Restore last-known value into the flow.
        viewModelScope.launch {
            kvObserve("user_name").collect { value ->
                _name.value = value as? String ?: ""
            }
        }
    }

    fun updateName(name: String) {
        viewModelScope.launch { kvSet("user_name", name) }
    }
}

Why use typed flow capabilities instead?

KV_OBSERVE returns Flow<Any?>, you cast every value at the read site, which is brittle. For commonly-observed prefs, register a typed flow capability:

kotlin
// Host: SettingsRepository wrapping the DataStore.
class SettingsRepository @Inject constructor(
    private val store: DataStore<Preferences>
) {
    private val DARK_MODE = booleanPreferencesKey("dark_mode")
    val darkMode: Flow<Boolean> = store.data.map { it[DARK_MODE] ?: false }
    suspend fun setDarkMode(enabled: Boolean) {
        store.edit { it[DARK_MODE] = enabled }
    }
}

// AppCapabilityIds.kt:
object AppCapabilityIds {
    const val OBSERVE_DARK_MODE: Short = 0x4010.toShort()
    const val SET_DARK_MODE: Short = 0x4011.toShort()
}

// Provider:
override fun buildRegistry(): CapabilityRegistry = CapabilityRegistry().apply {
    registerCoreCapabilities(context, dataStore = preferences)

    registerFlow(AppCapabilityIds.OBSERVE_DARK_MODE) { _ ->
        settingsRepository.darkMode.map { it as Any? }
    }
    registerSuspend(AppCapabilityIds.SET_DARK_MODE) { args ->
        settingsRepository.setDarkMode(args[0] as Boolean)
    }
}

KBC-side:

kotlin
@KetoyCapabilityStub(id = 0x4010, name = "OBSERVE_DARK_MODE")
fun observeDarkMode(): Flow<Boolean> = error(STUB_MSG)

@KetoyCapabilityStub(id = 0x4011, name = "SET_DARK_MODE")
suspend fun setDarkMode(enabled: Boolean): Unit = error(STUB_MSG)

Now your KBC code reads Flow<Boolean> directly:

kotlin
val dark by observeDarkMode().collectAsState(initial = false)

Persistable types vs Compose state

A typed ketoyViewModel<T>() holds its StateFlow in the ViewModelStore, so state survives rotation. For process-death survival, the state-map model mirrors a filtered subset of value types to SavedStateHandle. DataStore is stronger persistence than either, it survives reinstalls and app data clears, and it's queryable from native code on the host side.

When to use which:

Use caseTool
Transient UI state (dialog open, draft text)remember { mutableStateOf(...) }
Screen state that survives rotationketoyViewModel<T>() + StateFlow
Screen state that must survive process deathstate-map setState() + SavedStateHandle
User preferences (settings, last route, theme)DataStore
User content (todos, messages, drafts)Room
Large blobs (images, audio)File-backed capability (custom)

Limits

  • The DataStore proxy uses PreferenceDataStoreFactory, value type is Preferences, not Protocol Buffers. If you need typed protobufs, wrap them in a custom capability returning Flow<MyProto>; KBC sees Any?.
  • DataStore writes go to disk async. KV_SET returns when the write has been queued, not flushed. For "must be on disk before screen closes" guarantees, expose a custom flush() capability.
  • Don't store secrets (auth tokens, refresh tokens) in DataStore from KBC. Keep credential handling host-side; expose only the Authorization-decorated HTTP capabilities to KBC.

Next: Room →