Android host shims
@symbiote-native/android is the one package on this site that wraps nothing. Every other
package here — slider, local auth,
system UI — puts a framework-agnostic JS surface over an existing
native library. This one is the inverse: pure Kotlin, zero JavaScript, supplying native
modules that RN’s Android host would normally provide but doesn’t here.
Why it’s needed: SymbioteNative drives Fabric directly and never instantiates ReactRootView, and
two of RN’s Android signals hang off exactly that class. Without this package installed,
Keyboard’s events never fire on Android and Settings reads back null — both silently, with
no error. iOS needs no equivalent: RCTKeyboardObserver and RCTSettingsManager are global
native observers RN already ships, independent of which renderer drives the view tree.
| OS platform | Support |
|---|---|
| iOS | — not needed (RN ships both equivalents) |
| Android | ✅ live |
| Framework adapter | Support |
|---|---|
| React | ✅ live |
| Vue | ✅ live |
| Angular | ✅ live |
| Svelte | ✅ live |
All four adapters are listed as live because none of them import this package. They reach it
through their existing Keyboard and Settings exports, which resolve a native module by name —
this package is what makes that name resolve on Android.
Installation
Section titled “Installation”npm install @symbiote-native/androidNo further native setup. The package ships a react-native.config.js declaring its Android
sourceDir plus the SymbioteAndroidPackage import path and instance, so RN’s Gradle
autolinking registers both modules deterministically. There is no iOS half to link (ios: null
in that config) and no manifest permission to merge — the shipped AndroidManifest.xml is empty.
There is nothing to import from this package — see API below. Using it means
using Keyboard and Settings from your adapter’s own barrel, exactly as you would on iOS.
Both are re-exported by React, Vue, Angular,
and Svelte from @symbiote-native/engine, so the JS is identical across all four and across both
platforms.
import { useEffect, useState } from 'react';import { KEYBOARD_EVENT, Keyboard, Settings, Text, View } from '@symbiote-native/react';
export default function KeyboardStatus() { const [height, setHeight] = useState(0);
useEffect(() => { const shown = Keyboard.addListener(KEYBOARD_EVENT.didShow, () => { setHeight(Keyboard.metrics()?.height ?? 0); }); const hidden = Keyboard.addListener(KEYBOARD_EVENT.didHide, () => setHeight(0));
// Persisted through SharedPreferences on Android, NSUserDefaults on iOS. Settings.set({ lastSeenKeyboard: Date.now() });
return () => { shown.remove(); hidden.remove(); }; }, []);
return ( <View> <Text>Keyboard height: {height}</Text> </View> );}<script setup lang="ts">import { onMounted, onUnmounted, ref } from 'vue';import { KEYBOARD_EVENT, Keyboard, Settings, Text, View } from '@symbiote-native/vue';
const height = ref(0);let subscriptions: ReturnType<typeof Keyboard.addListener>[] = [];
onMounted(() => { subscriptions = [ Keyboard.addListener(KEYBOARD_EVENT.didShow, () => { height.value = Keyboard.metrics()?.height ?? 0; }), Keyboard.addListener(KEYBOARD_EVENT.didHide, () => { height.value = 0; }), ];
Settings.set({ lastSeenKeyboard: Date.now() });});
onUnmounted(() => { for (const subscription of subscriptions) subscription.remove();});</script>
<template> <View> <Text>Keyboard height: {{ height }}</Text> </View></template>import { Component, OnDestroy, OnInit, signal } from '@angular/core';import { KEYBOARD_EVENT, Keyboard, Settings, Text, View } from '@symbiote-native/angular';
@Component({ standalone: true, imports: [Text, View], template: ` <View> <Text>Keyboard height: {{ height() }}</Text> </View> `,})export class KeyboardStatus implements OnInit, OnDestroy { readonly height = signal(0);
private subscriptions: ReturnType<typeof Keyboard.addListener>[] = [];
ngOnInit(): void { this.subscriptions = [ Keyboard.addListener(KEYBOARD_EVENT.didShow, () => { this.height.set(Keyboard.metrics()?.height ?? 0); }), Keyboard.addListener(KEYBOARD_EVENT.didHide, () => this.height.set(0)), ];
Settings.set({ lastSeenKeyboard: Date.now() }); }
ngOnDestroy(): void { for (const subscription of this.subscriptions) subscription.remove(); }}There’s no service to inject() — Keyboard and Settings are plain objects off the
adapter barrel, same as on React and Vue.
<script lang="ts"> import { KEYBOARD_EVENT, Keyboard, Settings, Text, View } from '@symbiote-native/svelte';
let height = $state(0);
$effect(() => { const shown = Keyboard.addListener(KEYBOARD_EVENT.didShow, () => { height = Keyboard.metrics()?.height ?? 0; }); const hidden = Keyboard.addListener(KEYBOARD_EVENT.didHide, () => (height = 0));
// Persisted through SharedPreferences on Android, NSUserDefaults on iOS. Settings.set({ lastSeenKeyboard: Date.now() });
return () => { shown.remove(); hidden.remove(); }; });</script>
<View> <Text>Keyboard height: {height}</Text></View>Keyboard and Settings are plain objects off the adapter barrel here too, same as on
every other adapter — the $effect’s returned function is the teardown, taking the place of
React’s useEffect cleanup and Vue’s separate onUnmounted.
This package has no JavaScript or TypeScript API. It exports no functions, no types, no
components — there is deliberately nothing to import from @symbiote-native/android, and a prop
or signature table would be fabricated. What it publishes is a native surface, consumed by name.
Native modules
Section titled “Native modules”| Native module name | Kotlin class | Backs |
|---|---|---|
KeyboardObserver |
KeyboardObserverModule |
The JS Keyboard module — addListener, removeAllListeners, isVisible, metrics, scheduleLayoutAnimation. The engine resolves this exact name on both platforms (RN’s own INativeKeyboardObserver spec uses it), so the JS side is platform-uniform and unchanged |
SettingsManager |
SettingsManagerModule |
The JS Settings module — get, set, watchKeys, clearWatch. Claims the same name RN’s iOS Settings resolves, and backs it with SharedPreferences instead of NSUserDefaults |
Both are registered by one ReactPackage, SymbioteAndroidPackage, which is the class
autolinking discovers. A plain legacy ReactPackage works under the New Architecture via
TurboModule interop, so neither module needs a codegen spec.
Emitted events
Section titled “Emitted events”| Event | Emitted by | Payload |
|---|---|---|
keyboardDidShow |
KeyboardObserverModule |
{ endCoordinates: { screenX, screenY, width, height }, easing: 'keyboard', duration: 0 } — the same shape RN’s JS Keyboard already parses, in DIP |
keyboardDidHide |
KeyboardObserverModule |
Same shape with height: 0 and screenY at the full view height |
settingsUpdated |
SettingsManagerModule |
The full current SharedPreferences snapshot, re-broadcast when a value changes from outside RN. Suppressed for the module’s own writes |
- Only the
did*keyboard events exist on Android. The module emitskeyboardDidShowandkeyboardDidHide, neverkeyboardWillShow/WillHide/WillChangeFrame— Android’s inset callback fires after the transition, so there is no “will” moment to report. Code that must run on both platforms should listen for thedid*pair. Keyboard.scheduleLayoutAnimationis a no-op on Android. The emitted payload carriesduration: 0, and that method returns early on a zero duration since a zero-length animation does nothing. It stays meaningful on iOS, where native supplies a real duration and easing.- The IME inset is read directly, not via
getRootWindowInsets(). UnderadjustResizethe latter’s consumed insets read0while the keyboard is up. The module observes the rawime()inset on the apply-insets dispatch instead, subtracting the system-bar inset to get the on-screen height above the navigation bar. - Insets are observed, never consumed. The listener returns the insets unchanged, so RN’s own status-bar and safe-area handling is untouched.
- A floating/compact IME bar counts as keyboard-up.
isVisible(ime())is true for any IME surface, including Gboard’s one-handed bar at roughly 64dp. That is deliberate parity — RN’sReactRootViewkeys off the same signal, and suppressing it by a height threshold would diverge from RN. Settingsnumbers are stored asIntorFloat, notDouble.SharedPreferenceshas noputDouble, so an integral in-range JS number is stored asIntand everything else asFloat. Exact for counters and flags; a fractional double rounds to about 7 significant digits.- Nested objects and arrays are skipped.
NSUserDefaultsaccepts them,SharedPreferencesdoes not, so a nestedMap/Arrayvalue is dropped with a native warning rather than silently written. Anullvalue removes the key, mirroring the iOS plist write. - The native
deleteValuesmethod has no JS caller today.SettingsManagerModuleimplements it for iOS-surface parity, but the engine’sSettingsonly ever callssetValues— there is noSettings.deletein the JS API to reach it.
How the wrapper works
Section titled “How the wrapper works”The whole package is three Kotlin files plus an autolinking config. package.json sets
"files": ["android"] and "react-native": "android" — it ships tracked native source and
performs no JS build at all, unlike every other publishable package here, which compiles
src/ to build/ at pack time.
packages/android/├── react-native.config.js # Android sourceDir + SymbioteAndroidPackage import/instance└── android/src/main/ ├── AndroidManifest.xml # empty — no permission to merge └── java/com/symbiote/android/ ├── SymbioteAndroidPackage # the one ReactPackage, registers both modules ├── KeyboardObserverModule.kt # OnApplyWindowInsetsListener on the decor view └── SettingsManagerModule.kt # SharedPreferences ("symbiote.settings")KeyboardObserverModule attaches its inset listener to the current activity’s decor view, and
re-attaches on host resume — the activity and its window may not exist yet when JS first
subscribes, so a subscription made before the window is ready still starts observing.
SettingsManagerModule registers its SharedPreferences change listener only while JS is
observing (from the first addListener to the last removeListeners), and holds a strong
reference to it, since SharedPreferences keeps only a weak one and GC would otherwise drop the
subscription.
Neither module touches the renderer. That is the point: the SymbioteNative
core drives Fabric directly and never forks RN’s native stack, so a signal
RN attaches to its own ReactRootView has to be re-derived beside it rather than patched into it.