@akhilpulse/samadhaan-session-replay 0.6.1 → 0.6.3

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/README.md CHANGED
@@ -140,8 +140,16 @@ cd ios && pod install
140
140
  the first time the assistant opens. If the user denies it, the voice bar
141
141
  shows "Mic blocked — allow microphone access in Settings" instead of
142
142
  pretending to listen.
143
- - **iOS** — add `NSMicrophoneUsageDescription` to `Info.plist` or the OS will
144
- kill the app when recording starts.
143
+ - **iOS** — add `NSMicrophoneUsageDescription` to `Info.plist` (**required**
144
+ without it iOS terminates the app the moment the mic is touched):
145
+ ```xml
146
+ <key>NSMicrophoneUsageDescription</key>
147
+ <string>Needed so the voice assistant can hear you.</string>
148
+ ```
149
+ Starting in **0.6.2 the SDK explicitly triggers the system mic prompt** when
150
+ the assistant opens (via the bundled native module — run `pod install` and
151
+ rebuild after upgrading). If the user denies it, the voice bar shows
152
+ "Mic blocked" instead of pretending to listen.
145
153
 
146
154
  #### Agent voice playback
147
155
 
@@ -148,6 +148,18 @@ RCT_EXPORT_METHOD(stop)
148
148
  [self tearDownEngine];
149
149
  }
150
150
 
151
+ // Explicitly triggers the iOS microphone permission prompt (first call shows
152
+ // the system dialog; later calls resolve with the remembered answer).
153
+ // REQUIRES NSMicrophoneUsageDescription in the host app's Info.plist —
154
+ // without it iOS terminates the app the moment the mic is touched.
155
+ RCT_EXPORT_METHOD(requestMicPermission:(RCTPromiseResolveBlock)resolve
156
+ rejecter:(__unused RCTPromiseRejectBlock)reject)
157
+ {
158
+ [[AVAudioSession sharedInstance] requestRecordPermission:^(BOOL granted) {
159
+ resolve(@(granted));
160
+ }];
161
+ }
162
+
151
163
  - (void)invalidate
152
164
  {
153
165
  [self tearDownEngine];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akhilpulse/samadhaan-session-replay",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, guided-tour overlays, and Shake-to-Assist AI voice guide.",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -56,7 +56,7 @@ let pixelCopyDisabled = false; // turned true after repeated runtime failures to
56
56
  let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
57
57
 
58
58
  const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
59
- const SDK_VERSION = "0.6.1";
59
+ const SDK_VERSION = "0.6.3";
60
60
 
61
61
  // ---- Module-level session singleton --------------------------------------
62
62
  // Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
@@ -857,36 +857,6 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
857
857
  </>
858
858
  )}
859
859
 
860
- {/* Shake-to-Assist: on-screen arrow pointing at the next element to tap. */}
861
- {assistantActive && assistantArrow && (() => {
862
- const { w, h } = viewSizeRef.current;
863
- const px = assistantArrow.nx * (w || 0);
864
- const py = assistantArrow.ny * (h || 0);
865
- // Keep the label bubble on-screen: flip below the target near the top edge.
866
- const below = py < 96;
867
- return (
868
- <View pointerEvents="none" style={StyleSheet.absoluteFill}>
869
- <View style={[styles.assistHalo, { left: px - 26, top: py - 26 }]} />
870
- <View style={[styles.assistDot, { left: px - 9, top: py - 9 }]} />
871
- {/* Arrow shaft pointing down at the target (or up when flipped). */}
872
- <View
873
- style={[
874
- styles.assistArrow,
875
- below
876
- ? { left: px - 9, top: py + 12 }
877
- : { left: px - 9, top: py - 30, transform: [{ rotate: "180deg" }] },
878
- ]}
879
- />
880
- {!!(assistantArrow.label || assistantArrow.instruction) && (
881
- <View style={[styles.assistBubble, { left: 16, right: 16, [below ? "top" : "bottom"]: below ? py + 36 : (h || 0) - py + 36 } as any]}>
882
- {!!assistantArrow.label && <Text style={styles.assistBubbleLabel}>{assistantArrow.label}</Text>}
883
- {!!assistantArrow.instruction && <Text style={styles.assistBubbleText}>{assistantArrow.instruction}</Text>}
884
- </View>
885
- )}
886
- </View>
887
- );
888
- })()}
889
-
890
860
  {/* Floating help button — draggable, tap to open the AI voice guide.
891
861
  This is the definitive trigger for Expo apps (no native shake module).
892
862
  Hidden during guided tours so it never collides with the End-tour pill. */}
@@ -925,12 +895,81 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
925
895
  </View>
926
896
  </View>
927
897
  )}
898
+
899
+ {/* Shake-to-Assist: on-screen arrow pointing at the next element to tap.
900
+ Rendered LAST so it draws above the bottom voice dock — targets near
901
+ the bottom of the screen used to be hidden underneath it. */}
902
+ {assistantActive && assistantArrow && (
903
+ <AssistArrowOverlay
904
+ nx={assistantArrow.nx}
905
+ ny={assistantArrow.ny}
906
+ label={assistantArrow.label}
907
+ instruction={assistantArrow.instruction}
908
+ w={viewSizeRef.current.w}
909
+ h={viewSizeRef.current.h}
910
+ />
911
+ )}
928
912
  </View>
929
913
  </SessionReplayContext.Provider>
930
914
  );
931
915
  }
932
916
 
933
917
  // ---- Shake-to-Assist UI bits ----
