@qafka/react-native 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.8.2] — 2026-11-12
11
+
12
+ ### Fixed
13
+
14
+ - Android emulator in development builds: initialization failed with `Attestation failed: 401 — Root certificate is not a Google attestation root`, because emulators report Android 8+ but have no hardware-backed keystore. In development builds (`__DEV__`) the SDK now detects the emulator and continues without attestation, the same way it already does on the iOS simulator. Use a TEST API key there; PRODUCTION keys still require an attested session. Release builds are unchanged and keep attesting on every device. Requires a native rebuild.
15
+
16
+ ## [2.8.1] — 2026-10-06
17
+
18
+ ### Added
19
+
20
+ - Close button on the voice page, in its top left corner. It only leaves voice: the voice session is ended and the widget returns to the chat page, so users no longer have to swipe back. The chat stays open and `onClose` is not called — closing the assistant is still done from the chat page.
21
+ - New optional `onVoiceClose` prop on `<Qafka />`, called when that button is pressed.
22
+ - New optional `voiceComponents.VoiceCloseButton` slot (`VoiceCloseButtonProps`) to replace the default button.
23
+
10
24
  ## [2.8.0] — 2026-10-06
11
25
 
12
26
  ### Added
@@ -27,9 +27,31 @@ class QafkaAttestationModule(reactContext: ReactApplicationContext) :
27
27
  "bundleId" to bundleId,
28
28
  "appVersion" to appVersion,
29
29
  "deviceModel" to deviceModel,
30
+ "isEmulator" to isEmulator(),
30
31
  )
31
32
  }
32
33
 
34
+ // Emulators have no TEE, so their attestation chain is rooted in a
35
+ // software root that the backend rejects. JS uses this only in
36
+ // development builds to fall back to the unsupported-device path.
37
+ private fun isEmulator(): Boolean {
38
+ val fingerprint = Build.FINGERPRINT ?: ""
39
+ val hardware = Build.HARDWARE ?: ""
40
+ val product = Build.PRODUCT ?: ""
41
+ val model = Build.MODEL ?: ""
42
+ return fingerprint.startsWith("generic") ||
43
+ fingerprint.startsWith("unknown") ||
44
+ fingerprint.contains("emulator") ||
45
+ hardware == "goldfish" ||
46
+ hardware == "ranchu" ||
47
+ product.startsWith("sdk") ||
48
+ product.contains("_sdk") ||
49
+ product.contains("emulator") ||
50
+ product.contains("simulator") ||
51
+ model.contains("Emulator") ||
52
+ model.contains("Android SDK built for")
53
+ }
54
+
33
55
  @ReactMethod
