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>:
| ID | Name | Signature |
|---|---|---|
0x0600 | KV_GET | suspend (key: String): Any? |
0x0601 | KV_SET | suspend (key: String, value: Any?): Unit |
0x0602 | KV_DELETE | suspend (key: String): Unit |
0x0603 | KV_OBSERVE | (key: String): Flow<Any?> |
0x0604 | KV_GET_ALL | suspend (): Map<String, Any?> |
0x0605 | KV_CLEAR | suspend (): Unit |
Supported value types: String, Int, Long, Float, Double,
Boolean. Anything else throws KetoyException on KV_SET.
Host setup
@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:
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:
@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:
@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:
// 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:
@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:
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 case | Tool |
|---|---|
| Transient UI state (dialog open, draft text) | remember { mutableStateOf(...) } |
| Screen state that survives rotation | ketoyViewModel<T>() + StateFlow |
| Screen state that must survive process death | state-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 isPreferences, not Protocol Buffers. If you need typed protobufs, wrap them in a custom capability returningFlow<MyProto>; KBC seesAny?. - DataStore writes go to disk async.
KV_SETreturns when the write has been queued, not flushed. For "must be on disk before screen closes" guarantees, expose a customflush()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 →