918
+
919
+ /**
920
+ * Pulsing arrow + label bubble pointing at the next element to tap.
921
+ * - Halo pulses continuously so the target is hard to miss.
922
+ * - The label bubble is clamped so it never hides behind the bottom voice dock
923
+ * (which occupies roughly the bottom 110px of the screen).
924
+ */
925
+ function AssistArrowOverlay({
926
+ nx, ny, label, instruction, w, h,
927
+ }: { nx: number; ny: number; label?: string; instruction?: string; w: number; h: number }) {
928
+ const pulse = useRef(new Animated.Value(0)).current;
929
+ useEffect(() => {
930
+ const loop = Animated.loop(
931
+ Animated.sequence([
932
+ Animated.timing(pulse, { toValue: 1, duration: 650, useNativeDriver: true }),
933
+ Animated.timing(pulse, { toValue: 0, duration: 650, useNativeDriver: true }),
934
+ ])
935
+ );
936
+ loop.start();
937
+ return () => loop.stop();
938
+ }, [pulse]);
939
+
940
+ const px = nx * (w || 0);
941
+ const py = ny * (h || 0);
942
+ // Keep the label bubble on-screen: flip below the target near the top edge.
943
+ const below = py < 96;
944
+ // Never let the bubble sink behind the bottom voice dock (~110px tall).
945
+ const bubbleBottom = Math.max((h || 0) - py + 36, 122);
946
+ const scale = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.45] });
947
+ const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 0.35] });
948
+
949
+ return (
950
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
951
+ <Animated.View
952
+ style={[styles.assistHalo, { left: px - 26, top: py - 26, transform: [{ scale }], opacity }]}
953
+ />
954
+ <View style={[styles.assistDot, { left: px - 9, top: py - 9 }]} />
955
+ {/* Arrow shaft pointing down at the target (or up when flipped). */}
956
+ <View
957
+ style={[
958
+ styles.assistArrow,
959
+ below
960
+ ? { left: px - 9, top: py + 12 }
961
+ : { left: px - 9, top: py - 30, transform: [{ rotate: "180deg" }] },
962
+ ]}
963
+ />
964
+ {!!(label || instruction) && (
965
+ <View style={[styles.assistBubble, { left: 16, right: 16, [below ? "top" : "bottom"]: below ? py + 36 : bubbleBottom } as any]}>
966
+ {!!label && <Text style={styles.assistBubbleLabel}>{label}</Text>}
967
+ {!!instruction && <Text style={styles.assistBubbleText}>{instruction}</Text>}
968
+ </View>
969
+ )}
970
+ </View>
971
+ );
972
+ }
934
973
  function assistantStatusLabel(phase: AssistantPhase, micStatus?: MicStatus | null): string {
935
974
  // Be honest when we can't actually hear the user — a fake "Listening…"
936
975
  // with a dead mic is the worst possible UX (bar stuck forever).
package/src/voice.ts CHANGED
@@ -37,25 +37,44 @@ function loadOptional<T = any>(name: string): T | null {
37
37
  /**
38
38
  * Android requires a runtime RECORD_AUDIO grant — react-native-live-audio-stream
39
39
  * does NOT request it itself; without it the recorder silently captures nothing
40
- * and the assistant can never hear the user. iOS shows its own prompt when the
41
- * recorder starts (host app must have NSMicrophoneUsageDescription in Info.plist).
40
+ * and the assistant can never hear the user.
41
+ * iOS (SDK 0.6.2+): the bundled native module explicitly triggers the system
42
+ * mic prompt via AVAudioSession requestRecordPermission. The host app MUST have
43
+ * NSMicrophoneUsageDescription in Info.plist or iOS kills the app on first use.
44
+ * On older iOS builds without the native method, we fall through and let the
45
+ * recorder's own first mic access raise the prompt.
42
46
  */
43
47
  async function ensureMicPermission(): Promise<boolean> {
44
- if (Platform.OS !== "android") return true;
45
- try {
46
- const perm = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
47
- const already = await PermissionsAndroid.check(perm);
48
- if (already) return true;
49
- const res = await PermissionsAndroid.request(perm, {
50
- title: "Microphone access",
51
- message: "Needed so the voice assistant can hear you.",
52
- buttonPositive: "Allow",
53
- buttonNegative: "Not now",
54
- });
55
- return res === PermissionsAndroid.RESULTS.GRANTED;
56
- } catch {
57
- return false;
48
+ if (Platform.OS === "android") {
49
+ try {
50
+ const perm = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
51
+ const already = await PermissionsAndroid.check(perm);
52
+ if (already) return true;
53
+ const res = await PermissionsAndroid.request(perm, {
54
+ title: "Microphone access",
55
+ message: "Needed so the voice assistant can hear you.",
56
+ buttonPositive: "Allow",
57
+ buttonNegative: "Not now",
58
+ });
59
+ return res === PermissionsAndroid.RESULTS.GRANTED;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ if (Platform.OS === "ios") {
65
+ const native: any = (NativeModules as any).SessionReplayPcmPlayer;
66
+ if (native && typeof native.requestMicPermission === "function") {
67
+ try {
68
+ return !!(await native.requestMicPermission());
69
+ } catch {
70
+ // Native call failed — don't block; recorder start may still prompt.
71
+ return true;
72
+ }
73
+ }
74
+ // Old build without the native method — recorder start triggers the prompt.
75
+ return true;
58
76
  }
77
+ return true;
59
78
  }
60
79
 
61
80
  /** Why voice input isn't working (surfaced so the UI can tell the user). */