34
56
  fun isSupported(promise: Promise) {
35
57
  // Key Attestation requires Android 8.0+ (API 26) for hardware-backed
@@ -87,7 +87,7 @@ const { width: SCREEN_WIDTH } = react_native_1.Dimensions.get('window');
87
87
  * />
88
88
  * ```
89
89
  */
90
- exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
90
+ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onVoiceClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
91
91
  // === ALL HOOKS FIRST (Rules of Hooks compliance) ===
92
92
  const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
93
93
  const { sdkReady, unavailable, error: sdkError, resolvedApiKey, resolvedApiUrl } = (0, useSDK_1.useSDK)({
@@ -514,7 +514,14 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
514
514
 
515
515
  {/* Page 1: Voice — VoicePage paints Background edge-to-edge and
516
516
  handles its own internal safe-area padding for orb/transcript. */}
517
- <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} canResume={voiceCanResume} onResume={voiceResume} resumeLabel={voiceResumeLabel} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}/>
517
+ <VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} canResume={voiceCanResume} onResume={voiceResume} resumeLabel={voiceResumeLabel} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}
518
+ // The voice page's close button only leaves voice: end the session
519
+ // and slide back to the chat page, which stays open.
520
+ onClose={() => {
521
+ void voiceDisconnect();
522
+ scrollViewRef.current?.scrollTo({ x: 0, animated: true });
523
+ onVoiceClose?.();
524
+ }}/>
518
525
  </react_native_1.ScrollView>
519
526
  </react_native_1.View>) : (<ChatPage_1.ChatPage {...chatPageProps}/>);
520
527
  return (<react_native_1.View style={[
@@ -64,6 +64,14 @@ export interface VoiceMuteButtonProps {
64
64
  theme: Theme;
65
65
  state: VoiceChatState;
66
66
  }
67
+ /**
68
+ * Props passed to custom voice close button component
69
+ */
70
+ export interface VoiceCloseButtonProps {
71
+ onClose: () => void;
72
+ theme: Theme;
73
+ state: VoiceChatState;
74
+ }
67
75
  /**
68
76
  * Props passed to custom voice background component
69
77
  */
@@ -125,6 +133,11 @@ export interface VoiceComponents {
125
133
  * row in every voice layout. Defaults to a circular icon button.
126
134
  */
127
135
  VoiceMuteButton?: React.ComponentType<VoiceMuteButtonProps>;
136
+ /**
137
+ * Slot for the button that leaves the voice page and returns to the chat
138
+ * page, rendered in the top left corner. Defaults to a circular icon button.
139
+ */
140
+ VoiceCloseButton?: React.ComponentType<VoiceCloseButtonProps>;
128
141
  /**
129
142
  * Slot for the multi-chip list rendered when the AI calls the built-in
130
143
  * `qafka_display` tool with mode="chip".
@@ -689,6 +702,12 @@ export interface QafkaProps {
689
702
  */
690
703
  onClose?: () => void;
691
704
  /**
705
+ * Optional. Called when the close button on the voice page is pressed. That
706
+ * button only leaves voice: the voice session is ended and the widget
707
+ * returns to the chat page, which stays open — `onClose` is not called.
708
+ */
709
+ onVoiceClose?: () => void;
710
+ /**
692
711
  * Callback when back button is pressed
693
712
  * If provided, a back button will be shown in the top left corner
694
713
  */
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { QafkaProps } from './Qafka.types';
3
- type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
3
+ type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onVoiceClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
4
4
  mode?: 'floating' | 'fullscreen' | 'inline';
5
5
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
6
6
  };
@@ -51,6 +51,7 @@ interface VoicePageProps {
51
51
  onNavigationAccept?: () => void;
52
52
  onNavigationDismiss?: () => void;
53
53
  NavigationButtonComponent?: ComponentType<NavigationButtonProps>;
54
+ onClose?: () => void;
54
55
  }
55
- export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, canResume, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }: VoicePageProps): React.JSX.Element;
56
+ export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, canResume, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, onClose, }: VoicePageProps): React.JSX.Element;
56
57
  export {};
@@ -306,6 +306,19 @@ function DefaultVoiceMuteButton({ isMuted, onToggle, theme, }) {
306
306
  {isMuted ? <react_native_1.View style={[muteStyles.slash, { backgroundColor: fg }]}/> : null}
307
307
  </react_native_1.Pressable>);
308
308
  }
309
+ function DefaultVoiceCloseButton({ onClose, theme }) {
310
+ return (<react_native_1.Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel="End voice chat" style={({ pressed }) => [
311
+ muteStyles.button,
312
+ {
313
+ backgroundColor: theme?.colors?.surface ?? 'rgba(0,0,0,0.06)',
314
+ opacity: pressed ? 0.75 : 1,
315
+ },
316
+ ]}>
317
+ <react_native_1.Text style={{ fontSize: 24, lineHeight: 26, color: theme?.colors?.text ?? '#111' }}>
318
+ {'×'}
319
+ </react_native_1.Text>
320
+ </react_native_1.Pressable>);
321
+ }
309
322
  /**
310
323
  * Minimal microphone glyph built with stock Views — capsule body, stand
311
324
  * post, and base bar. No external icon font, no SVG, no Skia.
@@ -408,7 +421,7 @@ function ChatTranscript({ history, liveAiText, state, primaryTextColor, secondar
408
421
  </react_native_1.View>);
409
422
  }
410
423
  // --- Main component ---
411
- function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, canResume = false, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }) {
424
+ function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, canResume = false, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, onClose, }) {
412
425
  const Indicator = voiceComponents?.VoiceIndicator || DefaultVoiceIndicator;
413
426
  const Background = voiceComponents?.VoiceBackground || DefaultVoiceBackground;
414
427
  const Transcript = voiceComponents?.VoiceTranscript || DefaultVoiceTranscript;
@@ -424,6 +437,11 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
424
437
  const resumeHint = resumable && resumeLabel ? (<react_native_1.Text style={{ marginTop: 12, fontSize: 14, color: STATE_COLORS.idle }}>
425
438
  {resumeLabel}
426
439
  </react_native_1.Text>) : null;
440
+ // Leaves voice and returns to the chat page, so that does not require
441
+ // swiping back. Top left: the mic button owns the top right corner.
442
+ const CloseButton = voiceComponents?.VoiceCloseButton || DefaultVoiceCloseButton;
443
+ const showClose = typeof onClose === 'function';
444
+ const closeButton = showClose ? (<CloseButton onClose={onClose} theme={theme} state={state}/>) : null;
427
445
  const hasToolUI = !transcriptOverrideForTurn && (renderedTools?.length ?? 0) > 0;
428
446
  // When tool UI is rendered, the compact status bar takes over regardless of
429
447
  // chosen mode — preserves the single-screen / hands-free experience.
@@ -463,6 +481,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
463
481
  borderBottomColor: 'rgba(0,0,0,0.08)',
464
482
  backgroundColor: theme?.colors?.background ?? '#fff',
465
483
  }}>
484
+ {closeButton}
466
485
  <react_native_1.View style={{
467
486
  width: 44,
468
487
  height: 44,
@@ -605,6 +624,14 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
605
624
  </react_native_1.ScrollView>) : null}
606
625
  </react_native_1.View>
607
626
 
627
+ {showClose && !hasToolUI ? (<react_native_1.View style={{
628
+ position: 'absolute',
629
+ top: insets.top + 12,
630
+ left: 16,
631
+ }}>
632
+ {closeButton}
633
+ </react_native_1.View>) : null}
634
+
608
635
  {showMute && !hasToolUI ? (<react_native_1.View style={{
609
636
  position: 'absolute',
610
637
  // Absolute children aren't pushed by the parent's safe-area paddingTop,
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export { QafkaProvider } from './QafkaProvider';
5
5
  export { Qafka } from './Qafka';
6
- export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, QafkaHandle } from './Qafka.types';
6
+ export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, VoiceCloseButtonProps, QafkaHandle } from './Qafka.types';
7
7
  export type { ToolResponseMode } from './Qafka.types';
8
8
  export type { QafkaProps, QafkaProjectIds, ProjectIdOf } from './Qafka.types';
9
9
  export { MessageBubble } from './MessageBubble';
@@ -14,6 +14,11 @@ export interface QafkaDeviceInfo {
14
14
  */
15
15
  export declare function getDeviceInfo(): QafkaDeviceInfo;
16
16
  export declare function isAttestationAvailable(): boolean;
17
+ /**
18
+ * True when the native module reports an Android emulator. iOS simulators
19
+ * already report `isSupported() === false`, so they never need this flag.
20
+ */
21
+ export declare function isEmulator(): boolean;
17
22
  export declare function isSupported(): Promise<boolean>;
18
23
  export declare function generateKey(): Promise<string>;
19
24
  export declare function attestKey(keyId: string, challengeBase64: string): Promise<string>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Platform = void 0;
4
4
  exports.getDeviceInfo = getDeviceInfo;
5
5
  exports.isAttestationAvailable = isAttestationAvailable;
6
+ exports.isEmulator = isEmulator;
6
7
  exports.isSupported = isSupported;
7
8
  exports.generateKey = generateKey;
8
9
  exports.attestKey = attestKey;
@@ -31,6 +32,14 @@ function getDeviceInfo() {
31
32
  function isAttestationAvailable() {
32
33
  return NativeAttestation !== null;
33
34
  }
35
+ /**
36
+ * True when the native module reports an Android emulator. iOS simulators
37
+ * already report `isSupported() === false`, so they never need this flag.
38
+ */
39
+ function isEmulator() {
40
+ const mod = react_native_1.NativeModules.QafkaAttestation;
41
+ return mod?.isEmulator === true;
42
+ }
34
43
  async function isSupported() {
35
44
  if (!NativeAttestation)
36
45
  return false;
@@ -74,12 +74,18 @@ class AttestationManager {
74
74
  // is essential for diagnosing release-build failures where the JS-side
75
75
  // error overlay only shows a generic "App configuration error".
76
76
  this.log(`[Qafka:init] platform=${react_native_1.Platform.OS} moduleLinked=${moduleLinked}`, `[Qafka:init] platform=${react_native_1.Platform.OS} url=${this.config.apiUrl} moduleLinked=${moduleLinked}`);
77
- const supported = await NativeAttestation.isSupported();
78
- this.log(`[Qafka:init] isSupported=${supported}`);
77
+ // Android emulators report Android 8+ but cannot produce a Google-rooted
78
+ // attestation. Only development builds treat them as unsupported; release
79
+ // builds keep attesting so an emulator is never let through unverified.
80
+ const devEmulator = __DEV__ && react_native_1.Platform.OS === 'android' && NativeAttestation.isEmulator();
81
+ const supported = devEmulator ? false : await NativeAttestation.isSupported();
82
+ this.log(`[Qafka:init] isSupported=${supported} devEmulator=${devEmulator}`);
79
83
  if (!supported) {
80
84
  const reason = !moduleLinked
81
85
  ? `the QafkaAttestation native module is not registered (rebuild your app after installing the SDK)`
82
- : `this device does not support attestation (Android 8.0+ / iOS 14+ required)`;
86
+ : devEmulator
87
+ ? `Android emulators cannot produce hardware-backed attestation; use a TEST API key`
88
+ : `this device does not support attestation (Android 8.0+ / iOS 14+ required)`;
83
89
  if (__DEV__) {
84
90
  console.warn(`[Qafka] Attestation unavailable in this dev environment (${reason}). ` +
85
91
  `Continuing without it; see the Qafka docs for local development setup.`);
@@ -299,6 +299,10 @@ You may optionally restyle the chips via `voiceComponents.dataChipList` / `voice
299
299
  <Qafka onUnavailable={() => setChatAvailable(false)} />
300
300
  {chatAvailable && <LauncherButton onPress={openChat} />}
301
301
  ```
302
+ - **`onVoiceClose`** — optional; fires when the close button on the voice page is pressed.
303
+ That button only leaves voice: the voice session ends and the widget returns to the chat
304
+ page, which stays open. `onClose` is not called — it still belongs to the chat page's own
305
+ close button. Restyle the button via `voiceComponents.VoiceCloseButton`.
302
306
 
303
307
  ---
304
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
4
  "description": "Drop-in AI assistant for React Native: chat, voice, and tool execution with screen-aware navigation.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",