From 304618edf41de54a1a0a3d95addfc112d4520009 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 28 Oct 2026 09:09:03 +0100 Subject: [PATCH 1/5] feat(ai-components): add VoiceGlow and VoiceWave voice-agent visualizations (#3895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket: https://linear.app/stream/issue/RN-468 ## 🎯 Goal Android (GetStream/stream-chat-android#6848, GetStream/stream-chat-android#6849, GetStream/stream-chat-android#6851), iOS (GetStream/stream-chat-swift#4338) and web (GetStream/stream-chat-react#3339) added two voice-agent visualizations to their AI components. This brings them to `stream-chat-react-native/ai-components` (and `stream-chat-expo/ai-components`) with the same motion, so the platforms look and move alike, and shows them in a new voice mode in `ExpoChatGPTSample`. ## 🛠 Implementation details **Public API** (from the `ai-components` entry point): - `VoiceWave`: a single string carrying a wave packet that erupts with speech, its colors flowing along it. - `VoiceGlow`: a soft globe whose color masses orbit inside it and swell with speech. - `useDictationAudioLevel()`: the microphone's live level while dictation listens, as a Reanimated `SharedValue`. - Types `VoiceAgentState`, `VoiceVisualizationProps`, `VoiceWaveProps`, `VoiceGlowProps`, `DictationLevel`; `useDictationState` and `useDictationTranscript` are now exported too. - An optional `voiceVisualization` theme key: `colors` (default palette, the accent color) and `backdropColor` (light or dark look; the system color scheme when unset). Both components take `state` (`idle | connecting | listening | thinking | speaking`), `colors` (one to five colors), `expressivity` (0 to 2) and `audioLevel` (a `SharedValue`, read once a frame on the UI thread; `null` animates speech on its own). Other `View` props go to the root. **How it's built:** - One motion driver, ported from Android's `VoiceVisualizationDriver` (per-state motion table, frame-rate-independent easing, reduced motion at 0.07x, tap bounce, HSV palette, and the `Steady`/`Smooth` level responses for glow and wave). It's plain objects and worklets, advanced in a Reanimated `useFrameCallback`, so the motion runs on the UI thread and keeps going while JS is busy streaming a reply. - `VoiceGlow` is a Skia `RuntimeEffect`: Android's AGSL shader is SkSL, so it's ported nearly verbatim and runs on every Android version. Every glow shares one compiled effect. There is no Canvas/CSS fallback and no `rendering` prop. - `VoiceWave` is a Skia path with a 13-stop gradient, as Android draws it; its speech envelope uses the shader's float32 simplex noise (as on iOS and the web). - Drawn at most 2x for the glow and 3x for the wave, as on iOS. - Touches are only observed on a hit area the size of the shape, so a parent `Pressable` still fires and touches beside the shape pass through. Hover ripples with a mouse or pen. - Accessibility follows native: "Voice agent" as the label and the state as the value; Reduce Motion is honoured. - Where native and web differ, this follows native (e.g. no web-only wave smoothing). **Native dictation:** - New `AIDictationOnLevel` event (no codegen spec change; it goes through the existing `NativeEventEmitter`). Android maps the recognizer's `onRmsChanged`; iOS measures the audio tap in dBFS, throttled to ~30 Hz, tuned on an iPhone for `.measurement` mode (-60 to -35 dBFS). - Fix: iOS dictation now remembers the `AVAudioSession` configuration it found and restores it when dictation ends. It used to leave the session in the `.record` category, which muted all playback in the app after the first dictation and kept other apps' audio ducked. **Sample:** `ExpoChatGPTSample` gets a full-screen voice mode (`VoiceGlow` for the agent, `VoiceWave` on the microphone level). It listens with dictation, sends the transcript, follows the agent's AI state, reads the reply aloud with `expo-speech` and listens again. The send path moved into a shared `useSendAIMessage`, and the demo backend is now called with `platform: 'openai'`, as in the React example (its Anthropic key currently fails `/summarize`). **Entry isolation:** everything lives under `src/ai-components`; `validate-entry-isolation` passes. ## 🎨 UI Changes Voice mode in `ExpoChatGPTSample`, on a physical iPhone 15 Pro Max and the iOS simulator. Screenshots and a recording to be added. ## 🧪 Testing - Unit tests: Android's 22 driver tests and the bounce tests ported; palette, input clamping, simplex noise against GPU readback values, hit areas, wave geometry, rendering, accessibility label and value, theme fallbacks, reduced motion, and the dictation level hook. `yarn test:unit`, `yarn lint`, `yarn typecheck` and `yarn build` pass. - The SkSL shader compiles and renders under CanvasKit, matching Android's Paparazzi snapshot. - iOS simulator: both visualizations render and animate, and the voice-mode loop runs (listening, thinking, speaking, back to listening). Speech recognition itself fails on iOS 27 simulators ("Failed to initialize recognizer"), so transcription was faked there. - Physical iPhone: dictation, the mic level driving the wave, the spoken reply and the full voice loop work. - Not yet tested: Android on a device (the Android level mapping is untuned), SampleApp. ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [x] Documentation is updated (AI components README; the React Native v9 AI integration docs are in a separate getstream.io PR) - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android (iOS done, Android pending) --- .../components/VoiceMode/VoiceMode.tsx | 132 ++++++++ .../components/VoiceMode/index.ts | 1 + .../VoiceMode/useVoiceConversation.ts | 159 +++++++++ .../hooks/useSendAIMessage.ts | 35 ++ examples/ExpoChatGPTSample/http/requests.ts | 9 +- examples/ExpoChatGPTSample/package.json | 1 + .../ExpoChatGPTSample/screens/ChatContent.tsx | 30 +- package/jest-setup.tsx | 56 +++- .../dictation/AIDictationController.kt | 34 +- package/shared-native/ios/AIDictation.mm | 13 +- .../shared-native/ios/AIDictationModule.swift | 68 +++- package/src/ai-components/README.md | 52 ++- .../__tests__/VoiceVisualization.test.tsx | 208 ++++++++++++ .../__tests__/dictation.test.tsx | 59 +++- .../__tests__/voiceDriver.test.ts | 317 ++++++++++++++++++ .../__tests__/voicePalette.test.ts | 124 +++++++ package/src/ai-components/index.ts | 4 +- .../message-composer/ComposerView.tsx | 9 +- .../message-composer/SpeechToTextButton.tsx | 7 +- .../src/ai-components/native/AIDictation.ts | 11 + .../ai-components/store/dictation/store.ts | 9 + .../store/dictation/useDictationState.ts | 3 +- package/src/ai-components/store/index.ts | 2 + package/src/ai-components/theme.ts | 16 + .../transcription/useDictation.ts | 19 +- .../transcription/useDictationAudioLevel.ts | 38 +++ .../voice-visualization/VoiceGlow.tsx | 79 +++++ .../VoiceVisualizationView.tsx | 179 ++++++++++ .../voice-visualization/VoiceWave.tsx | 67 ++++ .../voice-visualization/bounce.ts | 61 ++++ .../voice-visualization/driver.ts | 240 +++++++++++++ .../voice-visualization/glowShader.ts | 227 +++++++++++++ .../voice-visualization/index.ts | 3 + .../ai-components/voice-visualization/math.ts | 73 ++++ .../voice-visualization/palette.ts | 59 ++++ .../voice-visualization/simplex.ts | 92 +++++ .../voice-visualization/styles.ts | 42 +++ .../voice-visualization/types.ts | 44 +++ .../voice-visualization/useReduceMotion.ts | 34 ++ .../useVoiceVisualization.ts | 153 +++++++++ .../voice-visualization/waveGeometry.ts | 137 ++++++++ yarn.lock | 10 + 42 files changed, 2863 insertions(+), 53 deletions(-) create mode 100644 examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx create mode 100644 examples/ExpoChatGPTSample/components/VoiceMode/index.ts create mode 100644 examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts create mode 100644 examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts create mode 100644 package/src/ai-components/__tests__/VoiceVisualization.test.tsx create mode 100644 package/src/ai-components/__tests__/voiceDriver.test.ts create mode 100644 package/src/ai-components/__tests__/voicePalette.test.ts create mode 100644 package/src/ai-components/transcription/useDictationAudioLevel.ts create mode 100644 package/src/ai-components/voice-visualization/VoiceGlow.tsx create mode 100644 package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx create mode 100644 package/src/ai-components/voice-visualization/VoiceWave.tsx create mode 100644 package/src/ai-components/voice-visualization/bounce.ts create mode 100644 package/src/ai-components/voice-visualization/driver.ts create mode 100644 package/src/ai-components/voice-visualization/glowShader.ts create mode 100644 package/src/ai-components/voice-visualization/index.ts create mode 100644 package/src/ai-components/voice-visualization/math.ts create mode 100644 package/src/ai-components/voice-visualization/palette.ts create mode 100644 package/src/ai-components/voice-visualization/simplex.ts create mode 100644 package/src/ai-components/voice-visualization/styles.ts create mode 100644 package/src/ai-components/voice-visualization/types.ts create mode 100644 package/src/ai-components/voice-visualization/useReduceMotion.ts create mode 100644 package/src/ai-components/voice-visualization/useVoiceVisualization.ts create mode 100644 package/src/ai-components/voice-visualization/waveGeometry.ts diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx new file mode 100644 index 0000000000..d1a0984bff --- /dev/null +++ b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Svg, { Line } from 'react-native-svg'; + +import { + StreamTheme, + useDictationAudioLevel, + type VoiceAgentState, + VoiceGlow, + VoiceWave, +} from 'stream-chat-expo/ai-components'; + +import { useVoiceConversation } from './useVoiceConversation'; + +const BACKDROP = '#0b0b10'; +const COLORS = ['#005fff', '#7b5cff', '#00c2ff']; + +const LABELS: Record = { + connecting: 'Connecting…', + idle: 'Tap the glow to talk', + listening: 'Listening', + speaking: 'Speaking', + thinking: 'Thinking', +}; + +const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { + const { top, bottom } = useSafeAreaInsets(); + const { error, listen, phase, userIsTalking } = useVoiceConversation(true); + const micLevel = useDictationAudioLevel(); + + return ( + + + + + ✕ + + + + + + + {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]} + + + + + ); +}; + +/** + * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame + * for as long as the chat is open. + */ +const VoiceIcon = () => ( + + {[ + [3, 8, 14], + [7, 4, 18], + [11, 2, 20], + [15, 5, 17], + [19, 9, 13], + ].map(([x, y1, y2]) => ( + + ))} + +); + +/** Opens a full-screen voice conversation with the AI agent. */ +export const VoiceMode = () => { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(true)} + style={styles.button} + > + + + + setOpen(false)} visible={open}> + {open ? setOpen(false)} /> : null} + + + ); +}; + +const styles = StyleSheet.create({ + button: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderColor: '#ececec', + borderRadius: 24, + borderWidth: 1, + height: 48, + justifyContent: 'center', + overflow: 'hidden', + width: 48, + }, + close: { color: '#ffffff', fontSize: 22 }, + entry: { alignItems: 'flex-end', paddingHorizontal: 16, paddingBottom: 8 }, + glow: { aspectRatio: 1, width: '100%' }, + glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, + header: { alignItems: 'flex-end', paddingHorizontal: 20 }, + label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' }, + overlay: { backgroundColor: BACKDROP, flex: 1 }, + wave: { alignSelf: 'stretch', height: 140 }, +}); diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts new file mode 100644 index 0000000000..abe97f1086 --- /dev/null +++ b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts @@ -0,0 +1 @@ +export * from './VoiceMode'; diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts new file mode 100644 index 0000000000..adb5e2627e --- /dev/null +++ b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; + +import * as Speech from 'expo-speech'; + +import { AIStates, useAIState, useChannelContext, useStableCallback } from 'stream-chat-expo'; + +import { + dictationStore, + useDictation, + useDictationState, + useDictationTranscript, + type VoiceAgentState, +} from 'stream-chat-expo/ai-components'; + +import { useSendAIMessage } from '../../hooks/useSendAIMessage'; + +/** A turn the agent is never seen working on ends after this long. */ +const TURN_TIMEOUT_MS = 20_000; + +const WORKING_STATES: string[] = [AIStates.Thinking, AIStates.Generating, AIStates.ExternalSources]; + +// Marks voice mode's dictation as its own, so the composer underneath ignores it. +const DICTATION_SOURCE = { source: 'voice-mode' }; + +/** Markdown read aloud as plain words. */ +const toSpeech = (text: string) => + text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[*_#>`~|]/g, '') + .replace(/\s+/g, ' ') + .trim(); + +/** + * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard, + * waits for the agent to answer, reads the answer aloud and listens again. + * + * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to, + * for `VoiceWave`. + */ +export const useVoiceConversation = (active: boolean) => { + const [phase, setPhase] = useState('idle'); + const { start, cancel } = useDictation(undefined, DICTATION_SOURCE); + const { state: dictationState, error } = useDictationState(); + const { transcript } = useDictationTranscript(); + const send = useSendAIMessage(); + const { channel } = useChannelContext(); + const { aiState } = useAIState(channel); + + // The messages already in the channel when the question was sent, so the reply is found by + // identity rather than by comparing the server's timestamps with the device's clock. + const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null); + const timeout = useRef | null>(null); + // An error left over from an earlier turn, ignored until dictation starts again and clears it. + const staleError = useRef(dictationStore.getLatestValue().error); + + const listen = useStableCallback(() => { + if (!active) return; + turn.current = null; + staleError.current = dictationStore.getLatestValue().error; + setPhase('connecting'); + void start(); + }); + + const latestReply = useStableCallback(() => { + const seenIds = turn.current?.seenIds ?? new Set(); + const messages = channel?.state.latestMessages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (seenIds.has(message.id)) break; + if (message.ai_generated && message.text) return message.text; + } + return null; + }); + + /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */ + const finishTurn = useStableCallback(() => { + if (timeout.current) clearTimeout(timeout.current); + timeout.current = null; + const reply = latestReply(); + turn.current = null; + if (!active) return; + if (!reply) { + listen(); + return; + } + setPhase('speaking'); + Speech.speak(toSpeech(reply), { + onDone: listen, + onError: listen, + // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in. + useApplicationAudioSession: false, + }); + }); + + // Starts listening when voice mode opens, and stops everything when it closes. + useEffect(() => { + if (!active) return; + listen(); + return () => { + if (timeout.current) clearTimeout(timeout.current); + timeout.current = null; + turn.current = null; + cancel(); + void Speech.stop(); + setPhase('idle'); + }; + }, [active, cancel, listen]); + + // Follows dictation: listening while it records, and sends what it heard once it stops. + useEffect(() => { + if (!active || turn.current) return; + if (dictationState === 'listening') { + setPhase('listening'); + return; + } + if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return; + if (error && error !== staleError.current) { + setPhase('idle'); + return; + } + if (phase === 'connecting') return; + const text = transcript.trim(); + if (!text) { + // Nothing was heard; keep listening. + listen(); + return; + } + turn.current = { + sawWorking: false, + seenIds: new Set((channel?.state.latestMessages ?? []).map((message) => message.id)), + }; + setPhase('thinking'); + void send({ text }); + timeout.current = setTimeout(() => { + if (!turn.current?.sawWorking) finishTurn(); + }, TURN_TIMEOUT_MS); + }, [active, channel, dictationState, error, finishTurn, listen, phase, send, transcript]); + + // Follows the agent: its turn ends once it has been seen working and is done. + useEffect(() => { + const current = turn.current; + if (!active || !current) return; + if (WORKING_STATES.includes(aiState)) { + current.sawWorking = true; + } else if (current.sawWorking) { + finishTurn(); + } + }, [active, aiState, finishTurn]); + + return { + // Only an error from the current attempt, not one left over while dictation restarts. + error: phase === 'idle' ? error : null, + listen, + phase, + userIsTalking: phase === 'listening', + }; +}; diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts new file mode 100644 index 0000000000..c81147a43b --- /dev/null +++ b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts @@ -0,0 +1,35 @@ +import { useMessageComposer, useMessageInputContext, useStableCallback } from 'stream-chat-expo'; + +import type { ComposerViewProps } from 'stream-chat-expo/ai-components'; + +export type AIMessage = Parameters[0]; + +/** + * Sends a message to the AI channel through the channel's composer, with its attachments. Shared + * by the composer and voice mode, so both go through `preSendMessageRequest` (which starts the + * agent on the first message). + */ +export const useSendAIMessage = () => { + const messageComposer = useMessageComposer(); + const { sendMessage } = useMessageInputContext(); + + return useStableCallback(async ({ text, attachments }: AIMessage) => { + messageComposer.textComposer.setText(text); + if (attachments && attachments.length > 0) { + const localAttachments = await Promise.all( + attachments.map((asset) => + messageComposer.attachmentManager.fileToLocalUploadAttachment({ + ...asset, + // The picker can leave these out (e.g. some iOS photos have no file name). + name: asset.name ?? asset.uri.split('/').pop() ?? 'image', + size: asset.size ?? 0, + type: asset.type ?? 'image/jpeg', + }), + ), + ); + messageComposer.attachmentManager.upsertAttachments(localAttachments); + } + + await sendMessage(); + }); +}; diff --git a/examples/ExpoChatGPTSample/http/requests.ts b/examples/ExpoChatGPTSample/http/requests.ts index e8c20536b0..fce4cd3941 100644 --- a/examples/ExpoChatGPTSample/http/requests.ts +++ b/examples/ExpoChatGPTSample/http/requests.ts @@ -1,12 +1,19 @@ import { post } from './api'; +// The AI provider the sample backend uses, as in the React SDK's ai-chatbot example. +const PLATFORM = 'openai'; + export const startAI = (channelId: string) => post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', { channel_id: channelId, + platform: PLATFORM, }); export const stopAI = (channelId: string) => post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', { channel_id: channelId, }); export const summarize = (text: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { text }); + post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { + platform: PLATFORM, + text, + }); diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index c044710ef1..f914847373 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -19,6 +19,7 @@ "expo-image-picker": "~58.0.10", "expo-linking": "~58.0.12", "expo-router": "~58.0.18", + "expo-speech": "~58.0.3", "react": "19.3.0", "react-native": "0.88.0", "react-native-gesture-handler": "~3.4.0", diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index d715ccbee1..b12acf5b3f 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -17,9 +17,7 @@ import { ThemeProvider, useAIState, useChannelContext, - useMessageComposer, useMessageContext, - useMessageInputContext, useStableCallback, useTheme, WithComponents, @@ -28,12 +26,13 @@ import { import { AITypingIndicatorView, ComposerView, - type ComposerViewProps, StreamingMessageView, } from 'stream-chat-expo/ai-components'; import { bottomSheetOptions } from '../bottomSheetOptions'; +import { VoiceMode } from '../components/VoiceMode'; import { useAppContext } from '../contexts/AppContext'; +import { useSendAIMessage } from '../hooks/useSendAIMessage'; import { startAI, summarize } from '../http/requests'; const CustomMessage = (props: MessageProps) => { @@ -93,8 +92,6 @@ const CustomStreamingMessageView = () => { }; const CustomComposerView = () => { - const messageComposer = useMessageComposer(); - const { sendMessage } = useMessageInputContext(); const { channel } = useChannelContext(); const { aiState } = useAIState(channel); @@ -114,27 +111,7 @@ const CustomComposerView = () => { [safeAreaInsets], ); - const serializeToMessage = useStableCallback( - async ({ text, attachments }: Parameters[0]) => { - messageComposer.textComposer.setText(text); - if (attachments && attachments.length > 0) { - const localAttachments = await Promise.all( - attachments.map((asset) => - messageComposer.attachmentManager.fileToLocalUploadAttachment({ - ...asset, - // The picker can leave these out (e.g. some iOS photos have no file name). - name: asset.name ?? asset.uri.split('/').pop() ?? 'image', - size: asset.size ?? 0, - type: asset.type ?? 'image/jpeg', - }), - ), - ); - messageComposer.attachmentManager.upsertAttachments(localAttachments); - } - - await sendMessage(); - }, - ); + const serializeToMessage = useSendAIMessage(); return ( { > + diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx index e6b56b908c..e4519c92c7 100644 --- a/package/jest-setup.tsx +++ b/package/jest-setup.tsx @@ -65,8 +65,16 @@ jest.mock('react-native-reanimated', () => { makeMutable: (init: T) => ({ addListener: noop, removeListener: noop, value: init }), useSharedValue: (init: T) => new Proxy(RNReanimatedmock.useSharedValue(init), { - get: (target, prop) => - prop === 'addListener' || prop === 'removeListener' ? noop : Reflect.get(target, prop), + get: (target, prop) => { + if (prop === 'addListener' || prop === 'removeListener') return noop; + // The mock's shared values have no `modify`; run it in place, as on the UI thread. + if (prop === 'modify') { + return (modifier: (value: T) => T) => { + target.value = modifier(target.value); + }; + } + return Reflect.get(target, prop); + }, }), NativeEventsManager: class { attachEvents() {} @@ -150,13 +158,43 @@ jest.mock('react-native-teleport', () => { jest.mock('react-native-safe-area-context', () => mockSafeAreaContext); -// Skia and victory-native are optional peers used by the `ai-components` charts. Skia's JS needs its -// native module at import time, so both are replaced with inert stand-ins that render plain views. -jest.mock('@shopify/react-native-skia', () => ({ - LinearGradient: () => null, - matchFont: () => ({}), - vec: (x: number, y: number) => ({ x, y }), -})); +// Skia and victory-native are optional peers used by the `ai-components` charts and voice +// visualizations. Skia's JS needs its native module at import time, so both are replaced with inert +// stand-ins that render plain views. +jest.mock('@shopify/react-native-skia', () => { + const React = require('react'); + const rn = require('react-native'); + const Primitive = () => null; + const pathBuilder = () => { + const commands: Array<[string, number, number]> = []; + const builder = { + build: () => ({ commands }), + lineTo: (x: number, y: number) => { + commands.push(['L', x, y]); + return builder; + }, + moveTo: (x: number, y: number) => { + commands.push(['M', x, y]); + return builder; + }, + }; + return builder; + }; + return { + Canvas: ({ children, style }: { children?: unknown; style?: unknown }) => + React.createElement(rn.View, { style, testID: 'skia-canvas' }, children), + Fill: ({ children }: { children?: unknown }) => children ?? null, + LinearGradient: Primitive, + matchFont: () => ({}), + Path: Primitive, + Shader: Primitive, + Skia: { + PathBuilder: { Make: pathBuilder }, + RuntimeEffect: { Make: (source: string) => ({ source }) }, + }, + vec: (x: number, y: number) => ({ x, y }), + }; +}); jest.mock('victory-native', () => { const React = require('react'); diff --git a/package/shared-native/android/dictation/AIDictationController.kt b/package/shared-native/android/dictation/AIDictationController.kt index a06fae8cf9..69335507fa 100644 --- a/package/shared-native/android/dictation/AIDictationController.kt +++ b/package/shared-native/android/dictation/AIDictationController.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageManager import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.speech.RecognitionListener import android.speech.RecognizerIntent import android.speech.SpeechRecognizer @@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicBoolean * * Shared by the `AIDictation` TurboModule of both SDK packages (each package's codegen generates its * own `NativeAIDictationSpec`), which forward their calls here. Events are delivered through - * [emitEvent] with the names in [EVENT_ON_STATE], [EVENT_ON_RESULT] and [EVENT_ON_ERROR]. + * [emitEvent] with the names in [EVENT_ON_STATE], [EVENT_ON_RESULT], [EVENT_ON_ERROR] and + * [EVENT_ON_LEVEL]. */ class AIDictationController( private val reactContext: ReactApplicationContext, @@ -41,6 +43,16 @@ class AIDictationController( const val EVENT_ON_STATE = "AIDictationOnState" const val EVENT_ON_RESULT = "AIDictationOnResult" const val EVENT_ON_ERROR = "AIDictationOnError" + const val EVENT_ON_LEVEL = "AIDictationOnLevel" + + // The recognizer's RMS reads about -2 dB in a quiet room and up to about 10 dB for speech + // close to the microphone; this span maps onto the level from 0 to 1. + private const val LEVEL_FLOOR_DB = 0f + private const val LEVEL_CEILING_DB = 10f + + // The shortest time between two level events, about 30 a second as on iOS: the recognizer + // can report RMS faster than the voice visualizations need. + private const val LEVEL_INTERVAL_MS = 33L private const val STATE_IDLE = "idle" private const val STATE_STARTING = "starting" @@ -63,6 +75,7 @@ class AIDictationController( private var currentLocale: Locale = Locale.getDefault() private var silenceTimeoutMs: Long = DEFAULT_SILENCE_TIMEOUT_MS private var lastSpeechTimeMs: Long = 0L + private var lastLevelAtMs: Long = 0L private var isSpeechInProgress: Boolean = false @@ -328,7 +341,9 @@ class AIDictationController( lastSpeechTimeMs = System.currentTimeMillis() } - override fun onRmsChanged(rmsdB: Float) = Unit + override fun onRmsChanged(rmsdB: Float) { + emitLevel(rmsdB) + } override fun onBufferReceived(buffer: ByteArray?) = Unit override fun onEndOfSpeech() { @@ -471,6 +486,21 @@ class AIDictationController( emitEvent(EVENT_ON_STATE, map) } + /** + * Emits the microphone's level to JS, from 0 to 1, for the voice visualizations, at most about + * 30 times a second. The recognizer reports it as RMS in dB while it listens. + */ + private fun emitLevel(rmsdB: Float) { + val now = SystemClock.elapsedRealtime() + if (now - lastLevelAtMs < LEVEL_INTERVAL_MS) return + lastLevelAtMs = now + val level = ((rmsdB - LEVEL_FLOOR_DB) / (LEVEL_CEILING_DB - LEVEL_FLOOR_DB)).coerceIn(0f, 1f) + val map = Arguments.createMap().apply { + putDouble("level", level.toDouble()) + } + emitEvent(EVENT_ON_LEVEL, map) + } + /** * Emits a generic error event to JS with a code and message. */ diff --git a/package/shared-native/ios/AIDictation.mm b/package/shared-native/ios/AIDictation.mm index 5c657e9668..c3b0302ba4 100644 --- a/package/shared-native/ios/AIDictation.mm +++ b/package/shared-native/ios/AIDictation.mm @@ -17,6 +17,7 @@ static NSString *const AIDictationOnStateEventName = @"AIDictationOnState"; static NSString *const AIDictationOnResultEventName = @"AIDictationOnResult"; static NSString *const AIDictationOnErrorEventName = @"AIDictationOnError"; +static NSString *const AIDictationOnLevelEventName = @"AIDictationOnLevel"; @interface AIDictation () @end @@ -46,7 +47,12 @@ - (instancetype)init - (NSArray *)supportedEvents { - return @[ AIDictationOnStateEventName, AIDictationOnResultEventName, AIDictationOnErrorEventName ]; + return @[ + AIDictationOnStateEventName, + AIDictationOnResultEventName, + AIDictationOnErrorEventName, + AIDictationOnLevelEventName, + ]; } - (std::shared_ptr)getTurboModule: @@ -109,6 +115,11 @@ - (void)emitOnError:(NSDictionary *)value [self sendEventWithName:AIDictationOnErrorEventName body:value]; } +- (void)emitOnLevel:(NSDictionary *)value +{ + [self sendEventWithName:AIDictationOnLevelEventName body:value]; +} + @end #endif diff --git a/package/shared-native/ios/AIDictationModule.swift b/package/shared-native/ios/AIDictationModule.swift index b753af2c93..f933e8ed43 100644 --- a/package/shared-native/ios/AIDictationModule.swift +++ b/package/shared-native/ios/AIDictationModule.swift @@ -15,6 +15,7 @@ import React func emitOnState(_ value: NSDictionary) func emitOnResult(_ value: NSDictionary) func emitOnError(_ value: NSDictionary) + func emitOnLevel(_ value: NSDictionary) } @objc(AIDictationModule) @@ -67,6 +68,19 @@ public class AIDictationModule: NSObject { // Timer used for the simple silence monitor. private var silenceTimer: Timer? + // When the microphone level was last emitted, read and written only on the audio tap's thread. + private var lastLevelTime: TimeInterval = 0 + // The audio session as dictation found it, restored once dictation ends. Its `.record` + // category mutes all playback, so leaving it in place would silence the app. + private var savedAudioSession: ( + category: AVAudioSession.Category, + mode: AVAudioSession.Mode, + options: AVAudioSession.CategoryOptions + )? + // The loudness, in dBFS, that maps to a level of 0 and of 1. + private let LEVEL_FLOOR_DB: Float = -60 + private let LEVEL_CEILING_DB: Float = -35 + // Set while the permission prompts of the first `start()` are on screen. `stop()` and // `cancel()` clear it, which abandons that start. private var isAwaitingPermission: Bool = false @@ -194,6 +208,7 @@ public class AIDictationModule: NSObject { try self.startRecognition() } catch { self.isListening = false + self.stopAudioEngine() self.emitState(self.STATE_IDLE) self.emitError( self.ERROR_INTERNAL, @@ -282,15 +297,34 @@ public class AIDictationModule: NSObject { * - Uses `.record` category and `.measurement` mode for best recognition * quality. * - Ducks other audio and notifies other sessions on deactivation. + * - Remembers the session's previous configuration for `restoreAudioSession()`. * * Throws if the session cannot be configured or activated. */ private func setupAudioSession() throws { let session = AVAudioSession.sharedInstance() + if savedAudioSession == nil { + savedAudioSession = (session.category, session.mode, session.categoryOptions) + } try session.setCategory(.record, mode: .measurement, options: [.duckOthers]) try session.setActive(true, options: .notifyOthersOnDeactivation) } + /** + * Gives the audio session back as dictation found it: deactivates it, so audio that was ducked + * resumes, and restores its category, mode and options, so the app can play sound again. + * + * Failures are ignored: deactivation fails while other audio in the app is still running, and + * restoring the category is still worth trying then. + */ + private func restoreAudioSession() { + guard let saved = savedAudioSession else { return } + savedAudioSession = nil + let session = AVAudioSession.sharedInstance() + try? session.setActive(false, options: .notifyOthersOnDeactivation) + try? session.setCategory(saved.category, mode: saved.mode, options: saved.options) + } + /** * Starts a fresh `SFSpeechRecognitionTask` and connects it to `AVAudioEngine`. * @@ -324,6 +358,7 @@ public class AIDictationModule: NSObject { inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak self] (buffer, _) in self?.recognitionRequest?.append(buffer) + self?.measureLevel(buffer) } audioEngine.prepare() @@ -420,13 +455,15 @@ public class AIDictationModule: NSObject { } /** - * Stops the audio engine and removes any installed tap on the input node. + * Stops the audio engine, removes any installed tap on the input node and gives the audio + * session back. */ private func stopAudioEngine() { if audioEngine.isRunning { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) } + restoreAudioSession() } // ------------------------------------------------------------------------- @@ -549,6 +586,35 @@ public class AIDictationModule: NSObject { eventSink?.emitOnState(payload) } + /** + * Emits the microphone's level to JS for the voice visualizations, from 0 to 1, about 30 times + * a second at most. Called on the audio tap's thread. + * + * The level is the buffer's loudness in dBFS mapped onto 0 to 1. The session records in + * `.measurement` mode, which turns off the input's gain processing, so speech arrives quiet: + * about -52 to -44 dBFS at a normal distance on an iPhone. A quiet room (about -60 dBFS) reads + * 0 and loud speech close to the phone (about -35 dBFS) reads 1, so talking lands around the + * middle. + */ + private func measureLevel(_ buffer: AVAudioPCMBuffer) { + let now = ProcessInfo.processInfo.systemUptime + guard now - lastLevelTime >= 1.0 / 30.0, + let samples = buffer.floatChannelData?[0], + buffer.frameLength > 0 else { return } + lastLevelTime = now + let count = Int(buffer.frameLength) + var sum: Float = 0 + for i in 0..` | no | The level of whoever is talking, from 0 to 1, read once a frame on the UI thread. Feed it at 15 Hz or more for the motion to follow the words. While it is `null` or absent, speech animates on its own. | +| `colors` | `string[]` | no | One to five colors. Past five are ignored, and colors that cannot be read are skipped. Defaults to the theme's `voiceVisualization.colors`. | +| `expressivity` | `number` | no | From 0 to 2: how far and how fast it moves. Colors never change with it. Defaults to `1`. | +| `stateLabels` | `Partial>` | no | What screen readers read as the value for each state, for example to translate them. Defaults to "Ready", "Connecting", "Listening", "Thinking" and "Speaking". | + +```tsx +import { useDictationAudioLevel, VoiceGlow, VoiceWave } from 'stream-chat-react-native/ai-components'; + +const VoiceAgent = ({ agentState, isListening }) => { + const micLevel = useDictationAudioLevel(); + + return ( + <> + + + + ); +}; +``` + +The motion runs entirely on the UI thread with Reanimated and Skia, so the visualizations stay smooth while the JS thread is busy streaming a reply, and a changing `audioLevel` re-renders nothing. + +**Audio level.** `useDictationAudioLevel()` gives the microphone's level while `useDictation` (or the `SpeechToTextButton`) is listening, and `null` otherwise. For any other source, such as a remote participant's audio level from Stream Video, keep a shared value of your own and write to it: `const level = useSharedValue(null)`, then `level.value = participant.audioLevel`. + +**Interaction.** Tapping the shape bounces it and sends out a ripple, and a hovering mouse or pen ripples it too. Touches are only observed, never claimed, so a `Pressable` around the visualization still gets its press; touches beside the shape reach whatever is behind it. + +**Theming.** The theme's `voiceVisualization.colors` are the default palette (the accent color unless you set them). The visualizations draw for a light or a dark background following `voiceVisualization.backdropColor` when you set it, and the system color scheme otherwise. + +**Accessibility.** Each is an image named "Voice agent" with its state as the value ("Ready", "Connecting", "Listening", "Thinking", "Speaking"), as on iOS and Android. Pass `stateLabels` to translate the state values, `accessibilityLabel` to change the label, or `accessibilityValue` to replace the value outright. Under the system's Reduce Motion (animations off on Android), they barely move and ignore taps. + +**Differences from the native SDKs.** The glow has no `rendering` option: the shader runs on every Android version through Skia, so there is no Canvas fallback, and it does not switch to a cheaper look in battery saver as on Android. A visualization keeps animating while it is mounted, even when scrolled off screen; it pauses only while the app is in the background. + ### Theming Each one of the components in the SDK is fully theme-compatible. The `StreamTheme` provider takes care of this for you. diff --git a/package/src/ai-components/__tests__/VoiceVisualization.test.tsx b/package/src/ai-components/__tests__/VoiceVisualization.test.tsx new file mode 100644 index 0000000000..4953c7872a --- /dev/null +++ b/package/src/ai-components/__tests__/VoiceVisualization.test.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { AccessibilityInfo, Pressable } from 'react-native'; + +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; + +import { type DeepPartial, StreamTheme, type Theme, VoiceGlow, VoiceWave } from '../index'; +import { rgbToHsv } from '../voice-visualization/palette'; +import { GLOW_STYLE, WAVE_STYLE } from '../voice-visualization/styles'; +import type { VoiceVisualizationProps } from '../voice-visualization/types'; +import { useVoiceVisualization } from '../voice-visualization/useVoiceVisualization'; +import { waveColors, wavePath } from '../voice-visualization/waveGeometry'; + +const themed = + (style?: DeepPartial) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + +const layout = (width: number, height: number) => ({ + nativeEvent: { layout: { height, width, x: 0, y: 0 } }, +}); + +const renderVisualization = ( + props: Partial = {}, + style?: DeepPartial, + visualStyle = GLOW_STYLE, +) => + renderHook( + (hookProps: VoiceVisualizationProps) => useVoiceVisualization(hookProps, visualStyle), + { initialProps: { state: 'idle', ...props }, wrapper: themed(style) }, + ); + +const hueOf = (color: [number, number, number]) => rgbToHsv(color).hue; + +describe('VoiceGlow and VoiceWave', () => { + afterEach(() => jest.restoreAllMocks()); + + it.each([ + ['VoiceGlow', VoiceGlow], + ['VoiceWave', VoiceWave], + ])('%s names itself and its state, as on iOS and Android', (_, Component) => { + const { rerender } = render(, { wrapper: themed() }); + + const root = screen.getByLabelText('Voice agent'); + expect(root.props.accessibilityRole).toBe('image'); + expect(root.props.accessibilityValue).toEqual({ text: 'Ready' }); + + rerender(); + expect(screen.getByLabelText('Voice agent').props.accessibilityValue).toEqual({ + text: 'Speaking', + }); + }); + + it('takes a label and value of its own', () => { + render( + , + { wrapper: themed() }, + ); + + expect(screen.getByLabelText('Assistant').props.accessibilityValue).toEqual({ + text: 'Talking', + }); + }); + + it.each([ + ['VoiceGlow', VoiceGlow], + ['VoiceWave', VoiceWave], + ])('%s draws once it has a size', (_, Component) => { + render(, { wrapper: themed() }); + expect(screen.queryByTestId('skia-canvas')).toBeNull(); + + fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 200)); + + expect(screen.getByTestId('skia-canvas')).toBeTruthy(); + expect(screen.getByTestId('voice-visualization-hit-area')).toBeTruthy(); + }); + + it('lets a press on the shape reach a parent', () => { + const onPress = jest.fn(); + render( + + + , + { wrapper: themed() }, + ); + fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 300)); + + const hitArea = screen.getByTestId('voice-visualization-hit-area'); + fireEvent(hitArea, 'touchEnd', { nativeEvent: { locationX: 96, locationY: 96 } }); + fireEvent.press(hitArea); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('compiles the glow shader once for every glow', () => { + // A fresh copy of the module, so a shader compiled by an earlier test doesn't hide a miss. + jest.isolateModules(() => { + const { Skia: freshSkia } = require('@shopify/react-native-skia'); + const { getGlowEffect } = require('../voice-visualization/glowShader'); + const make = jest.spyOn(freshSkia.RuntimeEffect, 'Make'); + + const first = getGlowEffect(); + const second = getGlowEffect(); + + expect(make).toHaveBeenCalledTimes(1); + expect(second).toBe(first); + }); + }); + + it('keeps the touch area as a real view, so taps on the shape land', () => { + render(, { wrapper: themed() }); + fireEvent(screen.getByLabelText('Voice agent'), 'layout', layout(300, 200)); + + expect(screen.getByTestId('voice-visualization-hit-area').props.collapsable).toBe(false); + }); + + it('reads translated state values', () => { + render(, { + wrapper: themed(), + }); + + expect(screen.getByLabelText('Voice agent').props.accessibilityValue).toEqual({ + text: 'Denkt nach', + }); + }); +}); + +describe('voice visualization motion', () => { + afterEach(() => jest.restoreAllMocks()); + + it('uses its own colors first', () => { + const { result } = renderVisualization({ colors: ['#ff0000', 'nope', '#00ff00'] }); + + expect(result.current.driver.value.colors[0].hue).toBe(hueOf([1, 0, 0])); + expect(result.current.driver.value.colors[1].hue).toBe(hueOf([0, 1, 0])); + expect(result.current.driver.value.count).toBe(2); + }); + + it('falls back to the theme, then to the default accent', () => { + const fromTheme = renderVisualization( + { colors: [] }, + { + voiceVisualization: { colors: ['#00ff00'] }, + }, + ); + expect(fromTheme.result.current.driver.value.colors[0].hue).toBe(hueOf([0, 1, 0])); + + const fallback = renderVisualization( + { colors: ['nope'] }, + { + voiceVisualization: { colors: ['also-nope'] }, + }, + ); + expect(fallback.result.current.driver.value.colors[0].hue).toBe(hueOf([0, 95 / 255, 1])); + }); + + it('draws for a dark backdrop', () => { + const light = renderVisualization({}, { voiceVisualization: { backdropColor: '#ffffff' } }); + const dark = renderVisualization({}, { voiceVisualization: { backdropColor: '#101010' } }); + + expect(light.result.current.dark.value).toBe(0); + expect(dark.result.current.dark.value).toBe(1); + }); + + it('takes the wave’s smooth level response and the glow’s steady one', () => { + const glow = renderVisualization({}, undefined, GLOW_STYLE); + const wave = renderVisualization({}, undefined, WAVE_STYLE); + + expect(glow.result.current.driver.value.levelResponse).toBe(GLOW_STYLE.levelResponse); + expect(wave.result.current.driver.value.levelResponse).toBe(WAVE_STYLE.levelResponse); + }); + + it('bounces on a tap, unless motion is reduced', async () => { + const motion = renderVisualization(); + act(() => motion.result.current.tap(0.1, 0.1)); + expect(motion.result.current.driver.value.tapAge).toBe(0); + expect(motion.result.current.driver.value.tapX).toBe(0.1); + + jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValueOnce(true); + const reduced = renderVisualization(); + await act(async () => { + await Promise.resolve(); + }); + act(() => reduced.result.current.tap(0.1, 0.1)); + expect(reduced.result.current.driver.value.tapAge).toBeGreaterThan(0); + }); + + it('draws the wave as one stroke with its ends faded out', () => { + const { result } = renderVisualization({ state: 'speaking' }, undefined, WAVE_STYLE); + const d = result.current.driver.value; + + const path = wavePath(d, 300, 200) as unknown as { commands: Array<[string, number, number]> }; + expect(path.commands).toHaveLength(161); + expect(path.commands[0][0]).toBe('M'); + expect(path.commands[0][1]).toBeCloseTo(150 - 0.42 * 200, 4); + expect(path.commands[160][1]).toBeCloseTo(150 + 0.42 * 200, 4); + + const stops = waveColors(d, 0); + expect(stops).toHaveLength(13); + expect(stops[0][3]).toBe(0); + expect(stops[12][3]).toBe(0); + expect(stops[6][3]).toBeCloseTo(1, 4); + }); +}); diff --git a/package/src/ai-components/__tests__/dictation.test.tsx b/package/src/ai-components/__tests__/dictation.test.tsx index 83219584f9..4755ea5e5d 100644 --- a/package/src/ai-components/__tests__/dictation.test.tsx +++ b/package/src/ai-components/__tests__/dictation.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { DeviceEventEmitter } from 'react-native'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import * as aiComponents from '../index'; import { AIDictation } from '../native/AIDictation'; @@ -162,5 +162,62 @@ describe('ai-components dictation', () => { state: 'idle', }); }); + + it('streams the microphone level while listening and clears it afterwards', () => { + mockNativeModule = createNativeModule(); + const levels: number[] = []; + const unsubscribe = AIDictation.addLevelListener(({ level }) => levels.push(level)); + const { result, unmount } = renderHook(() => aiComponents.useDictationAudioLevel()); + expect(result.current.value).toBeNull(); + + act(() => { + DeviceEventEmitter.emit('AIDictationOnState', { state: 'listening' }); + DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.4 }); + }); + expect(levels).toEqual([0.4]); + expect(result.current.value).toBe(0.4); + + act(() => { + DeviceEventEmitter.emit('AIDictationOnState', { state: 'idle' }); + }); + expect(result.current.value).toBeNull(); + + act(() => { + DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.7 }); + DeviceEventEmitter.emit('AIDictationOnError', { code: 'X', message: 'boom' }); + }); + expect(result.current.value).toBeNull(); + + unsubscribe(); + unmount(); + act(() => { + DeviceEventEmitter.emit('AIDictationOnLevel', { level: 0.9 }); + }); + expect(levels).toEqual([0.4, 0.7]); + }); + + it('leaves the composer text alone for dictation started elsewhere', async () => { + mockNativeModule = createNativeModule(); + const composer = renderComposer(); + fireEvent.changeText(composer.getByPlaceholderText('Ask anything'), 'my draft'); + + const { result } = renderHook(() => + aiComponents.useDictation(undefined, { source: 'voice-mode' }), + ); + await act(async () => { + await result.current.start(); + }); + act(() => { + DeviceEventEmitter.emit('AIDictationOnState', { state: 'listening' }); + DeviceEventEmitter.emit('AIDictationOnResult', { isFinal: true, text: 'spoken question' }); + DeviceEventEmitter.emit('AIDictationOnState', { state: 'idle' }); + }); + + expect(aiComponents.dictationStore.getLatestValue()).toMatchObject({ + source: 'voice-mode', + transcript: 'spoken question', + }); + expect(composer.getByPlaceholderText('Ask anything').props.value).toBe('my draft'); + }); }); }); diff --git a/package/src/ai-components/__tests__/voiceDriver.test.ts b/package/src/ai-components/__tests__/voiceDriver.test.ts new file mode 100644 index 0000000000..4c49a1a68d --- /dev/null +++ b/package/src/ai-components/__tests__/voiceDriver.test.ts @@ -0,0 +1,317 @@ +import { + bounceScale, + createBounce, + easeOut, + scaleAt, + startBounce, +} from '../voice-visualization/bounce'; +import { + advanceDriver, + createDriverState, + driverReact, + driverScale, + ease, + hoverDriver, + LEVEL_RESPONSE, + type LevelResponse, + NO_TAP, + tapDriver, + type VoiceDriverInput, + type VoiceDriverState, +} from '../voice-visualization/driver'; +import type { Hsv, VoiceAgentState } from '../voice-visualization/types'; + +// Ported from stream-chat-android's VoiceVisualizationDriverTest and VoiceVisualizationBounceTest, +// so the platforms keep moving alike. + +const blue: Hsv = { hue: 218 / 360, saturation: 1, value: 1 }; +const red: Hsv = { hue: 0, saturation: 1, value: 1 }; +const FRAME = 1 / 60; + +const input = ( + state: VoiceAgentState, + overrides: Partial> = {}, +): VoiceDriverInput => ({ + audioLevel: null, + colors: [blue], + expressivity: 1, + reduceMotion: false, + state, + ...overrides, +}); + +/** A driver with the input it reads each frame, as the view keeps them. */ +const createDriver = (initial: VoiceDriverInput, response?: LevelResponse) => { + const driver = { input: initial, state: createDriverState(initial, response) }; + return { + ...driver, + advance: (seconds: number) => advanceDriver(driver.state, driver.input, seconds), + run: (seconds: number) => { + for (let i = 0; i < Math.floor(seconds / FRAME); i++) { + advanceDriver(driver.state, driver.input, FRAME); + } + }, + setInput: (next: VoiceDriverInput) => { + driver.input = next; + }, + get d(): VoiceDriverState { + return driver.state; + }, + get input() { + return driver.input; + }, + }; +}; + +const expectNear = (actual: number, expected: number, tolerance: number) => + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance); + +describe('voice visualization driver', () => { + it('starts settled on the state', () => { + const { d } = createDriver(input('speaking')); + + expect(driverReact(d)).toBe(1); + expect(d.load).toBe(0); + expect(d.flow).toBe(0); + expect(d.bright).toBe(1); + expect(d.time).toBe(0); + expect(d.count).toBe(1); + expect(driverScale(d)).toBe(1); + expect(d.tapAge).toBe(NO_TAP); + expect(d.colors[0]).toEqual(blue); + expect(d.colors[4]).toEqual(blue); + }); + + it('does not share its colors with the caller’s palette', () => { + const palette = [{ ...blue }]; + const driver = createDriver(input('idle', { colors: palette })); + driver.setInput(input('idle', { colors: [red] })); + + driver.run(1); + + expect(palette[0]).toEqual(blue); + }); + + it('eases toward the motion of a new state', () => { + const driver = createDriver(input('idle')); + driver.setInput(input('connecting')); + + driver.advance(FRAME); + expectNear(driver.d.load, 0.08, 1e-4); + driver.run(2); + expectNear(driver.d.load, 1, 1e-3); + expectNear(driverReact(driver.d), 0, 1e-3); + }); + + it('eases the same at any frame rate', () => { + const at60 = createDriver(input('idle')); + const at120 = createDriver(input('idle')); + at60.setInput(input('speaking')); + at120.setInput(input('speaking')); + + for (let i = 0; i < 30; i++) at60.advance(1 / 60); + for (let i = 0; i < 60; i++) at120.advance(1 / 120); + + expectNear(driverReact(at60.d), driverReact(at120.d), 1e-4); + expectNear(at60.d.time, at120.d.time, 1e-2); + }); + + it('clamps a long frame', () => { + const driver = createDriver(input('idle')); + + driver.advance(5); + + expectNear(driver.d.time, 0.05 * 0.4, 1e-5); + }); + + it('turns the flow only while thinking', () => { + const driver = createDriver(input('speaking')); + driver.run(1); + expect(driver.d.flowSpin).toBe(0); + + driver.setInput(input('thinking')); + driver.run(1); + expect(driver.d.flowSpin).toBeGreaterThan(0); + }); + + it('keeps the colors turning in every state', () => { + const driver = createDriver(input('idle')); + + driver.run(1); + + expect(driver.d.orbSpin).toBeGreaterThan(0); + }); + + it('scales amplitude and speed with expressivity', () => { + const lively = createDriver(input('speaking', { expressivity: 2 })); + const still = createDriver(input('speaking', { expressivity: 0 })); + + expectNear(driverReact(lively.d), 1.85, 1e-5); + expectNear(driverReact(still.d), 0.15, 1e-5); + lively.advance(0.05); + expectNear(lively.d.time, 0.05 * 1.25 * 1.4, 1e-5); + }); + + it('slows time and ignores the pointer under reduced motion', () => { + const driver = createDriver(input('idle', { reduceMotion: true })); + + tapDriver(driver.d, driver.input, 0.1, 0.1); + hoverDriver(driver.d, driver.input, 0.1, 0.1); + driver.advance(0.05); + + expectNear(driver.d.time, 0.05 * 0.07 * 0.4, 1e-6); + expect(driver.d.tapAge).toBeGreaterThan(NO_TAP); + expect(driverScale(driver.d)).toBe(1); + expect(driver.d.hoverAmount).toBe(0); + }); + + it('stops hovering once motion is reduced', () => { + const driver = createDriver(input('idle')); + hoverDriver(driver.d, driver.input, 0.1, 0.1); + driver.run(1); + + driver.setInput(input('idle', { reduceMotion: true })); + driver.run(1); + + expectNear(driver.d.hoverAmount, 0, 1e-3); + }); + + it('ripples from a tap and bounces', () => { + const driver = createDriver(input('idle')); + + tapDriver(driver.d, driver.input, 0.1, -0.2); + expect(driver.d.tapX).toBe(0.1); + expect(driver.d.tapY).toBe(-0.2); + expect(driver.d.tapAge).toBe(0); + + driver.advance(0.05); + expectNear(driver.d.tapAge, 0.05, 1e-6); + expect(driverScale(driver.d)).toBeLessThan(1); + driver.run(0.5); + expect(driverScale(driver.d)).toBe(1); + }); + + it('eases hovering in and out', () => { + const driver = createDriver(input('idle')); + + hoverDriver(driver.d, driver.input, 0.2, 0.1); + driver.run(1); + expectNear(driver.d.hoverAmount, 1, 1e-3); + expect(driver.d.hoverX).toBe(0.2); + + hoverDriver(driver.d, driver.input, null, null); + driver.run(1); + expectNear(driver.d.hoverAmount, 0, 1e-3); + expect(driver.d.hoverX).toBe(0.2); + }); + + it('lifts the motion to a live audio level', () => { + const driver = createDriver(input('idle', { audioLevel: 0.8 })); + + driver.run(2); + expectNear(driver.d.mic, 0.8, 1e-3); + expectNear(driver.d.voice, 1, 1e-3); + expectNear(driverReact(driver.d), 0.8, 1e-3); + + driver.setInput(input('idle')); + driver.run(2); + expectNear(driver.d.voice, 0, 1e-3); + expectNear(driverReact(driver.d), 0.12, 1e-3); + }); + + const settled = (response: LevelResponse, audioLevel: number) => { + const driver = createDriver(input('idle', { audioLevel }), response); + driver.run(1); + return driver; + }; + + it('catches up with a louder level within a word when smooth', () => { + const driver = settled(LEVEL_RESPONSE.smooth, 0); + + driver.setInput(input('idle', { audioLevel: 1 })); + driver.run(0.1); + expect(driver.d.mic).toBeGreaterThanOrEqual(0.5); + expect(driver.d.mic).toBeLessThanOrEqual(0.8); + driver.run(0.3); + expect(driver.d.mic).toBeGreaterThan(0.95); + }); + + it('lets a quieter level fall away over a pause, not a syllable, when smooth', () => { + const driver = settled(LEVEL_RESPONSE.smooth, 1); + + driver.setInput(input('idle', { audioLevel: 0 })); + driver.run(0.1); + expect(driver.d.mic).toBeGreaterThan(0.7); + driver.run(0.9); + expect(driver.d.mic).toBeLessThan(0.1); + }); + + it('follows a quieter level within a syllable when steady', () => { + const driver = settled(LEVEL_RESPONSE.steady, 1); + + driver.setInput(input('idle', { audioLevel: 0 })); + driver.run(0.1); + expect(driver.d.mic).toBeLessThan(0.1); + }); + + it('eases a hue the short way round', () => { + const driver = createDriver( + input('idle', { colors: [{ hue: 0.95, saturation: 1, value: 1 }] }), + ); + driver.setInput(input('idle', { colors: [{ hue: 0.05, saturation: 1, value: 1 }] })); + + driver.advance(FRAME); + expectNear(driver.d.colors[0].hue, 0.962, 1e-4); + driver.run(2); + expectNear(driver.d.colors[0].hue, 0.05, 1e-3); + }); + + it('repeats the first color in empty slots and crossfades the count', () => { + const driver = createDriver(input('idle', { colors: [blue, red] })); + expect(driver.d.colors[1]).toEqual(red); + expect(driver.d.colors[2]).toEqual(blue); + + driver.setInput(input('idle', { colors: [blue, red, red] })); + driver.advance(FRAME); + expectNear(driver.d.count, 2.12, 1e-4); + }); + + it('moves the per-frame share at 60 Hz', () => { + expectNear(ease(0.08, 1 / 60), 0.08, 1e-5); + expect(ease(0.08, 0)).toBe(0); + }); +}); + +describe('voice visualization bounce', () => { + it('follows the keyframes', () => { + expect(scaleAt(0)).toBe(1); + expectNear(scaleAt(0.15), 0.965, 1e-5); + expectNear(scaleAt(0.3), 0.93, 1e-5); + expectNear(scaleAt(0.62), 1.03, 1e-5); + expectNear(scaleAt(1), 1, 1e-5); + }); + + it('times them with the CSS ease-out', () => { + expectNear(easeOut(0), 0, 1e-4); + expectNear(easeOut(0.5), 0.684, 2e-3); + expectNear(easeOut(1), 1, 1e-4); + }); + + it('dips, overshoots and settles', () => { + const bounce = createBounce(); + expect(bounceScale(bounce)).toBe(1); + + startBounce(bounce); + const scales = Array.from({ length: 21 }, () => { + const scale = bounceScale(bounce); + bounce.age += 1 / 60; + return scale; + }); + + expectNear(scales[0], 1, 1e-9); + expectNear(Math.min(...scales), 0.93, 0.01); + expectNear(Math.max(...scales), 1.03, 0.01); + expect(scales.indexOf(Math.min(...scales))).toBeLessThan(scales.indexOf(Math.max(...scales))); + expect(bounceScale(bounce)).toBe(1); + }); +}); diff --git a/package/src/ai-components/__tests__/voicePalette.test.ts b/package/src/ai-components/__tests__/voicePalette.test.ts new file mode 100644 index 0000000000..fc2591b988 --- /dev/null +++ b/package/src/ai-components/__tests__/voicePalette.test.ts @@ -0,0 +1,124 @@ +import { audioLevelInput, expressivityInput, speechEnv } from '../voice-visualization/math'; +import { luminance, parseColor, rgbToHsv, toHsvPalette } from '../voice-visualization/palette'; +import { snoise } from '../voice-visualization/simplex'; +import { GLOW_STYLE, WAVE_STYLE } from '../voice-visualization/styles'; + +describe('voice visualization palette', () => { + it('keeps a color’s full hue, saturation and value', () => { + const hsv = rgbToHsv([0, 95 / 255, 1]); + + expect(hsv.hue * 360).toBeCloseTo(217.6, 1); + expect(hsv.saturation).toBeCloseTo(1, 4); + expect(hsv.value).toBeCloseTo(1, 4); + }); + + it('keeps a muted color muted', () => { + // hsv(108°, 25%, 60%) + const hsv = rgbToHsv([0.48, 0.6, 0.45]); + + expect(hsv.hue).toBeCloseTo(0.3, 2); + expect(hsv.saturation).toBeCloseTo(0.25, 2); + expect(hsv.value).toBeCloseTo(0.6, 4); + }); + + it('gives a gray no saturation', () => { + const hsv = rgbToHsv([0.5, 0.5, 0.5]); + + expect(hsv.saturation).toBe(0); + expect(hsv.value).toBe(0.5); + }); + + it.each([ + ['#005fff', [0, 95 / 255, 1]], + ['#0af', [0, 170 / 255, 1]], + ['#005fff80', [0, 95 / 255, 1]], + ['rgb(0, 95, 255)', [0, 95 / 255, 1]], + ['rgba(0, 95, 255, 0.5)', [0, 95 / 255, 1]], + ['hsl(0, 100%, 50%)', [1, 0, 0]], + ['rebeccapurple', [0.4, 0.2, 0.6]], + ['#000000', [0, 0, 0]], + ])('reads %s', (color, expected) => { + const rgb = parseColor(color); + expect(rgb).not.toBeNull(); + rgb?.forEach((channel, i) => expect(channel).toBeCloseTo(expected[i], 2)); + }); + + it('skips colors it cannot read', () => { + expect(parseColor('not-a-color')).toBeNull(); + expect(parseColor('#12345')).toBeNull(); + expect(parseColor('')).toBeNull(); + expect(toHsvPalette(['nope', '#ff0000'])).toHaveLength(1); + }); + + it('keeps only the first five colors', () => { + expect(toHsvPalette(Array.from({ length: 7 }, () => '#ff0000'))).toHaveLength(5); + }); + + it('measures luminance for the light or dark look', () => { + expect(luminance('#ffffff')).toBeCloseTo(1, 4); + expect(luminance('#000000')).toBe(0); + expect(luminance('#1c1e22')).toBeLessThan(0.5); + expect(luminance('')).toBeNull(); + }); +}); + +describe('voice visualization inputs', () => { + it('clamps expressivity to 0-2 and replaces one that is not a number', () => { + expect(expressivityInput(3)).toBe(2); + expect(expressivityInput(-1)).toBe(0); + expect(expressivityInput(0.5)).toBe(0.5); + expect(expressivityInput(Number.NaN)).toBe(1); + expect(expressivityInput(Number.POSITIVE_INFINITY)).toBe(1); + expect(expressivityInput(undefined)).toBe(1); + }); + + it('clamps the audio level to 0-1 and treats one that is not a number as no audio', () => { + expect(audioLevelInput(2)).toBe(1); + expect(audioLevelInput(-1)).toBe(0); + expect(audioLevelInput(0.4)).toBe(0.4); + expect(audioLevelInput(Number.NaN)).toBeNull(); + expect(audioLevelInput(null)).toBeNull(); + expect(audioLevelInput(undefined)).toBeNull(); + }); +}); + +describe('voice visualization noise', () => { + it('matches the shader’s simplex noise as a GPU computes it', () => { + // Read back from the shader's own snoise on a GPU (stream-chat-react's fixtures). + const gpu: Array<[number, number, number, number]> = [ + [0.3, 21, 0, 0.7696], + [1.7, 5, 0, 0.208], + [0.12, 0, 0, 0.049], + [2.5, 9, 0, 0.3869], + [10.2, 14, 0, 0.603], + [3.3, 1.1, 0.9, -0.7541], + [0.05, 0.05, 0.05, -0.5481], + ]; + for (const [x, y, z, expected] of gpu) { + expect(snoise(x, y, z)).toBeCloseTo(expected, 2); + } + }); + + it('gives speech phrases and pauses', () => { + const samples = Array.from({ length: 2000 }, (_, i) => speechEnv(i * 0.01)); + + expect(Math.min(...samples)).toBe(0); + expect(Math.max(...samples)).toBeGreaterThan(0.6); + expect(Math.max(...samples)).toBeLessThanOrEqual(1); + }); +}); + +describe('voice visualization hit areas', () => { + it('takes the globe within a third of the shorter side', () => { + expect(GLOW_STYLE.contains(0, 0)).toBe(true); + expect(GLOW_STYLE.contains(0.2, 0.2)).toBe(true); + expect(GLOW_STYLE.contains(0.3, 0.3)).toBe(false); + }); + + it('takes the string across, and only near its line', () => { + expect(WAVE_STYLE.contains(0.45, 0)).toBe(true); + expect(WAVE_STYLE.contains(0, 0.1)).toBe(true); + expect(WAVE_STYLE.contains(0, 0.2)).toBe(false); + expect(WAVE_STYLE.contains(0.55, 0)).toBe(false); + }); +}); diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts index c0eec23884..f037bbfa42 100644 --- a/package/src/ai-components/index.ts +++ b/package/src/ai-components/index.ts @@ -5,6 +5,7 @@ export * from './charts'; export * from './message-composer'; export * from './StreamingMessageView'; export * from './AITypingIndicatorView'; +export * from './voice-visualization'; // components export * from './components'; @@ -15,7 +16,8 @@ export * from './contexts'; // services export * from './services'; export * from './transcription/useDictation'; -export { isDictationAvailable } from './native/AIDictation'; +export * from './transcription/useDictationAudioLevel'; +export { isDictationAvailable, type DictationLevel } from './native/AIDictation'; // state-stores export * from './store'; diff --git a/package/src/ai-components/message-composer/ComposerView.tsx b/package/src/ai-components/message-composer/ComposerView.tsx index c2ae35564a..f214323ece 100644 --- a/package/src/ai-components/message-composer/ComposerView.tsx +++ b/package/src/ai-components/message-composer/ComposerView.tsx @@ -19,7 +19,7 @@ import type { AbstractMediaPickerService, MediaPickerState, } from '../services/media-picker-service/AbstractMediaPickerService'; -import { useComposerText } from '../store'; +import { COMPOSER_DICTATION_SOURCE, useComposerText } from '../store'; import type { ComposerState } from '../store'; import { useDictationState } from '../store/dictation/useDictationState'; import { useDictationTranscript } from '../store/dictation/useDictationTranscript'; @@ -100,7 +100,7 @@ export const AIMessageInput = () => { } = useTheme(); const styles = useStyles(); - const { error, isRecording } = useDictationState(); + const { error, isRecording, source } = useDictationState(); const { text } = useComposerText(); const { transcript } = useDictationTranscript(); const { setText } = useMessageComposerContext(); @@ -109,10 +109,13 @@ export const AIMessageInput = () => { useEffect(() => { const transcriptChanged = transcript !== previousTranscript.current; previousTranscript.current = transcript; + // Only dictation from the composer's own button fills the input; dictation started elsewhere + // (a voice mode, say) must not overwrite what the user typed. + if (source !== COMPOSER_DICTATION_SOURCE) return; if (!error && (isRecording || transcriptChanged)) { setText(transcript); } - }, [isRecording, transcript, error, setText]); + }, [isRecording, transcript, error, setText, source]); return ( ({ @@ -33,6 +33,9 @@ export const SpeechToTextButton = (props: SpeechToTextButtonProps) => { return ; }; +// Marks this button's dictation as the composer's, so `ComposerView` fills its input with it. +const DICTATION_OPTIONS = { source: COMPOSER_DICTATION_SOURCE }; + const AvailableSpeechToTextButton = ({ options }: SpeechToTextButtonProps) => { const { theme: { @@ -41,7 +44,7 @@ const AvailableSpeechToTextButton = ({ options }: SpeechToTextButtonProps) => { }, } = useTheme(); - const { cancel, toggle } = useDictation(options); + const { cancel, toggle } = useDictation(options, DICTATION_OPTIONS); const { isRecording } = useStateStore(dictationStore, selector); useEffect(() => { diff --git a/package/src/ai-components/native/AIDictation.ts b/package/src/ai-components/native/AIDictation.ts index 450abae7c8..47ad2437ef 100644 --- a/package/src/ai-components/native/AIDictation.ts +++ b/package/src/ai-components/native/AIDictation.ts @@ -11,6 +11,10 @@ import NativeAIDictation, { const ON_STATE_EVENT = 'AIDictationOnState'; const ON_RESULT_EVENT = 'AIDictationOnResult'; const ON_ERROR_EVENT = 'AIDictationOnError'; +const ON_LEVEL_EVENT = 'AIDictationOnLevel'; + +/** The microphone's level while dictation listens, from 0 to 1. */ +export type DictationLevel = { level: number }; let eventEmitter: NativeEventEmitter | undefined; const getEventEmitter = () => { @@ -74,4 +78,11 @@ export const AIDictation = { ); return sub ? () => sub.remove() : noop; }, + /** The microphone's level while dictation listens, about 10 to 30 times a second. */ + addLevelListener(listener: (payload: DictationLevel) => void) { + const sub = getEventEmitter()?.addListener(ON_LEVEL_EVENT, (event) => + listener(event as DictationLevel), + ); + return sub ? () => sub.remove() : noop; + }, }; diff --git a/package/src/ai-components/store/dictation/store.ts b/package/src/ai-components/store/dictation/store.ts index 9c3c0514d1..41e4fbb874 100644 --- a/package/src/ai-components/store/dictation/store.ts +++ b/package/src/ai-components/store/dictation/store.ts @@ -7,6 +7,11 @@ export type DictationStoreState = { transcript: string; isRecording: boolean; error?: DictationError | null; + /** + * Who started the current dictation, as passed to `useDictation`, or `null`. Lets a consumer + * such as `ComposerView` pick up only the transcripts meant for it. + */ + source?: string | null; }; const DEFAULT_STATE: DictationStoreState = { @@ -14,8 +19,12 @@ const DEFAULT_STATE: DictationStoreState = { transcript: '', isRecording: false, error: null, + source: null, }; +/** The `source` of dictation started by the composer's own `SpeechToTextButton`. */ +export const COMPOSER_DICTATION_SOURCE = 'composer'; + export const dictationStore = new StateStore(DEFAULT_STATE); const reset = () => dictationStore.next(DEFAULT_STATE); diff --git a/package/src/ai-components/store/dictation/useDictationState.ts b/package/src/ai-components/store/dictation/useDictationState.ts index a40627005d..6a79bcb009 100644 --- a/package/src/ai-components/store/dictation/useDictationState.ts +++ b/package/src/ai-components/store/dictation/useDictationState.ts @@ -2,10 +2,11 @@ import { useStateStore } from '@stream-io/state-store/react-bindings'; import { dictationStore, type DictationStoreState } from './store'; -const selector = ({ state, isRecording, error }: DictationStoreState) => ({ +const selector = ({ state, isRecording, error, source }: DictationStoreState) => ({ state, isRecording, error, + source, }); export const useDictationState = () => useStateStore(dictationStore, selector); diff --git a/package/src/ai-components/store/index.ts b/package/src/ai-components/store/index.ts index f737510a79..12ae1a9e26 100644 --- a/package/src/ai-components/store/index.ts +++ b/package/src/ai-components/store/index.ts @@ -9,3 +9,5 @@ export * from './composer/useComposerText'; // dictation export * from './dictation/store'; +export * from './dictation/useDictationState'; +export * from './dictation/useDictationTranscript'; diff --git a/package/src/ai-components/theme.ts b/package/src/ai-components/theme.ts index 8d61874359..c2047a2d7b 100644 --- a/package/src/ai-components/theme.ts +++ b/package/src/ai-components/theme.ts @@ -56,6 +56,19 @@ export type Theme = { aiTypingIndicatorView: { text: TextStyle; }; + /** + * `VoiceGlow` and `VoiceWave`. Optional, so a hand-built `Theme` without it keeps type-checking; + * the visualizations fall back to the default accent then. + */ + voiceVisualization?: { + /** One to five colors, used when a visualization is given no `colors` of its own. */ + colors: string[]; + /** + * The color the visualizations sit on, which tells them to draw for a light or a dark + * background. Defaults to the system's color scheme. + */ + backdropColor?: string; + }; }; export const defaultTheme: Theme = { @@ -135,4 +148,7 @@ export const defaultTheme: Theme = { aiTypingIndicatorView: { text: {}, }, + voiceVisualization: { + colors: [colors.accent_blue], + }, }; diff --git a/package/src/ai-components/transcription/useDictation.ts b/package/src/ai-components/transcription/useDictation.ts index eb3e3c9522..bfbf050675 100644 --- a/package/src/ai-components/transcription/useDictation.ts +++ b/package/src/ai-components/transcription/useDictation.ts @@ -27,7 +27,19 @@ const selector = ({ isRecording }: DictationStoreState) => ({ }); // TODO: add proper jsdocs -export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPTIONS) => { +export type UseDictationOptions = { + /** + * Who is dictating, stored as the dictation store's `source` when dictation starts. The + * `ComposerView` only fills its input with dictation whose source is the composer's own, so + * dictation started elsewhere (a voice mode, say) leaves the composer's text alone. + */ + source?: string; +}; + +export const useDictation = ( + defaultOptions: DictationStartOptions = DEFAULT_OPTIONS, + { source }: UseDictationOptions = {}, +) => { const { isRecording } = useStateStore(dictationStore, selector); useEffect(() => { @@ -65,11 +77,12 @@ export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPT code: 'NO_PERMISSION', message: 'Microphone permission not granted', }, + source: source ?? null, }); return; } - dictationStore.partialNext({ error: null, transcript: '' }); + dictationStore.partialNext({ error: null, source: source ?? null, transcript: '' }); const opts: DictationStartOptions = { ...defaultOptions, @@ -78,7 +91,7 @@ export const useDictation = (defaultOptions: DictationStartOptions = DEFAULT_OPT AIDictation.start(opts); }, - [defaultOptions], + [defaultOptions, source], ); const stop = useCallback(() => { diff --git a/package/src/ai-components/transcription/useDictationAudioLevel.ts b/package/src/ai-components/transcription/useDictationAudioLevel.ts new file mode 100644 index 0000000000..16574389a7 --- /dev/null +++ b/package/src/ai-components/transcription/useDictationAudioLevel.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +import { type SharedValue, useSharedValue } from 'react-native-reanimated'; + +import { AIDictation } from '../native/AIDictation'; + +/** + * The microphone's live level while dictation listens, from 0 to 1, for the `audioLevel` of + * `VoiceWave` and `VoiceGlow`. It is `null` while dictation is not listening, which lets a + * visualization animate speech on its own. + * + * The level is a shared value rather than state, so a level changing many times a second + * re-renders nothing: the visualizations read it once a frame on the UI thread. + */ +export const useDictationAudioLevel = (): SharedValue => { + const level = useSharedValue(null); + + useEffect(() => { + const unsubscribeLevel = AIDictation.addLevelListener((payload) => { + level.value = typeof payload?.level === 'number' ? payload.level : null; + }); + const unsubscribeState = AIDictation.addStateListener(({ state }) => { + if (state !== 'listening' && state !== 'starting') level.value = null; + }); + const unsubscribeError = AIDictation.addErrorListener(() => { + level.value = null; + }); + + return () => { + unsubscribeLevel(); + unsubscribeState(); + unsubscribeError(); + level.value = null; + }; + }, [level]); + + return level; +}; diff --git a/package/src/ai-components/voice-visualization/VoiceGlow.tsx b/package/src/ai-components/voice-visualization/VoiceGlow.tsx new file mode 100644 index 0000000000..f521ce2444 --- /dev/null +++ b/package/src/ai-components/voice-visualization/VoiceGlow.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { useDerivedValue } from 'react-native-reanimated'; + +import { Canvas, Fill, Shader } from '@shopify/react-native-skia'; + +import { driverReact, driverScale } from './driver'; +import { getGlowEffect } from './glowShader'; +import { GLOW_STYLE } from './styles'; +import type { VoiceVisualizationProps } from './types'; +import type { VoiceVisualization } from './useVoiceVisualization'; +import { type VoiceCanvasSize, VoiceVisualizationView } from './VoiceVisualizationView'; + +export type VoiceGlowProps = VoiceVisualizationProps; + +const GlowCanvas = ({ + size: { height, width }, + visualization: { dark, driver }, +}: { + size: VoiceCanvasSize; + visualization: VoiceVisualization; +}) => { + const effect = getGlowEffect(); + const uniforms = useDerivedValue(() => { + const d = driver.value; + const color = (i: number) => [d.colors[i].hue, d.colors[i].saturation, d.colors[i].value, 0]; + return { + bright: d.bright, + col0: color(0), + col1: color(1), + col2: color(2), + col3: color(3), + col4: color(4), + count: d.count, + dark: dark.value, + flow: d.flow, + flowSpin: d.flowSpin, + hover: [d.hoverX, d.hoverY], + hoverAmt: d.hoverAmount, + load: d.load, + mic: d.mic, + orbSpin: d.orbSpin, + react: driverReact(d), + resolution: [width, height], + sat: d.sat, + scale: driverScale(d), + tap: [d.tapX, d.tapY], + tapTime: d.tapAge, + time: d.time, + voice: d.voice, + }; + }); + + if (!effect) return null; + + return ( + + + + + + ); +}; + +const renderGlow = (visualization: VoiceVisualization, size: VoiceCanvasSize) => ( + +); + +/** + * A voice agent as a soft, airy globe: color masses orbit inside it and swell with speech, and the + * gaps between them let the background show through. Tapping the globe bounces it and ripples it + * from the touch, and so does a hovering mouse or pen. + * + * It has no size of its own and draws in the middle of the view it is given, about half as wide + * as its shorter side, so give it a square. + */ +export const VoiceGlow = (props: VoiceGlowProps) => ( + +); diff --git a/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx b/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx new file mode 100644 index 0000000000..8276245d84 --- /dev/null +++ b/package/src/ai-components/voice-visualization/VoiceVisualizationView.tsx @@ -0,0 +1,179 @@ +import React, { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { + type GestureResponderEvent, + type LayoutChangeEvent, + PixelRatio, + type PointerEvent, + StyleSheet, + View, +} from 'react-native'; + +import Animated from 'react-native-reanimated'; + +import type { VoiceVisualizationStyle } from './styles'; +import type { VoiceAgentState, VoiceVisualizationProps } from './types'; +import { useVoiceVisualization, type VoiceVisualization } from './useVoiceVisualization'; + +/** What the renderer draws in: the canvas size, in its own units, and the screen's pixels per unit. */ +export type VoiceCanvasSize = { height: number; pixelRatio: number; width: number }; + +type ViewProps = VoiceVisualizationProps & { + /** Draws the visualization into a canvas of the given size. */ + renderCanvas: (visualization: VoiceVisualization, size: VoiceCanvasSize) => ReactNode; + visualStyle: VoiceVisualizationStyle; +}; + +const DEFAULT_STATE_LABELS: Record = { + connecting: 'Connecting', + idle: 'Ready', + listening: 'Listening', + speaking: 'Speaking', + thinking: 'Thinking', +}; + +/** + * What VoiceWave and VoiceGlow share: the motion, the canvas kept at the view's size and at most + * the style's density, reduced motion, taps and hover, and accessibility. + * + * It names itself "Voice agent" with its state as the value, as on iOS and Android. Touches are + * only watched, never claimed, so a press handler on the view or a parent still gets them, and + * only the shape takes them: around it they reach whatever is behind. + */ +export const VoiceVisualizationView = ({ + accessibilityLabel = 'Voice agent', + accessibilityValue, + audioLevel, + colors, + expressivity, + onLayout, + pointerEvents = 'box-none', + renderCanvas, + state, + stateLabels, + style, + visualStyle, + ...restProps +}: ViewProps) => { + const visualization = useVoiceVisualization( + { audioLevel, colors, expressivity, state }, + visualStyle, + ); + const { hover, opacity, tap } = visualization; + const [layout, setLayout] = useState({ height: 0, width: 0 }); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + const { height, width } = event.nativeEvent.layout; + setLayout((current) => + current.width === width && current.height === height ? current : { height, width }, + ); + }, + [onLayout], + ); + + const side = Math.min(layout.width, layout.height); + const pixelRatio = PixelRatio.get(); + // Drawn at no more than the style's density, then scaled up to the view: the glow's soft + // gradients look the same at 2x with fewer pixels to fill, while the wave's line needs 3x. + const density = Math.min(1, visualStyle.maxScale / pixelRatio); + const canvasSize = useMemo( + () => ({ + height: layout.height * density, + pixelRatio: pixelRatio * density, + width: layout.width * density, + }), + [density, layout.height, layout.width, pixelRatio], + ); + const hitWidth = visualStyle.hitArea.width * side; + const hitHeight = visualStyle.hitArea.height * side; + + /** A touch on the hit area, centred on the view and normalized to its shorter side, y up. */ + const toLocalPoint = useCallback( + (locationX: number, locationY: number) => ({ + x: (locationX - hitWidth / 2) / (side || 1), + y: (hitHeight / 2 - locationY) / (side || 1), + }), + [hitHeight, hitWidth, side], + ); + + const handleTouchEnd = useCallback( + (event: GestureResponderEvent) => { + const { x, y } = toLocalPoint(event.nativeEvent.locationX, event.nativeEvent.locationY); + if (visualStyle.contains(x, y)) tap(x, y); + }, + [tap, toLocalPoint, visualStyle], + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + // Only a hovering mouse or pen ripples it; a finger has no hover. + const { buttons, offsetX, offsetY, pointerType } = event.nativeEvent; + if (pointerType === 'touch') return; + const { x, y } = toLocalPoint(offsetX, offsetY); + const hovering = buttons === 0 && visualStyle.contains(x, y); + hover(hovering ? x : null, hovering ? y : null); + }, + [hover, toLocalPoint, visualStyle], + ); + + const handlePointerLeave = useCallback(() => hover(null, null), [hover]); + + return ( + + {side > 0 ? ( + <> + + {renderCanvas(visualization, canvasSize)} + + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + canvas: { position: 'absolute' }, + hitArea: { position: 'absolute' }, +}); diff --git a/package/src/ai-components/voice-visualization/VoiceWave.tsx b/package/src/ai-components/voice-visualization/VoiceWave.tsx new file mode 100644 index 0000000000..b367c3ba05 --- /dev/null +++ b/package/src/ai-components/voice-visualization/VoiceWave.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { useDerivedValue } from 'react-native-reanimated'; + +import { Canvas, LinearGradient, Path, vec } from '@shopify/react-native-skia'; + +import { WAVE_STYLE } from './styles'; +import type { VoiceVisualizationProps } from './types'; +import type { VoiceVisualization } from './useVoiceVisualization'; +import { type VoiceCanvasSize, VoiceVisualizationView } from './VoiceVisualizationView'; +import { + WAVE_END, + WAVE_POSITIONS, + WAVE_START, + waveColors, + wavePath, + waveStrokeWidth, +} from './waveGeometry'; + +export type VoiceWaveProps = VoiceVisualizationProps; + +const WaveCanvas = ({ + size: { height, pixelRatio, width }, + visualization: { dark, driver }, +}: { + size: VoiceCanvasSize; + visualization: VoiceVisualization; +}) => { + const path = useDerivedValue(() => wavePath(driver.value, width, height)); + const colors = useDerivedValue(() => waveColors(driver.value, dark.value)); + const m = Math.min(width, height); + + return ( + + + + + + ); +}; + +const renderWave = (visualization: VoiceVisualization, size: VoiceCanvasSize) => ( + +); + +/** + * A voice agent as a single string, flat at its ends, carrying a wave packet that erupts with + * speech while its colors flow along it. Tapping the string ripples it from the touch, and so does + * a hovering mouse or pen. + * + * It has no size of its own and draws in the middle of the view it is given, about 80% as wide as + * its shorter side, so give it a square or a wide box. + */ +export const VoiceWave = (props: VoiceWaveProps) => ( + +); diff --git a/package/src/ai-components/voice-visualization/bounce.ts b/package/src/ai-components/voice-visualization/bounce.ts new file mode 100644 index 0000000000..7e4c50a6e1 --- /dev/null +++ b/package/src/ai-components/voice-visualization/bounce.ts @@ -0,0 +1,61 @@ +/** + * The bounce of a tap: a dip to 93% and an overshoot to 103% before settling, over 340 ms. + * + * These are authored keyframes rather than a spring: the ease-out curve times the whole run and + * the keyframes are linear between their stops. + */ +export const BOUNCE_DURATION = 0.34; + +const OFFSETS = [0, 0.3, 0.62, 1]; +const SCALES = [1, 0.93, 1.03, 1]; + +/** How long ago the last bounce started, in seconds; infinite before the first. */ +export type BounceState = { age: number }; + +export const createBounce = (): BounceState => ({ age: Number.POSITIVE_INFINITY }); + +export const startBounce = (bounce: BounceState) => { + 'worklet'; + bounce.age = 0; +}; + +export const advanceBounce = (bounce: BounceState, seconds: number) => { + 'worklet'; + bounce.age += seconds; +}; + +/** CSS `ease-out`, `cubic-bezier(0, 0, 0.58, 1)`. */ +export const easeOut = (x: number) => { + 'worklet'; + const bx = 3 * 0.58; + const ax = 1 - bx; + const by = 3; + const ay = 1 - by; + let low = 0; + let high = 1; + for (let i = 0; i < 24; i++) { + const middle = (low + high) / 2; + if ((ax * middle + bx) * middle * middle < x) low = middle; + else high = middle; + } + const t = (low + high) / 2; + return (ay * t + by) * t * t; +}; + +/** The keyframed scale at `progress`, from 0 to 1. */ +export const scaleAt = (progress: number) => { + 'worklet'; + for (let i = 1; i < OFFSETS.length; i++) { + if (progress <= OFFSETS[i]) { + const fraction = (progress - OFFSETS[i - 1]) / (OFFSETS[i] - OFFSETS[i - 1]); + return SCALES[i - 1] + (SCALES[i] - SCALES[i - 1]) * fraction; + } + } + return 1; +}; + +export const bounceScale = (bounce: BounceState) => { + 'worklet'; + if (bounce.age >= BOUNCE_DURATION) return 1; + return scaleAt(easeOut(Math.max(bounce.age, 0) / BOUNCE_DURATION)); +}; diff --git a/package/src/ai-components/voice-visualization/driver.ts b/package/src/ai-components/voice-visualization/driver.ts new file mode 100644 index 0000000000..7d9c7fc070 --- /dev/null +++ b/package/src/ai-components/voice-visualization/driver.ts @@ -0,0 +1,240 @@ +import { advanceBounce, type BounceState, bounceScale, createBounce, startBounce } from './bounce'; +import { MAXIMUM_COLORS } from './palette'; +import type { Hsv, VoiceAgentState } from './types'; + +/** + * The share of the way the live level moves each frame at 60 Hz, toward a louder or a quieter + * level. + */ +export type LevelResponse = { rise: number; fall: number }; + +export const LEVEL_RESPONSE = { + /** Follows the level closely, both ways. The glow's. */ + steady: { fall: 0.35, rise: 0.35 }, + /** + * Follows the level like a VU meter: about 0.1 s up to a louder reading, about 0.3 s down from + * it, so a meter's per-syllable jumps swell with the phrase instead. The wave's. + */ + smooth: { fall: 0.05, rise: 0.16 }, +} satisfies Record; + +export type VoiceDriverInput = { + state: VoiceAgentState; + /** One to five colors. */ + colors: Hsv[]; + /** From 0 to 2. */ + expressivity: number; + /** From 0 to 1, or null when no live audio drives the motion. */ + audioLevel: number | null; + reduceMotion: boolean; +}; + +/** + * The motion of a voice visualization: where it is in time, how it moves, and its colors. A plain + * object rather than a class, so it can live in a shared value and be advanced on the UI thread. + */ +export type VoiceDriverState = { + speed: number; + bright: number; + sat: number; + load: number; + flow: number; + baseReact: number; + expressivity: number; + time: number; + flowSpin: number; + orbSpin: number; + count: number; + tapX: number; + tapY: number; + tapAge: number; + hoverX: number; + hoverY: number; + hoverAmount: number; + isHovering: boolean; + mic: number; + voice: number; + /** The colors drawn, eased in place toward the input's color slots. */ + colors: Hsv[]; + bounce: BounceState; + levelResponse: LevelResponse; +}; + +/** + * How a state moves. `load`, `flow` and `react` each select a motion pattern, one per state, so + * easing between states crossfades the patterns. + */ +type VoiceMotion = { + bright: number; + flow: number; + load: number; + react: number; + sat: number; + speed: number; +}; + +const MOTIONS: Record = { + connecting: { bright: 1, flow: 0, load: 1, react: 0, sat: 1, speed: 1 }, + idle: { bright: 1, flow: 0, load: 0, react: 0.12, sat: 1, speed: 0.4 }, + listening: { bright: 1, flow: 0, load: 0, react: 0.45, sat: 1, speed: 0.9 }, + speaking: { bright: 1, flow: 0, load: 0, react: 1, sat: 1, speed: 1.25 }, + thinking: { bright: 1, flow: 1, load: 0, react: 0, sat: 1, speed: 1.25 }, +}; + +export const NO_TAP = 100; +const MAX_FRAME = 0.05; +/** How fast time runs under reduced motion. */ +export const REDUCED = 0.07; +const DRIVERS = 0.08; +const GENTLE = 0.12; +const VOICE = 0.1; + +/** Expressivity scales amplitude and speed, each with a floor so the motion breathes at 0. */ +export const scales = (expressivity: number) => { + 'worklet'; + return { amplitude: 0.15 + 0.85 * expressivity, speed: 0.6 + 0.4 * expressivity }; +}; + +/** + * The share of the way a value moves in `dt` seconds when it moves `perFrame` of the way each + * frame at 60 Hz. + */ +export const ease = (perFrame: number, dt: number) => { + 'worklet'; + return 1 - Math.pow(1 - perFrame, dt * 60); +}; + +const rem = (x: number, m: number) => { + 'worklet'; + return ((x % m) + m) % m; +}; + +/** Eases an HSV color in place, its hue taking the shorter way round the wheel. */ +const easeHsv = (color: Hsv, target: Hsv, fraction: number) => { + 'worklet'; + const hueStep = (rem(target.hue - color.hue + 1.5, 1) - 0.5) * fraction; + color.hue = rem(color.hue + hueStep + 1, 1); + color.saturation += (target.saturation - color.saturation) * fraction; + color.value += (target.value - color.value) * fraction; +}; + +/** The five color slots the renderers read; past the palette they repeat its first color. */ +export const slots = (colors: Hsv[]): Hsv[] => { + 'worklet'; + const first = colors[0] ?? { hue: 0, saturation: 0, value: 0 }; + const result: Hsv[] = []; + for (let i = 0; i < MAXIMUM_COLORS; i++) result.push(colors[i] ?? first); + return result; +}; + +/** A driver settled on `input`, so the first frame shows it rather than easing into it. */ +export const createDriverState = ( + input: VoiceDriverInput, + levelResponse: LevelResponse = LEVEL_RESPONSE.steady, +): VoiceDriverState => { + const m = MOTIONS[input.state]; + const s = scales(input.expressivity); + return { + baseReact: m.react * s.amplitude, + bounce: createBounce(), + bright: m.bright, + // Copies, as they are eased in place and the palette belongs to the caller. + colors: slots(input.colors).map((color) => ({ ...color })), + count: input.colors.length, + expressivity: input.expressivity, + flow: m.flow * s.amplitude, + flowSpin: 0, + hoverAmount: 0, + hoverX: 0, + hoverY: 0, + isHovering: false, + levelResponse, + load: m.load * s.amplitude, + mic: 0, + orbSpin: 0, + sat: m.sat, + speed: m.speed * s.speed, + tapAge: NO_TAP, + tapX: 0, + tapY: 0, + time: 0, + voice: 0, + }; +}; + +/** Live audio lifts the motion to its level, whatever the state. */ +export const driverReact = (d: VoiceDriverState) => { + 'worklet'; + return d.baseReact + (Math.max(d.baseReact, d.mic) - d.baseReact) * d.voice; +}; + +/** The scale of a tap's bounce. */ +export const driverScale = (d: VoiceDriverState) => { + 'worklet'; + return bounceScale(d.bounce); +}; + +/** A tap at (x, y), centred and normalized to the shorter side with y up. */ +export const tapDriver = (d: VoiceDriverState, input: VoiceDriverInput, x: number, y: number) => { + 'worklet'; + if (input.reduceMotion) return; + d.tapX = x; + d.tapY = y; + d.tapAge = 0; + startBounce(d.bounce); +}; + +/** Where a pointer hovers, or null once it has left. */ +export const hoverDriver = ( + d: VoiceDriverState, + input: VoiceDriverInput, + x: number | null, + y: number | null, +) => { + 'worklet'; + if (x === null || y === null || input.reduceMotion) { + d.isHovering = false; + return; + } + d.hoverX = x; + d.hoverY = y; + d.isHovering = true; +}; + +/** + * Advances the motion by `seconds`, easing each value toward the input's motion by a fixed share + * per frame at 60 Hz, converted to time, so it moves the same at any refresh rate. + */ +export const advanceDriver = (d: VoiceDriverState, input: VoiceDriverInput, seconds: number) => { + 'worklet'; + // Clamped so a frame after a stall cannot leap the motion. + const dt = Math.min(Math.max(seconds, 0), MAX_FRAME); + if (input.reduceMotion) d.isHovering = false; + const m = MOTIONS[input.state]; + const s = scales(input.expressivity); + const drivers = ease(DRIVERS, dt); + d.speed += (m.speed * s.speed - d.speed) * drivers; + d.bright += (m.bright - d.bright) * drivers; + d.sat += (m.sat - d.sat) * drivers; + d.load += (m.load * s.amplitude - d.load) * drivers; + d.flow += (m.flow * s.amplitude - d.flow) * drivers; + d.baseReact += (m.react * s.amplitude - d.baseReact) * drivers; + d.expressivity += (input.expressivity - d.expressivity) * drivers; + // Integrated rather than computed as time times a weight, so a change of state eases the turns + // instead of jumping their angles. + const step = dt * (input.reduceMotion ? REDUCED : 1) * d.speed; + d.time += step; + d.flowSpin += step * d.flow; + d.orbSpin += step * (1 + 0.5 * d.flow + 0.1 * d.baseReact); + d.tapAge += dt; + advanceBounce(d.bounce, dt); + const gentle = ease(GENTLE, dt); + d.hoverAmount += ((d.isHovering ? 1 : 0) - d.hoverAmount) * gentle; + d.voice += ((input.audioLevel === null ? 0 : 1) - d.voice) * ease(VOICE, dt); + const level = input.audioLevel ?? 0; + const response = level > d.mic ? d.levelResponse.rise : d.levelResponse.fall; + d.mic += (level - d.mic) * ease(response, dt); + const targets = slots(input.colors); + for (let i = 0; i < d.colors.length; i++) easeHsv(d.colors[i], targets[i], gentle); + d.count += (input.colors.length - d.count) * gentle; +}; diff --git a/package/src/ai-components/voice-visualization/glowShader.ts b/package/src/ai-components/voice-visualization/glowShader.ts new file mode 100644 index 0000000000..fe2d8019d6 --- /dev/null +++ b/package/src/ai-components/voice-visualization/glowShader.ts @@ -0,0 +1,227 @@ +import { Skia, type SkRuntimeEffect } from '@shopify/react-native-skia'; + +/** + * The glow's shader, SkSL: the AGSL glow of stream-chat-android (itself ported from the iOS Metal + * glow) as is, since AGSL is SkSL, so the platforms look alike. Every pixel is computed on the + * GPU: color masses warped by simplex noise, concentric waves, ripples from a tap or a hovering + * pointer, light and a halo. Pixels past the halo return at once, so only the glow itself costs. + */ +const SOURCE = ` +uniform float2 resolution; +uniform float4 col0; +uniform float4 col1; +uniform float4 col2; +uniform float4 col3; +uniform float4 col4; +uniform float time; +uniform float count; +uniform float bright; +uniform float sat; +uniform float load; +uniform float flow; +uniform float flowSpin; +uniform float orbSpin; +uniform float react; +uniform float dark; +uniform float mic; +uniform float voice; +uniform float scale; +uniform float2 tap; +uniform float tapTime; +uniform float2 hover; +uniform float hoverAmt; + +float sq(float x) { return x * x; } +float ss(float e0, float e1, float x) { + float t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} +float3 hsv2rgb(float3 c) { + float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + float3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); +} +float3 desat(float3 c, float s) { + float l = dot(c, float3(0.299, 0.587, 0.114)); + return mix(float3(l), c, s); +} +float3 saturate3(float3 c, float s) { + float l = dot(c, float3(0.299, 0.587, 0.114)); + return clamp(mix(float3(l), c, s), 0.0, 1.0); +} +float2 rot2(float2 p, float a) { + float s = sin(a); float c = cos(a); + return float2(c * p.x + s * p.y, -s * p.x + c * p.y); +} +float3 vivid(float4 hsv) { + return hsv2rgb(float3(hsv.x, clamp(hsv.y + dark * 0.06, 0.0, 1.0), hsv.z)); +} +// Waves spreading out from the last tap, fading within a second and a half. +float tapRipple(float2 p) { + float age = tapTime; + if (age > 1.5) return 0.0; + float d = distance(p, tap); + float front = age * 0.75; + float env = exp(-sq((d - front) / 0.14)); + float decay = exp(-age * 2.6); + return sin(d * 34.0 - age * 24.0) * env * decay; +} +// A gentle ripple following a hovering pointer. +float hoverRipple(float2 p) { + if (hoverAmt <= 0.001) return 0.0; + float d = distance(p, hover); + float env = exp(-sq(d / 0.22)); + return sin(d * 30.0 - time * 7.0) * env * hoverAmt * 0.6; +} +float4 permute(float4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); } +float4 taylorInvSqrt(float4 r) { return 1.79284291400159 - 0.85373472095314 * r; } +float snoise(float3 v) { + const float2 C = float2(1.0 / 6.0, 1.0 / 3.0); + const float4 D = float4(0.0, 0.5, 1.0, 2.0); + float3 i = floor(v + dot(v, C.yyy)); + float3 x0 = v - i + dot(i, C.xxx); + float3 g = step(x0.yzx, x0.xyz); + float3 l = 1.0 - g; + float3 i1 = min(g.xyz, l.zxy); + float3 i2 = max(g.xyz, l.zxy); + float3 x1 = x0 - i1 + 1.0 * C.xxx; + float3 x2 = x0 - i2 + 2.0 * C.xxx; + float3 x3 = x0 - 1.0 + 3.0 * C.xxx; + i = mod(i, 289.0); + float4 p = permute(permute(permute( + i.z + float4(0.0, i1.z, i2.z, 1.0)) + + i.y + float4(0.0, i1.y, i2.y, 1.0)) + + i.x + float4(0.0, i1.x, i2.x, 1.0)); + float n_ = 1.0 / 7.0; + float3 ns = n_ * D.wyz - D.xzx; + float4 j = p - 49.0 * floor(p * ns.z * ns.z); + float4 x_ = floor(j * ns.z); + float4 y_ = floor(j - 7.0 * x_); + float4 x = x_ * ns.x + ns.yyyy; + float4 y = y_ * ns.x + ns.yyyy; + float4 h = 1.0 - abs(x) - abs(y); + float4 b0 = float4(x.xy, y.xy); + float4 b1 = float4(x.zw, y.zw); + float4 s0 = floor(b0) * 2.0 + 1.0; + float4 s1 = floor(b1) * 2.0 + 1.0; + float4 sh = -step(h, float4(0.0)); + float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + float4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + float3 p0 = float3(a0.xy, h.x); + float3 p1 = float3(a0.zw, h.y); + float3 p2 = float3(a1.xy, h.z); + float3 p3 = float3(a1.zw, h.w); + float4 norm = taylorInvSqrt(float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3))); + p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; + float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} +float speechEnv(float t) { + float gate = ss(-0.25, 0.35, snoise(float3(t * 0.6, 21.0, 0.0))); + float syl = 0.55 + 0.45 * snoise(float3(t * 3.0, 5.0, 0.0)); + return clamp(gate * syl, 0.0, 1.0); +} +float2 coords(float2 pos) { + float m = min(resolution.x, resolution.y); + return float2(pos.x - 0.5 * resolution.x, 0.5 * resolution.y - pos.y) / m / scale; +} + +half4 main(float2 fragCoord) { + float2 q = coords(fragCoord); + float t = time; + float r = length(q); + + float speak = ss(0.6, 1.0, react); + float speech = react * mix(0.55 + 0.25 * sin(t * 1.6), speechEnv(t), speak); + speech = mix(speech, mic, voice); + float speechSmooth = mix(speech, react * (0.55 + 0.25 * sin(t * 1.6)), 0.5 * speak); + float energy = 0.12 + 0.30 * react + 0.18 * flow + 0.28 * load + 0.06 * speechSmooth; + float R = 0.24 * (1.0 + speechSmooth * 0.20 + load * 0.05 * sin(t * 2.0) + react * 0.04 * sin(t * 1.6)); + float p = r / R; + if (p > 2.2) return half4(0.0); + + float core = exp(-2.0 * p * p); + float body = ss(1.06, 0.62, p); + + float2 P = q / R; + float pl = length(P); + float nt = t * 0.15 + flowSpin * 0.05; + float nAmp = 0.20 + 0.28 * energy; + float2 sp = P + nAmp * float2(snoise(float3(P * 0.7, nt)), snoise(float3(P * 0.7 + 4.7, nt))); + float ang = atan(P.y, P.x); + float wave = (0.018 + 0.025 * energy) * sin(pl * 5.0 - t * 2.0) * ss(0.0, 0.4, pl); + sp += wave * float2(-sin(ang), cos(ang)); + sp = rot2(sp, flowSpin * 0.30); + float2 gtoP = q - (hoverAmt > 0.001 ? hover : tap); + sp += (gtoP / (length(gtoP) + 1e-4)) * (tapRipple(q) + hoverRipple(q)) * 0.5; + float bt = t; + float a0 = orbSpin * 0.42 + 2.0 * snoise(float3(bt * 0.12, 0.0, 0.0)); + float a1 = -orbSpin * 0.34 + 2.0 * snoise(float3(bt * 0.10, 5.0, 0.0)) + 2.0; + float a2 = orbSpin * 0.30 + 2.0 * snoise(float3(bt * 0.11, 9.0, 0.0)) + 1.0; + float a3 = -orbSpin * 0.26 + 2.0 * snoise(float3(bt * 0.09, 14.0, 0.0)) + 3.5; + float2 c0 = 0.40 * float2(cos(a0), sin(a0)); + float2 c1 = 0.38 * float2(cos(a1), sin(a1)); + float2 c2c = 0.36 * float2(cos(a2), sin(a2)); + float2 c3c = 0.34 * float2(cos(a3), sin(a3)); + float b0 = ss(1.15, 0.1, length(sp - c0)); + float b1 = ss(1.30, 0.1, length(sp - c1)); + float b2 = ss(1.20, 0.1, length(sp - c2c)); + float b3 = ss(1.25, 0.1, length(sp - c3c)); + + float3 k0 = saturate3(vivid(col0), 1.7); + float3 kL = saturate3(clamp(vivid(col0) * 1.18, 0.0, 1.0), 1.3); + float3 kD = saturate3(vivid(col0) * 0.85, 1.3); + float3 colB = mix(kL, saturate3(vivid(col1), 1.7), clamp(count - 1.0, 0.0, 1.0)); + float3 colC = mix(kD, saturate3(vivid(col2), 1.7), clamp(count - 2.0, 0.0, 1.0)); + float3 colD = saturate3(vivid(col3), 1.7); + float3 colE = saturate3(vivid(col4), 1.7); + float3 base = saturate3(mix(vivid(col0), float3(1.0), 0.06), 1.3); + float3 col = base; + col = mix(col, k0, b0); + col = mix(col, colB, b1); + col = mix(col, colC, b0 * 0.5); + col = mix(col, colD, b2 * clamp(count - 3.0, 0.0, 1.0)); + col = mix(col, colE, b3 * clamp(count - 4.0, 0.0, 1.0)); + col = saturate3(col, 1.15); + col = clamp(col * 1.12, 0.0, 1.0); + col = desat(col, sat); + + float z = sqrt(max(1.0 - p * p, 0.0)); + float3 nrm = normalize(float3(q / R, z + 1e-4)); + float3 Ld = normalize(float3(-0.35, 0.45, 0.85)); + float diff = clamp(dot(nrm, Ld), 0.0, 1.0); + col *= 0.80 + 0.20 * diff; + float fres = pow(1.0 - clamp(nrm.z, 0.0, 1.0), 2.5); + col += fres * 0.10 * mix(float3(1.0), k0, 0.6); + + float3 haloCol = mix(mix(vivid(col0), float3(1.0), 0.75), mix(vivid(col0), float3(1.0), 0.32), dark); + float halo = exp(-sq(max(r - R * 0.8, 0.0) / 0.09)); + float haloA = halo * (mix(0.05, 0.10, dark) + 0.05 * react); + float waves = clamp(b0 + 0.8 * b1, 0.0, 1.0); + float bodyA = clamp(body * (0.15 + 0.60 * waves + 0.06 * core) * (0.92 + 0.08 * fres), 0.0, 1.0); + float fade = mix(1.0, 0.45 + 0.35 * sin(t * 2.0), load); + float3 pm = col * bodyA + haloCol * haloA * (1.0 - bodyA); + float al = bodyA + haloA * (1.0 - bodyA); + float gain = bright * fade; + return half4(half3(pm * gain), half(al * gain)); +} +`; + +let effect: SkRuntimeEffect | null | undefined; + +/** + * The compiled shader, shared by every glow: it compiles once, the first time a glow draws. Null + * if Skia cannot compile it, in which case the glow draws nothing. + */ +export const getGlowEffect = () => { + if (effect === undefined) { + effect = Skia.RuntimeEffect.Make(SOURCE); + if (!effect && __DEV__) { + console.warn( + '[stream-chat-react-native/ai-components] VoiceGlow could not compile its shader, so it draws nothing. Please report this with your @shopify/react-native-skia version.', + ); + } + } + return effect; +}; diff --git a/package/src/ai-components/voice-visualization/index.ts b/package/src/ai-components/voice-visualization/index.ts new file mode 100644 index 0000000000..f8d09f5afb --- /dev/null +++ b/package/src/ai-components/voice-visualization/index.ts @@ -0,0 +1,3 @@ +export { VoiceGlow, type VoiceGlowProps } from './VoiceGlow'; +export { VoiceWave, type VoiceWaveProps } from './VoiceWave'; +export type { VoiceAgentState, VoiceVisualizationProps } from './types'; diff --git a/package/src/ai-components/voice-visualization/math.ts b/package/src/ai-components/voice-visualization/math.ts new file mode 100644 index 0000000000..45b2134cff --- /dev/null +++ b/package/src/ai-components/voice-visualization/math.ts @@ -0,0 +1,73 @@ +import { snoise } from './simplex'; + +/** The shader math the wave runs on the CPU, once per frame, on the UI thread. */ + +export const mix = (a: number, b: number, t: number) => { + 'worklet'; + return a + (b - a) * t; +}; + +export const fract = (x: number) => { + 'worklet'; + return x - Math.floor(x); +}; + +export const clamp = (x: number, low: number, high: number) => { + 'worklet'; + return Math.min(Math.max(x, low), high); +}; + +/** GLSL's smoothstep, evaluated the same with its edges either way round. */ +export const smoothstep = (edge0: number, edge1: number, x: number) => { + 'worklet'; + const t = clamp((x - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +}; + +/** HSV, hue a fraction of a turn, into `out` as RGB. */ +export const hsvToRgb = (hue: number, saturation: number, value: number, out: number[]) => { + 'worklet'; + for (let i = 0; i < 3; i++) { + const k = i === 0 ? 1 : i === 1 ? 2 / 3 : 1 / 3; + const p = Math.abs(fract(hue + k) * 6 - 3); + out[i] = value * mix(1, clamp(p - 1, 0, 1), saturation); + } +}; + +/** A palette color as is; dark mode lifts saturation a touch so it stays vivid on black. */ +export const vivid = ( + color: { hue: number; saturation: number; value: number }, + dark: number, + out: number[], +) => { + 'worklet'; + hsvToRgb(color.hue, clamp(color.saturation + dark * 0.06, 0, 1), color.value, out); +}; + +/** Pushes `c` away from its luminance by `amount`; below 1 desaturates. */ +export const saturate = (c: number[], amount: number) => { + 'worklet'; + const l = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + for (let i = 0; i < 3; i++) c[i] = clamp(mix(l, c[i], amount), 0, 1); +}; + +/** + * How much the agent is talking, 0 to 1: a slow phrase gate times a faster syllable wobble. The + * shader's own envelope, on the same simplex noise, so the wave and the glow speak alike. + */ +export const speechEnv = (t: number) => { + 'worklet'; + const gate = smoothstep(-0.25, 0.35, snoise(t * 0.6, 21, 0)); + const syllable = 0.55 + 0.45 * snoise(t * 3, 5, 0); + return clamp(gate * syllable, 0, 1); +}; + +/** `expressivity` from 0 to 2; one that is not a number gets the default, 1. */ +export const expressivityInput = (expressivity: number | undefined) => + typeof expressivity === 'number' && Number.isFinite(expressivity) ? clamp(expressivity, 0, 2) : 1; + +/** `level` from 0 to 1; one that is not a number counts as no live audio, so it can't stall the motion. */ +export const audioLevelInput = (level: number | null | undefined) => { + 'worklet'; + return typeof level === 'number' && Number.isFinite(level) ? clamp(level, 0, 1) : null; +}; diff --git a/package/src/ai-components/voice-visualization/palette.ts b/package/src/ai-components/voice-visualization/palette.ts new file mode 100644 index 0000000000..b23c5b8dd9 --- /dev/null +++ b/package/src/ai-components/voice-visualization/palette.ts @@ -0,0 +1,59 @@ +import { processColor } from 'react-native'; + +import type { Hsv } from './types'; + +export const MAXIMUM_COLORS = 5; + +export type Rgb = [number, number, number]; + +/** Used when neither the `colors` prop nor the theme gives a readable color. */ +export const FALLBACK_COLOR = '#005fff'; + +/** + * Reads any color React Native understands (names, `#rgb[a]`, `#rrggbb[aa]`, `rgb()`, `hsl()`, ...) + * into sRGB channels from 0 to 1, or null when it cannot be read. Platform colors have no + * channels to read and count as unreadable. + */ +export const parseColor = (color: string): Rgb | null => { + if (typeof color !== 'string' || !color.trim()) return null; + const processed = processColor(color.trim()); + if (typeof processed !== 'number') return null; + // 0xAARRGGBB, signed on Android. + const argb = processed < 0 ? processed + 2 ** 32 : processed; + const channel = (shift: number) => (Math.floor(argb / 2 ** shift) % 256) / 255; + return [channel(16), channel(8), channel(0)]; +}; + +/** RGB channels from 0 to 1 as HSV, keeping full saturation and value so muted colors render true. */ +export const rgbToHsv = ([r, g, b]: Rgb): Hsv => { + const high = Math.max(r, g, b); + const low = Math.min(r, g, b); + const delta = high - low; + let hue = 0; + if (delta !== 0) { + if (high === r) hue = ((((g - b) / delta) % 6) + 6) % 6; + else if (high === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + } + return { + hue: (((hue / 6) % 1) + 1) % 1, + saturation: high === 0 ? 0 : Math.min(Math.max(delta / high, 0), 1), + value: Math.min(Math.max(high, 0), 1), + }; +}; + +/** Colors as the HSV the renderers read, at most five; colors that cannot be read are skipped. */ +export const toHsvPalette = (colors: readonly string[]): Hsv[] => + colors + .map(parseColor) + .filter((rgb): rgb is Rgb => rgb !== null) + .slice(0, MAXIMUM_COLORS) + .map(rgbToHsv); + +/** Relative luminance of a color, or null when it cannot be read. */ +export const luminance = (color: string) => { + const rgb = parseColor(color); + if (!rgb) return null; + const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; diff --git a/package/src/ai-components/voice-visualization/simplex.ts b/package/src/ai-components/voice-visualization/simplex.ts new file mode 100644 index 0000000000..f95594d990 --- /dev/null +++ b/package/src/ai-components/voice-visualization/simplex.ts @@ -0,0 +1,92 @@ +/** + * 3-D simplex noise, a port of the glow shader's `snoise` (Ashima Arts / Stefan Gustavson), so the + * wave drawn on the CPU moves with the same speech rhythm as the shader, as on iOS and the web. + * + * It runs in float32 like a GPU, with `mod` as a multiply by a float32 reciprocal, because the + * hash is sensitive to rounding: in exact arithmetic some inputs hash to a neighbouring gradient. + * Every function is a worklet, as the motion runs on the UI thread. + */ + +const INV_289 = Math.fround(1 / 289); +const ONE_THIRD = Math.fround(1 / 3); +const ONE_SIXTH = Math.fround(1 / 6); +const NS_X = Math.fround(2 / 7); +const NS_Y = Math.fround(Math.fround(0.5 * Math.fround(1 / 7)) - 1); +const NS_Z = Math.fround(1 / 7); + +const mod289 = (x: number) => { + 'worklet'; + const f = Math.fround; + return f(x - f(289 * Math.floor(f(x * INV_289)))); +}; + +const permute = (x: number) => { + 'worklet'; + const f = Math.fround; + return mod289(f(f(f(x * 34) + 1) * x)); +}; + +const taylorInvSqrt = (r: number) => { + 'worklet'; + const f = Math.fround; + return f(1.79284291400159 - f(0.85373472095314 * r)); +}; + +/** A corner's contribution, its gradient from its hash as the shader's x_/y_/h/sh steps compute it. */ +const corner = (p: number, x: number, y: number, z: number) => { + 'worklet'; + const f = Math.fround; + const j = f(p - f(49 * Math.floor(f(f(p * NS_Z) * NS_Z)))); + const xi = Math.floor(f(j * NS_Z)); + const yi = Math.floor(f(j - f(7 * xi))); + const gx0 = f(f(xi * NS_X) + NS_Y); + const gy0 = f(f(yi * NS_X) + NS_Y); + const gz = f(f(1 - Math.abs(gx0)) - Math.abs(gy0)); + const sh = gz <= 0 ? -1 : 0; + const gx = f(gx0 + f((Math.floor(gx0) * 2 + 1) * sh)); + const gy = f(gy0 + f((Math.floor(gy0) * 2 + 1) * sh)); + const norm = taylorInvSqrt(gx * gx + gy * gy + gz * gz); + const m = Math.max(0.6 - (x * x + y * y + z * z), 0); + return m * m * m * m * (gx * norm * x + gy * norm * y + gz * norm * z); +}; + +/** Simplex noise at (x, y, z), from about -1 to 1. */ +export const snoise = (inX: number, inY: number, inZ: number) => { + 'worklet'; + const f = Math.fround; + const vx = f(inX); + const vy = f(inY); + const vz = f(inZ); + const s = f(f(vx + vy + vz) * ONE_THIRD); + const ix = Math.floor(f(vx + s)); + const iy = Math.floor(f(vy + s)); + const iz = Math.floor(f(vz + s)); + const t = f(f(ix + iy + iz) * ONE_SIXTH); + const x0 = f(f(vx - ix) + t); + const y0 = f(f(vy - iy) + t); + const z0 = f(f(vz - iz) + t); + + const gx = x0 >= y0 ? 1 : 0; + const gy = y0 >= z0 ? 1 : 0; + const gz = z0 >= x0 ? 1 : 0; + const i1x = Math.min(gx, 1 - gz); + const i1y = Math.min(gy, 1 - gx); + const i1z = Math.min(gz, 1 - gy); + const i2x = Math.max(gx, 1 - gz); + const i2y = Math.max(gy, 1 - gx); + const i2z = Math.max(gz, 1 - gy); + + const mx = mod289(ix); + const my = mod289(iy); + const mz = mod289(iz); + const hash = (dx: number, dy: number, dz: number) => + permute(f(f(permute(f(f(permute(f(mz + dz)) + my) + dy)) + mx) + dx)); + + return ( + 42 * + (corner(hash(0, 0, 0), x0, y0, z0) + + corner(hash(i1x, i1y, i1z), x0 - i1x + 1 / 6, y0 - i1y + 1 / 6, z0 - i1z + 1 / 6) + + corner(hash(i2x, i2y, i2z), x0 - i2x + 1 / 3, y0 - i2y + 1 / 3, z0 - i2z + 1 / 3) + + corner(hash(1, 1, 1), x0 - 0.5, y0 - 0.5, z0 - 0.5)) + ); +}; diff --git a/package/src/ai-components/voice-visualization/styles.ts b/package/src/ai-components/voice-visualization/styles.ts new file mode 100644 index 0000000000..4a6a01bee5 --- /dev/null +++ b/package/src/ai-components/voice-visualization/styles.ts @@ -0,0 +1,42 @@ +import { LEVEL_RESPONSE, type LevelResponse } from './driver'; + +/** + * What tells the glow and the wave apart besides how they draw, as on iOS. + * + * - `levelResponse`: the wave's height tracks the level directly, so it follows it smoothly; + * per-syllable jumps would make it jitter. The glow follows it closely. + * - `maxScale`: the densest it renders. The glow is soft all over, so 2x is enough; the wave's + * thin line is drawn at the screen's own scale, up to 3x, to stay crisp. + * - `contains`: whether a point, centred and normalized to the shorter side with y up, is on the + * shape rather than the empty space around it. + */ +export type VoiceVisualizationStyle = { + contains: (x: number, y: number) => boolean; + /** The shape's bounds as a share of the shorter side, for the area that takes touches. */ + hitArea: { width: number; height: number }; + levelResponse: LevelResponse; + maxScale: number; +}; + +/** How far from the centre a tap still lands on the globe, as a share of the shorter side. */ +const GLOBE_REACH = 0.32; + +export const GLOW_STYLE: VoiceVisualizationStyle = { + contains: (x, y) => { + 'worklet'; + return Math.hypot(x, y) < GLOBE_REACH; + }, + hitArea: { height: 2 * GLOBE_REACH, width: 2 * GLOBE_REACH }, + levelResponse: LEVEL_RESPONSE.steady, + maxScale: 2, +}; + +export const WAVE_STYLE: VoiceVisualizationStyle = { + contains: (x, y) => { + 'worklet'; + return Math.abs(x) < 0.5 && Math.abs(y) < 0.14; + }, + hitArea: { height: 0.28, width: 1 }, + levelResponse: LEVEL_RESPONSE.smooth, + maxScale: 3, +}; diff --git a/package/src/ai-components/voice-visualization/types.ts b/package/src/ai-components/voice-visualization/types.ts new file mode 100644 index 0000000000..d767638289 --- /dev/null +++ b/package/src/ai-components/voice-visualization/types.ts @@ -0,0 +1,44 @@ +import type { ViewProps } from 'react-native'; + +import type { SharedValue } from 'react-native-reanimated'; + +/** + * What a voice agent is doing, which a voice visualization shows as motion. + * + * The states differ only in how a visualization moves, never in its colors, and changing the + * state eases from one motion into the next. + * + * - `idle`: waiting to be spoken to, a calm, barely-there shimmer. + * - `connecting`: not ready yet, a loader sweep with a pulsing breath. + * - `listening`: hearing somebody, a gentle expand and contract. + * - `thinking`: working on an answer, a slow turn or a travelling wave. + * - `speaking`: talking, strong motion that ebbs and pauses like speech. + */ +export type VoiceAgentState = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking'; + +export type VoiceVisualizationProps = Omit & { + /** What the agent is doing, which sets how the visualization moves. */ + state: VoiceAgentState; + /** + * The level of whoever is talking, from 0 to 1, read once a frame on the UI thread. Feed it at + * 15 Hz or more for the visualization to move with the words; `null` animates speech on its own. + * See `useDictationAudioLevel` for the level of the microphone while dictating. + */ + audioLevel?: SharedValue; + /** + * One to five colors. Past five are ignored, and colors that cannot be read are skipped. When + * omitted or empty, the theme's `voiceVisualization.colors` are used. + */ + colors?: string[]; + /** From 0 to 2: how far and how fast it moves. Colors never change with it. Defaults to 1. */ + expressivity?: number; + /** + * What screen readers read as the value for each state, for example to translate them. Defaults + * to "Ready", "Connecting", "Listening", "Thinking" and "Speaking". The label, "Voice agent", is + * `accessibilityLabel`. + */ + stateLabels?: Partial>; +}; + +/** A color as hue (a fraction of a turn), saturation and value, each from 0 to 1. */ +export type Hsv = { hue: number; saturation: number; value: number }; diff --git a/package/src/ai-components/voice-visualization/useReduceMotion.ts b/package/src/ai-components/voice-visualization/useReduceMotion.ts new file mode 100644 index 0000000000..ed3188738f --- /dev/null +++ b/package/src/ai-components/voice-visualization/useReduceMotion.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +/** + * Whether the system asks for less motion: Reduce Motion on iOS, animations off on Android. It + * follows the setting while mounted. + * + * The AI entry point cannot reach the core SDK's own hook, which also only applies once its + * accessibility layer is enabled; the voice visualizations always honour the setting, as on iOS + * and Android. + */ +export const useReduceMotion = () => { + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + let mounted = true; + AccessibilityInfo.isReduceMotionEnabled?.() + .then((enabled) => { + // It starts off; only turning it on needs a render. + if (mounted && enabled) setReduceMotion(true); + }) + .catch(() => undefined); + const subscription = AccessibilityInfo.addEventListener?.( + 'reduceMotionChanged', + setReduceMotion, + ); + return () => { + mounted = false; + subscription?.remove(); + }; + }, []); + + return reduceMotion; +}; diff --git a/package/src/ai-components/voice-visualization/useVoiceVisualization.ts b/package/src/ai-components/voice-visualization/useVoiceVisualization.ts new file mode 100644 index 0000000000..2579318396 --- /dev/null +++ b/package/src/ai-components/voice-visualization/useVoiceVisualization.ts @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useColorScheme } from 'react-native'; + +import { + type FrameInfo, + runOnUI, + useFrameCallback, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { + advanceDriver, + createDriverState, + hoverDriver, + tapDriver, + type VoiceDriverInput, +} from './driver'; +import { audioLevelInput, expressivityInput } from './math'; +import { FALLBACK_COLOR, luminance, toHsvPalette } from './palette'; +import type { VoiceVisualizationStyle } from './styles'; +import type { VoiceVisualizationProps } from './types'; +import { useReduceMotion } from './useReduceMotion'; + +import { useTheme } from '../contexts'; + +/** How long the visualization takes to fade in once its first frame is drawn, as on iOS. */ +const FADE_IN_MS = 300; + +/** Whether to draw for a dark background: the theme's backdrop when readable, else the scheme. */ +const useDark = (backdropColor: string | undefined) => { + const scheme = useColorScheme(); + return useMemo(() => { + const backdrop = backdropColor ? luminance(backdropColor) : null; + if (backdrop !== null) return backdrop < 0.5 ? 1 : 0; + return scheme === 'dark' ? 1 : 0; + }, [backdropColor, scheme]); +}; + +/** + * A voice visualization's motion, advanced once a frame on the UI thread. + * + * The driver lives in a shared value the renderers derive from, so a frame redraws without + * re-rendering React, and `audioLevel` is read in the frame callback, so a level changing many + * times a second re-renders nothing either. + */ +export const useVoiceVisualization = ( + { audioLevel, colors, expressivity, state }: VoiceVisualizationProps, + style: VoiceVisualizationStyle, +) => { + const { theme } = useTheme(); + const reduceMotion = useReduceMotion(); + const themeColors = theme.voiceVisualization?.colors; + const dark = useDark(theme.voiceVisualization?.backdropColor); + + const colorsKey = (colors ?? []).join('|'); + const themeKey = (themeColors ?? []).join('|'); + const palette = useMemo(() => { + const own = toHsvPalette(colors ?? []); + if (own.length) return own; + const fromTheme = toHsvPalette(themeColors ?? []); + return fromTheme.length ? fromTheme : toHsvPalette([FALLBACK_COLOR]); + // Keyed by the colors themselves, so a new array of the same colors changes nothing. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colorsKey, themeKey]); + + const input: VoiceDriverInput = { + audioLevel: null, + colors: palette, + expressivity: expressivityInput(expressivity), + reduceMotion, + state, + }; + + // Starts settled on the first input, so the first frame shows it rather than easing into it. + const [initialDriver] = useState(() => createDriverState(input, style.levelResponse)); + const driver = useSharedValue(initialDriver); + const inputValue = useSharedValue(input); + const darkValue = useSharedValue(dark); + const opacity = useSharedValue(0); + const fadingIn = useSharedValue(false); + + useEffect(() => { + inputValue.value = { + audioLevel: null, + colors: palette, + expressivity: expressivityInput(expressivity), + reduceMotion, + state, + }; + }, [expressivity, inputValue, palette, reduceMotion, state]); + + useEffect(() => { + darkValue.value = dark; + }, [dark, darkValue]); + + const onFrame = useCallback( + (frame: FrameInfo) => { + 'worklet'; + const seconds = (frame.timeSincePreviousFrame ?? 0) / 1000; + const current = inputValue.value; + const level = audioLevelInput(audioLevel ? audioLevel.value : null); + const next = level === current.audioLevel ? current : { ...current, audioLevel: level }; + driver.modify((d) => { + 'worklet'; + advanceDriver(d, next, seconds); + return d; + }); + // It fades in once it has something to show, rather than flashing empty while the first + // frame lands. + if (!fadingIn.value) { + fadingIn.value = true; + opacity.value = withTiming(1, { duration: FADE_IN_MS }); + } + }, + [audioLevel, driver, fadingIn, inputValue, opacity], + ); + useFrameCallback(onFrame); + + /** A tap at a point centred and normalized to the shorter side, y up. */ + const tap = useCallback( + (x: number, y: number) => { + runOnUI((tapX: number, tapY: number) => { + 'worklet'; + driver.modify((d) => { + 'worklet'; + tapDriver(d, inputValue.value, tapX, tapY); + return d; + }); + })(x, y); + }, + [driver, inputValue], + ); + + /** Where a pointer hovers, or null once it has left. */ + const hover = useCallback( + (x: number | null, y: number | null) => { + runOnUI((hoverX: number | null, hoverY: number | null) => { + 'worklet'; + driver.modify((d) => { + 'worklet'; + hoverDriver(d, inputValue.value, hoverX, hoverY); + return d; + }); + })(x, y); + }, + [driver, inputValue], + ); + + return { dark: darkValue, driver, hover, opacity, tap }; +}; + +export type VoiceVisualization = ReturnType; diff --git a/package/src/ai-components/voice-visualization/waveGeometry.ts b/package/src/ai-components/voice-visualization/waveGeometry.ts new file mode 100644 index 0000000000..3af2a03c00 --- /dev/null +++ b/package/src/ai-components/voice-visualization/waveGeometry.ts @@ -0,0 +1,137 @@ +import { Skia, type SkPath } from '@shopify/react-native-skia'; + +import { driverReact, driverScale, type VoiceDriverState } from './driver'; +import { clamp, mix, saturate, smoothstep, speechEnv, vivid } from './math'; + +const POINTS = 160; +export const WAVE_STOPS = 13; +export const WAVE_START = -0.42; +export const WAVE_END = 0.42; + +/** The stops' positions along the gradient, evenly spaced. */ +export const WAVE_POSITIONS = Array.from({ length: WAVE_STOPS }, (_, s) => s / (WAVE_STOPS - 1)); + +const sq = (x: number) => { + 'worklet'; + return x * x; +}; + +const bandCenter = (i: number, seg: number, t: number) => { + 'worklet'; + switch (i) { + case 0: + return 0.5 * seg + 0.12 * Math.sin(t * 0.13); + case 1: + return 1.5 * seg + 0.13 * Math.sin(t * 0.11 + 2.1); + case 2: + return 2.5 * seg + 0.12 * Math.sin(t * 0.17 + 4.2); + case 3: + return 3.5 * seg + 0.12 * Math.sin(t * 0.15 + 1); + default: + return 4.5 * seg + 0.12 * Math.sin(t * 0.1 + 3.3); + } +}; + +/** The string's height above its centre line at each point, centred and normalized, y up. */ +export const waveHeights = (d: VoiceDriverState) => { + 'worklet'; + const t = d.time; + const react = driverReact(d); + const center = d.load * 0.3 * Math.sin(t * 1.3); + const freq = 24 + 26 * react + 10 * d.flow; + const speak = smoothstep(0.6, 1, react); + const drive = mix(speak * 0.26 * speechEnv(t), 0.3 * d.mic, d.voice); + const reach = 0.018 + 0.022 * react + drive + 0.14 * d.flow + 0.1 * d.load; + const tapAlive = d.tapAge < 1.5; + const hovered = d.hoverAmount > 0.001; + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i <= POINTS; i++) { + const x = WAVE_START + ((WAVE_END - WAVE_START) * i) / POINTS; + let y = Math.exp(-sq((x - center) / 0.18)) * reach * Math.sin(x * freq - t * 5); + if (tapAlive) { + y += + Math.exp(-sq((x - d.tapX) / 0.12)) * + Math.sin((x - d.tapX) * 40 - d.tapAge * 24) * + Math.exp(-d.tapAge * 2.6) * + 0.05; + } + if (hovered) { + y += + Math.exp(-sq((x - d.hoverX) / 0.12)) * + Math.sin((x - d.hoverX) * 40 - t * 7) * + d.hoverAmount * + 0.04; + } + xs.push(x); + ys.push(y); + } + return { xs, ys }; +}; + +type LegacyPath = SkPath & { + lineTo: (x: number, y: number) => void; + moveTo: (x: number, y: number) => void; +}; + +/** + * The wave as a path in a `width` by `height` canvas. Newer Skia versions build immutable paths with + * `PathBuilder`; earlier 2.x versions mutate `SkPath` itself. + */ +export const wavePath = (d: VoiceDriverState, width: number, height: number): SkPath => { + 'worklet'; + const m = Math.min(width, height); + const scale = driverScale(d); + const { xs, ys } = waveHeights(d); + const px = (i: number) => width / 2 + xs[i] * m * scale; + const py = (i: number) => height / 2 - ys[i] * m * scale; + if (Skia.PathBuilder) { + const builder = Skia.PathBuilder.Make(); + builder.moveTo(px(0), py(0)); + for (let i = 1; i < xs.length; i++) builder.lineTo(px(i), py(i)); + return builder.build(); + } + const path = Skia.Path.Make() as LegacyPath; + path.moveTo(px(0), py(0)); + for (let i = 1; i < xs.length; i++) path.lineTo(px(i), py(i)); + return path; +}; + +/** + * The string's colors at its gradient stops, as RGBA from 0 to 1: up to five bands drifting along + * it, weighed by how near their centres are, its ends fading out. + */ +export const waveColors = (d: VoiceDriverState, dark: number) => { + 'worklet'; + const band: number[][] = []; + for (let i = 0; i < 5; i++) { + const rgb = [0, 0, 0]; + vivid(d.colors[i], dark, rgb); + band.push(rgb); + } + const seg = 1 / Math.max(d.count, 1); + const bandWidth = seg * 0.7; + const stops: Float32Array[] = []; + for (let s = 0; s < WAVE_STOPS; s++) { + const x = WAVE_START + ((WAVE_END - WAVE_START) * s) / (WAVE_STOPS - 1); + const gx = clamp(x + 0.5, 0, 1); + const color = [0, 0, 0]; + let total = 1e-4; + for (let i = 0; i < 5; i++) { + const present = i === 0 ? 1 : clamp(d.count - i, 0, 1); + const weight = Math.exp(-sq((gx - bandCenter(i, seg, d.time)) / bandWidth)) * present; + for (let j = 0; j < 3; j++) color[j] += band[i][j] * weight; + total += weight; + } + for (let j = 0; j < 3; j++) color[j] /= total; + saturate(color, 1.2); + saturate(color, d.sat); + const alpha = clamp(smoothstep(0.4, 0.24, Math.abs(x)) * d.bright, 0, 1); + stops.push(new Float32Array([color[0], color[1], color[2], alpha])); + } + return stops; +}; + +/** The line is 0.6% of the shorter side, and never under 1.5 device pixels, as on Android and iOS. */ +export const waveStrokeWidth = (width: number, height: number, pixelRatio: number) => + Math.max(0.006 * Math.min(width, height), 1.5 / pixelRatio); diff --git a/yarn.lock b/yarn.lock index 89c2493a2f..7a56a95534 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10640,6 +10640,15 @@ __metadata: languageName: node linkType: hard +"expo-speech@npm:~58.0.3": + version: 58.0.3 + resolution: "expo-speech@npm:58.0.3" + peerDependencies: + expo: "*" + checksum: 10c0/552c3a34a96210cd0405ee278f8065500203ae87881e5b43500a9251ca2d58b6956e14e37e922872b6480669eff7a0a1ad8ff5aca500eea912cd7ae6d71bcf4d + languageName: node + linkType: hard + "expo-splash-screen@npm:~58.0.8": version: 58.0.8 resolution: "expo-splash-screen@npm:58.0.8" @@ -10747,6 +10756,7 @@ __metadata: expo-image-picker: "npm:~58.0.10" expo-linking: "npm:~58.0.12" expo-router: "npm:~58.0.18" + expo-speech: "npm:~58.0.3" react: "npm:19.3.0" react-native: "npm:0.88.0" react-native-gesture-handler: "npm:~3.4.0" From 9bc37a3fe52fabbad6d2d5d9f44545c273e820d8 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Wed, 28 Oct 2026 11:18:30 +0100 Subject: [PATCH 2/5] fix: adjusted message composer bottom offset calculation --- package/src/components/MessageInput/MessageComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index d0c9bba4e8..392e76ef37 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -316,7 +316,7 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { })); const { bottom } = useSafeAreaInsets(); - const BOTTOM_OFFSET = isKeyboardVisible || selectedPicker ? 16 : bottom ? bottom : 16; + const BOTTOM_OFFSET = isKeyboardVisible || selectedPicker ? 16 : bottom ? bottom + 16 : 16; const micPositionContextValue = useMemo( () => ({ micPositionX, micPositionY }), From a6bb08f4d8b745e4b1bcccfb67ae340a3112a3e2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 28 Oct 2026 12:24:02 +0100 Subject: [PATCH 3/5] fix: bump stream-chat to 9.54.2 Pulls in the following fixes from stream-chat-js: - poll crash when the last vote on an option is removed - message pagination after message_limit 0 queries and disconnected latest pages - custom attachments dropped when editing a message The poll preview label test now expects the emptied option to be removed from latest_votes_by_option instead of left as an empty array, which is how the LLC handles it now. --- examples/ExpoChatGPTSample/package.json | 2 +- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- .../useChannelPreviewPollLabel.test.tsx | 2 +- yarn.lock | 16 ++++++++-------- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index f914847373..73a01a0e80 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.1", + "stream-chat": "^9.54.2", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 814aced8bc..7ea24ba04c 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.1", + "stream-chat": "^9.54.2", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 2bfd129d17..b67283884a 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.1", + "stream-chat": "^9.54.2", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 25fb948e73..66d5739df6 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^9.54.1", + "stream-chat": "^9.54.2", "use-sync-external-store": "^1.5.0", "zod": "^4.1.12" }, diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx index 51980c760a..1b8f6e72db 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx @@ -80,7 +80,7 @@ describe('useChannelPreviewPollLabel', () => { poll.removeVote(voteA.id, MESSAGE_ID); }); - expect(poll.data.latest_votes_by_option['option-a']).toEqual([]); + expect(poll.data.latest_votes_by_option['option-a']).toBeUndefined(); expect(result.current).toBe('You voted: {{ option }}'); }); }); diff --git a/yarn.lock b/yarn.lock index 7a56a95534..303410fa0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6981,7 +6981,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.1" + stream-chat: "npm:^9.54.2" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10766,7 +10766,7 @@ __metadata: react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^9.54.1" + stream-chat: "npm:^9.54.2" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18272,7 +18272,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^9.54.1" + stream-chat: "npm:^9.54.2" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19045,7 +19045,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.54.1" + stream-chat: "npm:^9.54.2" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19133,9 +19133,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.54.1": - version: 9.54.1 - resolution: "stream-chat@npm:9.54.1" +"stream-chat@npm:^9.54.2": + version: 9.54.2 + resolution: "stream-chat@npm:9.54.2" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19151,7 +19151,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/1dd6564a5299f3e9d60a29a6ba6aa8841d94b5622975b71247c7e070e42104f09ef066e44283eb245e20058fb3079881c30bc961dc7e4168ebda0d1dcd66e824 + checksum: 10c0/ebe3835165ad942c1eb7c3c1e4d5b2d24f9638ce6b849918efe945cf4a41a856c3287b12e9f0ab166a128276f36a74f4482da0c505c3e5a339b47f90b800b65f languageName: node linkType: hard From 6163a403ac4b3f077fd124294baab58253893788 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Wed, 28 Oct 2026 12:35:07 +0100 Subject: [PATCH 4/5] chore: make offset adjustment android only --- package/src/components/MessageInput/MessageComposer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 392e76ef37..8fd021221a 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import Animated, { Extrapolation, @@ -316,7 +316,9 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { })); const { bottom } = useSafeAreaInsets(); - const BOTTOM_OFFSET = isKeyboardVisible || selectedPicker ? 16 : bottom ? bottom + 16 : 16; + const safeAreaBottomOffset = Platform.OS === 'android' ? bottom + 16 : bottom; + const BOTTOM_OFFSET = + isKeyboardVisible || selectedPicker ? 16 : bottom ? safeAreaBottomOffset : 16; const micPositionContextValue = useMemo( () => ({ micPositionX, micPositionY }), From 14850a37bd87df7196acd754c2c33031b8fd74d1 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Wed, 28 Oct 2026 16:21:51 +0000 Subject: [PATCH 5/5] test: add Detox e2e suite with a dedicated E2ETestApp (#3881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 🎯 Goal E2E test automation for the SDK, on par with the native iOS/Android and Flutter suites, running on PRs. Linear: [RN-450](https://linear.app/stream/issue/RN-450), [RN-451](https://linear.app/stream/issue/RN-451), [RN-452](https://linear.app/stream/issue/RN-452), [RN-453](https://linear.app/stream/issue/RN-453), [RN-454](https://linear.app/stream/issue/RN-454), [RN-455](https://linear.app/stream/issue/RN-455), [RN-457](https://linear.app/stream/issue/RN-457) (parent [RN-449](https://linear.app/stream/issue/RN-449)) ## 🛠 Implementation details **Dedicated app: `examples/E2ETestApp`.** A new bare RN 0.88 workspace with only the SDK and its required peer dependencies (no Firebase, maps, video or navigation library), like the native SDKs' `StreamChatUITestsApp`. It has three screens: a user selector, the channel list, and a channel stacked over the list. It reads a `MOCK_SERVER_URL` launch argument (`react-native-launch-arguments`), never persists a login and runs without offline storage, so every test starts clean. A clean Release build takes ~2 min on an M-series Mac; the SampleApp took ~15 min on Android. **The SampleApp app is unchanged**; only its Fastfile gains the e2e lanes. **Framework ([RN-450](https://linear.app/stream/issue/RN-450)):** Detox 20 + Jest 30 in `examples/E2ETestApp/e2e/`, with `ios.sim.*`, `android.emu.*` and `android.att.*` configs (debug and release). **Mock server ([RN-451](https://linear.app/stream/issue/RN-451)):** the shared [`stream-chat-test-mock-server`](https://github.com/GetStream/stream-chat-test-mock-server), one server per test through its driver (port 4569). **Page objects ([RN-452](https://linear.app/stream/issue/RN-452)) and robots ([RN-453](https://linear.app/stream/issue/RN-453), [RN-454](https://linear.app/stream/issue/RN-454), [RN-455](https://linear.app/stream/issue/RN-455)):** structure and naming follow the native suites: - `BackendRobot` / `ParticipantRobot`: 1:1 ports of the Android robots - `UserRobot` + `UserRobot*Asserts`: assert extensions, like Android's - `LoginPage` / `ChannelListPage` / `MessageListPage` - `setUpStreamTestCase()`, `step()`, and uiautomator-style element helpers Robots chain fluently like the native ones: `await userRobot.login().openChannel().assertMessage('Hi')`. **Smoke suite:** 6 tests (MessageList ×3, ChannelList ×3), named after their Android counterparts and keeping the native expectations. **SDK (additive testIDs only):** `channel-preview-title`, `channel-preview-message`, `channel-preview-sender`, and `message-status-{read,delivered,sending,sent}`, with a unit test. **CI ([RN-457](https://linear.app/stream/issue/RN-457)):** `.github/workflows/e2e-test.yml`. - **Android** runs on PRs: `ubuntu-latest`, KVM, API 36 AOSP emulator, `pixel` profile (the native Android SDK's setup). - **iOS** runs only on demand (`workflow_dispatch` with `ios: true`), not on PRs, to save CI time and macOS runners: `macos-15`, Xcode 26.2, iPhone 17 Pro. - Gated like the iOS SDK: a cheap `Check E2E Required` job runs `is_check_required` over the e2e sources (Fastfile `impact_analysis`), and `test_e2e` checks it too. - **`Test E2E Result` is the check to require in branch protection.** It covers Android (it doesn't wait for iOS) and passes when the Android job passes or is skipped as not required. - Caching: - the built `.app` / `.apk`, keyed on the app and SDK sources but not on `e2e/`, so a test-only change skips the native build - `node_modules` of `yarn workspaces focus e2etestapp` - CocoaPods, ccache (with `CCACHE_BINARY` exported, without which RN's ccache wrappers silently skip ccache), the Detox framework, Gradle (`cache-read-only: false`, since `setup-gradle` otherwise only writes on the default branch), and the mock server's gems - each PR fills its own caches (the workflow doesn't run on `develop`): its first run builds cold, and later pushes reuse them - On failure, uploads the mock server logs, Detox's logs and screenshots, and the emulator's crash data. **Fastlane** (in `examples/SampleApp/fastlane`, next to the repo's other lanes): `start_mock_server`, `stop_mock_server`, `e2e_pod_install`, `test_e2e`, `e2e_check_required`. **Notable findings**, documented in `examples/E2ETestApp/e2e/README.md`: - Fabric mounts some testID'd views (`message-list-item-*`, `message-text-container`) empty, with their content as siblings, so page objects scope to `message-content-wrapper` or the list instead. - iOS runs without Detox's idle synchronization, which can wedge on RN's UIManager shadow queue. Robots wait explicitly, and taps retry while the target isn't hittable yet. - `launchApp({ permissions })` makes Detox restart SpringBoard once per permission on every launch. On CI that cost 1.5–3 min per test, so the suite doesn't pass any. - Android sets the composer text directly (as the native suite does). Typing through Gboard left the text in a composing state that the composer didn't send. How to run and extend the suite: `examples/E2ETestApp/e2e/README.md`. ## 🎨 UI Changes None (testIDs only). ## 🧪 Testing - Locally (release builds): iOS simulator 6/6, 31s for the whole suite; Android emulator 6/6. - CI: green on [39459700744](https://github.com/GetStream/stream-chat-react-native/actions/runs/39459700744) and [39467760315](https://github.com/GetStream/stream-chat-react-native/actions/runs/39467760315). On the latter the iOS test step took 3m 12s (31 min before the launch-permission fix), and Android 3m 12s. - `e2e_check_required` against real PRs: no PR → `true`, #3870 (docs only) → `false`, #3879 (`package/`) → `true`. - `yarn build`, `yarn lint`, `yarn typecheck` (now including `e2etestapp`), `cd package && yarn test:typecheck`, `yarn test:unit` green. Not covered yet: Allure TestOps reporting, full suite ports, and iOS on PRs (manual runs only, to save CI time and macOS runners). ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [x] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .github/workflows/e2e-test.yml | 339 +++ .prettierignore | 4 + AGENTS.md | 8 +- eslint.config.mjs | 4 + examples/E2ETestApp/.detoxrc.js | 67 + examples/E2ETestApp/.gitignore | 79 + examples/E2ETestApp/.watchmanconfig | 1 + examples/E2ETestApp/App.tsx | 133 + examples/E2ETestApp/android/app/build.gradle | 124 + .../E2ETestApp/android/app/debug.keystore | Bin 0 -> 2257 bytes .../E2ETestApp/android/app/proguard-rules.pro | 10 + .../reactnative/e2etestapp/DetoxTest.kt | 27 + .../android/app/src/main/AndroidManifest.xml | 28 + .../reactnative/e2etestapp/MainActivity.kt | 22 + .../reactnative/e2etestapp/MainApplication.kt | 27 + .../res/drawable/rn_edit_text_material.xml | 37 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3056 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5024 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2096 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2858 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4569 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7098 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6464 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 10676 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9250 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 15523 bytes .../app/src/main/res/values/strings.xml | 3 + .../app/src/main/res/values/styles.xml | 9 + .../main/res/xml/network_security_config.xml | 8 + examples/E2ETestApp/android/build.gradle | 34 + examples/E2ETestApp/android/gradle.properties | 54 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/E2ETestApp/android/gradlew | 248 ++ examples/E2ETestApp/android/gradlew.bat | 98 + examples/E2ETestApp/android/settings.gradle | 6 + examples/E2ETestApp/app.json | 4 + examples/E2ETestApp/babel.config.js | 4 + examples/E2ETestApp/e2e/README.md | 119 + examples/E2ETestApp/e2e/jest.config.js | 18 + .../E2ETestApp/e2e/mock-server/DataTypes.ts | 25 + .../E2ETestApp/e2e/mock-server/MockServer.ts | 84 + .../E2ETestApp/e2e/pages/ChannelListPage.ts | 32 + examples/E2ETestApp/e2e/pages/LoginPage.ts | 11 + .../E2ETestApp/e2e/pages/MessageListPage.ts | 87 + .../E2ETestApp/e2e/robots/BackendRobot.ts | 129 + .../E2ETestApp/e2e/robots/ParticipantRobot.ts | 161 ++ examples/E2ETestApp/e2e/robots/Robot.ts | 33 + examples/E2ETestApp/e2e/robots/UserRobot.ts | 135 + .../e2e/robots/UserRobotChannelListAsserts.ts | 72 + .../e2e/robots/UserRobotMessageListAsserts.ts | 105 + examples/E2ETestApp/e2e/robots/index.ts | 7 + .../e2e/support/AndroidSystemDialogs.ts | 43 + examples/E2ETestApp/e2e/support/Element.ts | 164 ++ .../E2ETestApp/e2e/support/StreamTestCase.ts | 61 + examples/E2ETestApp/e2e/support/step.ts | 14 + .../e2e/tests/ChannelListTests.e2e.ts | 43 + .../e2e/tests/MessageListTests.e2e.ts | 44 + examples/E2ETestApp/e2e/tsconfig.json | 9 + examples/E2ETestApp/index.js | 7 + examples/E2ETestApp/ios/.xcode.env | 11 + .../ios/E2ETestApp.xcodeproj/project.pbxproj | 498 ++++ .../xcschemes/E2ETestApp.xcscheme | 88 + .../contents.xcworkspacedata | 10 + .../ios/E2ETestApp/AppDelegate.swift | 11 + .../AppIcon.appiconset/Contents.json | 53 + .../E2ETestApp/Images.xcassets/Contents.json | 6 + examples/E2ETestApp/ios/E2ETestApp/Info.plist | 78 + .../ios/E2ETestApp/LaunchScreen.storyboard | 47 + .../ios/E2ETestApp/PrivacyInfo.xcprivacy | 37 + .../ios/E2ETestApp/SceneDelegate.swift | 56 + examples/E2ETestApp/ios/Podfile | 45 + examples/E2ETestApp/ios/Podfile.lock | 2554 +++++++++++++++++ examples/E2ETestApp/metro.config.js | 81 + examples/E2ETestApp/package.json | 50 + examples/E2ETestApp/src/config.ts | 26 + examples/E2ETestApp/tsconfig.json | 7 + examples/SampleApp/fastlane/Fastfile | 125 +- package.json | 3 +- .../ChannelLastMessagePreview.tsx | 2 +- .../ChannelMessagePreviewDeliveryStatus.tsx | 10 +- .../ChannelPreview/ChannelPreviewTitle.tsx | 2 +- .../Message/MessageItemView/MessageStatus.tsx | 18 +- .../__tests__/MessageStatus.test.tsx | 21 + yarn.lock | 1347 ++++++++- 85 files changed, 7930 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/e2e-test.yml create mode 100644 examples/E2ETestApp/.detoxrc.js create mode 100644 examples/E2ETestApp/.gitignore create mode 100644 examples/E2ETestApp/.watchmanconfig create mode 100644 examples/E2ETestApp/App.tsx create mode 100644 examples/E2ETestApp/android/app/build.gradle create mode 100644 examples/E2ETestApp/android/app/debug.keystore create mode 100644 examples/E2ETestApp/android/app/proguard-rules.pro create mode 100644 examples/E2ETestApp/android/app/src/androidTest/java/io/getstream/reactnative/e2etestapp/DetoxTest.kt create mode 100644 examples/E2ETestApp/android/app/src/main/AndroidManifest.xml create mode 100644 examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt create mode 100644 examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt create mode 100644 examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 examples/E2ETestApp/android/app/src/main/res/values/strings.xml create mode 100644 examples/E2ETestApp/android/app/src/main/res/values/styles.xml create mode 100644 examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml create mode 100644 examples/E2ETestApp/android/build.gradle create mode 100644 examples/E2ETestApp/android/gradle.properties create mode 100644 examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/E2ETestApp/android/gradlew create mode 100644 examples/E2ETestApp/android/gradlew.bat create mode 100644 examples/E2ETestApp/android/settings.gradle create mode 100644 examples/E2ETestApp/app.json create mode 100644 examples/E2ETestApp/babel.config.js create mode 100644 examples/E2ETestApp/e2e/README.md create mode 100644 examples/E2ETestApp/e2e/jest.config.js create mode 100644 examples/E2ETestApp/e2e/mock-server/DataTypes.ts create mode 100644 examples/E2ETestApp/e2e/mock-server/MockServer.ts create mode 100644 examples/E2ETestApp/e2e/pages/ChannelListPage.ts create mode 100644 examples/E2ETestApp/e2e/pages/LoginPage.ts create mode 100644 examples/E2ETestApp/e2e/pages/MessageListPage.ts create mode 100644 examples/E2ETestApp/e2e/robots/BackendRobot.ts create mode 100644 examples/E2ETestApp/e2e/robots/ParticipantRobot.ts create mode 100644 examples/E2ETestApp/e2e/robots/Robot.ts create mode 100644 examples/E2ETestApp/e2e/robots/UserRobot.ts create mode 100644 examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts create mode 100644 examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts create mode 100644 examples/E2ETestApp/e2e/robots/index.ts create mode 100644 examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts create mode 100644 examples/E2ETestApp/e2e/support/Element.ts create mode 100644 examples/E2ETestApp/e2e/support/StreamTestCase.ts create mode 100644 examples/E2ETestApp/e2e/support/step.ts create mode 100644 examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts create mode 100644 examples/E2ETestApp/e2e/tsconfig.json create mode 100644 examples/E2ETestApp/index.js create mode 100644 examples/E2ETestApp/ios/.xcode.env create mode 100644 examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj create mode 100644 examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme create mode 100644 examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata create mode 100644 examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift create mode 100644 examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json create mode 100644 examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json create mode 100644 examples/E2ETestApp/ios/E2ETestApp/Info.plist create mode 100644 examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard create mode 100644 examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy create mode 100644 examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift create mode 100644 examples/E2ETestApp/ios/Podfile create mode 100644 examples/E2ETestApp/ios/Podfile.lock create mode 100644 examples/E2ETestApp/metro.config.js create mode 100644 examples/E2ETestApp/package.json create mode 100644 examples/E2ETestApp/src/config.ts create mode 100644 examples/E2ETestApp/tsconfig.json diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000000..e71792358f --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,339 @@ +name: E2E Tests + +on: + pull_request: + + workflow_dispatch: + inputs: + ios: + description: "Run iOS e2e tests" + type: boolean + default: false + android: + description: "Run Android e2e tests" + type: boolean + default: true + mock_server_branch: + description: "stream-chat-test-mock-server branch" + type: string + default: "main" + +permissions: + contents: read + pull-requests: read + checks: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + MOCK_SERVER_BRANCH: ${{ inputs.mock_server_branch || 'main' }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUM: ${{ github.event.pull_request.number }} + GITHUB_EVENT_ACTION: ${{ github.event.action }} + GITHUB_EVENT_BEFORE: ${{ github.event.before }} + GITHUB_EVENT_AFTER: ${{ github.event.after }} + # node_modules of the e2e app and the workspaces it depends on (nmHoistingLimits: workspaces + # keeps them apart); `yarn workspaces focus` installs nothing else. + NODE_MODULES_PATHS: | + node_modules + configs/*/node_modules + package/node_modules + package/native-package/node_modules + examples/E2ETestApp/node_modules + .yarn/install-state.gz + +jobs: + check: + name: Check E2E Required + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + required: ${{ steps.check.outputs.required }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/ruby-cache + - name: Check if e2e tests are required + id: check + working-directory: examples/SampleApp + run: bundle exec fastlane e2e_check_required + + ios: + needs: check + if: needs.check.outputs.required == 'true' && github.event_name == 'workflow_dispatch' && inputs.ios + runs-on: macos-15 + timeout-minutes: 60 + env: + DETOX_IOS_DEVICE: "iPhone 17 Pro" + IOS_VERSION: "26.2" + XCODE_VERSION: "26.2" + APP_PATH: examples/E2ETestApp/ios/build/detox/Build/Products/Release-iphonesimulator/E2ETestApp.app + MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + # A fresh simulator takes minutes to boot on CI; boot it now so that overlaps with setup. + - name: Boot simulator + run: | + runtime="com.apple.CoreSimulator.SimRuntime.iOS-${IOS_VERSION//./-}" + udid=$(xcrun simctl list devices available -j | + jq -r --arg rt "$runtime" --arg name "$DETOX_IOS_DEVICE" '.devices[$rt][]? | select(.name == $name) | .udid' | head -1) + if [ -z "$udid" ]; then + echo "::warning::No $DETOX_IOS_DEVICE on iOS $IOS_VERSION; Detox will pick and boot a simulator itself" + exit 0 + fi + echo "DETOX_IOS_UDID=$udid" >> "$GITHUB_ENV" + nohup xcrun simctl boot "$udid" > /dev/null 2>&1 & + - uses: ./.github/actions/ruby-cache + + # --- JS dependencies --- + - name: Restore node_modules + id: node-modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + # Only the e2e app's dependency tree, not every example app. + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - name: Save node_modules + if: steps.node-modules.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ steps.node-modules.outputs.cache-primary-key }} + + # --- App binary: rebuilt only when the app or SDK sources change, not for test-only changes --- + - name: Restore app + id: app + uses: actions/cache/restore@v5 + with: + path: ${{ env.APP_PATH }} + key: e2e-ios-app-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/ios/**', 'package/native-package/*.podspec', 'examples/SampleApp/Gemfile.lock', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/ios/**', '!examples/E2ETestApp/ios/build/**', '!examples/E2ETestApp/ios/Pods/**') }} + - name: Set up ccache + if: steps.app.outputs.cache-hit != 'true' + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: e2e-ios-xcode${{ env.XCODE_VERSION }} + max-size: 1500M + - name: Point the RN ccache wrappers at ccache + if: steps.app.outputs.cache-hit != 'true' + # RN's ccache-clang.sh runs `$CCACHE_BINARY clang`, but Xcode doesn't export that build + # setting to the compiler, so without this every compile silently skips ccache. + run: echo "CCACHE_BINARY=$(which ccache)" >> "$GITHUB_ENV" + - name: Cache pods + if: steps.app.outputs.cache-hit != 'true' + uses: actions/cache@v5 + with: + path: examples/E2ETestApp/ios/Pods + key: ${{ runner.os }}-e2e-pods-${{ hashFiles('examples/E2ETestApp/ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-e2e-pods- + - name: Install pods + if: steps.app.outputs.cache-hit != 'true' + working-directory: examples/SampleApp + run: USE_CCACHE=1 bundle exec fastlane e2e_pod_install + - name: Build app + if: steps.app.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp + run: yarn e2e:build -c ios.sim.release + - name: Save app + if: steps.app.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.APP_PATH }} + key: ${{ steps.app.outputs.cache-primary-key }} + + # --- Test tooling --- + - name: Install applesimutils + # Homebrew refuses formulae from non-official taps until they are trusted. + run: brew tap wix/brew && brew trust --formula wix/brew/applesimutils && brew install wix/brew/applesimutils + - name: Restore Detox framework + id: detox-framework + uses: actions/cache/restore@v5 + with: + path: ~/Library/Detox + key: ${{ runner.os }}-detox-framework-xcode${{ env.XCODE_VERSION }}-${{ hashFiles('yarn.lock') }} + # Yarn's `enableScripts: false` skips Detox's postinstall, which would build this. + - name: Build Detox framework + if: steps.detox-framework.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp + run: yarn detox build-framework-cache + - name: Save Detox framework + if: steps.detox-framework.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ~/Library/Detox + key: ${{ steps.detox-framework.outputs.cache-primary-key }} + - name: Resolve mock server revision + id: mock-server + run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache mock server gems + uses: actions/cache@v5 + with: + path: ${{ env.MOCK_SERVER_BUNDLE_PATH }} + key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }} + restore-keys: | + ${{ runner.os }}-mock-server-gems- + + - name: Wait for the simulator + if: env.DETOX_IOS_UDID != '' + timeout-minutes: 15 + # `-b` boots it if the background boot never started; it errors (405) when the device is + # already booted, and then the plain call returns at once. + run: xcrun simctl bootstatus "$DETOX_IOS_UDID" -b || xcrun simctl bootstatus "$DETOX_IOS_UDID" + - name: Run e2e tests + working-directory: examples/SampleApp + run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" + - uses: actions/upload-artifact@v6 + if: failure() + with: + name: e2e-ios-logs + path: | + examples/E2ETestApp/stream-chat-test-mock-server/logs + examples/E2ETestApp/artifacts + + android: + needs: check + if: needs.check.outputs.required == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android) + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + # Same as the native Android SDK e2e jobs, which run stably on these runners + ANDROID_API_LEVEL: "36" + ANDROID_EMULATOR_OPTIONS: -no-snapshot-save -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -camera-back none -camera-front none + DETOX_ADB_NAME: "emulator-5554" + APK_PATHS: | + examples/E2ETestApp/android/app/build/outputs/apk/release/app-release.apk + examples/E2ETestApp/android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk + MOCK_SERVER_BUNDLE_PATH: ${{ github.workspace }}/../mock-server-gems + # The emulator is x86_64: skip compiling native code for the other ABIs. + ORG_GRADLE_PROJECT_reactNativeArchitectures: x86_64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: "yarn" + cache-dependency-path: "yarn.lock" + - uses: actions/setup-java@v5 + with: + distribution: "zulu" + java-version: "21" + # Caches Gradle's dependencies, wrapper and build cache. It only writes on the default branch by + # default, and this workflow doesn't run there, so let PR runs write: their reruns reuse it. + - uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: false + - uses: ./.github/actions/ruby-cache + + # --- JS dependencies --- + - name: Restore node_modules + id: node-modules + uses: actions/cache/restore@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ runner.os }}-e2e-node-modules-${{ hashFiles('yarn.lock') }} + # Only the e2e app's dependency tree, not every example app. + - name: Install dependencies + run: yarn workspaces focus e2etestapp + - name: Save node_modules + if: steps.node-modules.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.NODE_MODULES_PATHS }} + key: ${{ steps.node-modules.outputs.cache-primary-key }} + + # --- App binaries: rebuilt only when the app or SDK sources change, not for test-only changes --- + - name: Restore APKs + id: apk + uses: actions/cache/restore@v5 + with: + path: ${{ env.APK_PATHS }} + key: e2e-android-apk-${{ hashFiles('yarn.lock', 'package/package.json', 'package/src/**', 'package/shared-native/**', 'package/native-package/package.json', 'package/native-package/src/**', 'package/native-package/android/**', '!package/native-package/android/build/**', 'examples/E2ETestApp/*.js', '!examples/E2ETestApp/.detoxrc.js', 'examples/E2ETestApp/*.json', 'examples/E2ETestApp/*.tsx', 'examples/E2ETestApp/src/**', 'examples/E2ETestApp/android/**', '!examples/E2ETestApp/android/**/build/**', '!examples/E2ETestApp/android/.gradle/**') }} + # Built before the emulator boots so the emulator doesn't idle through the Gradle build. + - name: Build app + if: steps.apk.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp + run: yarn e2e:build -c android.att.release + - name: Save APKs + if: steps.apk.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ env.APK_PATHS }} + key: ${{ steps.apk.outputs.cache-primary-key }} + # Frees the daemons' memory for the emulator, which otherwise can get killed mid-run. + - name: Stop Gradle daemons + if: steps.apk.outputs.cache-hit != 'true' + working-directory: examples/E2ETestApp/android + run: ./gradlew --stop + + # --- Emulator: same setup as the native Android SDK's e2e jobs (AOSP image, no Play services) --- + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # --- Test tooling --- + - name: Resolve mock server revision + id: mock-server + run: echo "sha=$(git ls-remote https://github.com/GetStream/stream-chat-test-mock-server "refs/heads/$MOCK_SERVER_BRANCH" | cut -f1)" >> "$GITHUB_OUTPUT" + - name: Cache mock server gems + uses: actions/cache@v5 + with: + path: ${{ env.MOCK_SERVER_BUNDLE_PATH }} + key: ${{ runner.os }}-mock-server-gems-${{ steps.mock-server.outputs.sha }} + restore-keys: | + ${{ runner.os }}-mock-server-gems- + + - name: Run e2e tests (Android emulator) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ env.ANDROID_API_LEVEL }} + arch: x86_64 + profile: pixel + emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }} + disable-animations: true + working-directory: examples/SampleApp + script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" + # The emulator has vanished mid-run without any output; this shows whether the kernel killed it. + - name: Diagnose emulator + if: failure() + run: | + sudo dmesg -T | grep -i -E 'out of memory|oom-kill|killed process' || echo "No OOM kills in dmesg" + free -m + df -h + ls -la /tmp/android-runner 2>/dev/null || true + - uses: actions/upload-artifact@v6 + if: failure() + with: + name: e2e-android-logs + path: | + examples/E2ETestApp/stream-chat-test-mock-server/logs + examples/E2ETestApp/artifacts + /tmp/android-runner + + result: + name: Test E2E Result + runs-on: ubuntu-latest + if: always() + needs: + - check + - android + steps: + - name: Check E2E results + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + echo "$NEEDS" + exit 1 + env: + NEEDS: ${{ toJson(needs) }} diff --git a/.prettierignore b/.prettierignore index c4f36f8ee4..55d7ebb190 100644 --- a/.prettierignore +++ b/.prettierignore @@ -24,6 +24,10 @@ examples/SampleApp/patches/ .yarn/ yarn.lock +# Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts +examples/E2ETestApp/stream-chat-test-mock-server/ +examples/E2ETestApp/artifacts/ + # Generated SDK assets package/src/components/docs/ package/src/theme/generated/ diff --git a/AGENTS.md b/AGENTS.md index e295c458bc..9d725756c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Targets iOS and Android. - **Languages:** TypeScript + React Native - **Runtime:** Node 24 (`.nvmrc` is `v24`; root `engines.node` is `>=20.19.4`; CI runs `24.x`) - **Package manager:** Yarn 4.15.0 (Berry). The binary lives at `.yarn/releases/yarn-4.15.0.cjs` and is activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (even the Homebrew classic 1.x) acts only as a launcher — no Corepack required. -- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/ExpoMessaging`. **No Lerna.** +- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`. **No Lerna.** - **Testing:** Jest with the `@react-native/jest-preset` + `@testing-library/react-native`. - **Build:** `react-native-builder-bob` → CommonJS (`lib/commonjs`), ESM (`lib/module`), types (`lib/typescript`) - **Lint/format:** ESLint 9 flat config + Prettier, strict (`--max-warnings 0`) @@ -41,7 +41,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `native-package/` — bare RN wrapper (`stream-chat-react-native`) - `expo-package/` — Expo wrapper (`stream-chat-expo`) - `shared-native/{ios,android}` — native source shared by both wrappers; synced into them, never edited in place -- `examples/` — `SampleApp` (full-featured), `ExpoMessaging` +- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging` - `configs/typescript-config/` — shared `tsconfig` presets - `ai-docs/` — agent-facing deep dives (see [References](#references)) - `perf/` — on-device performance toolkit (see [Accessibility, RTL & performance](#accessibility-rtl--performance)) @@ -244,6 +244,8 @@ Native pieces live in `package/shared-native/`: the Android-only `PerfText` Fabr To run one test file, prefer `cd package && TZ=UTC npx jest path/to/file.test.tsx`. The `testRegex` array in `jest.config.js` also accepts a temporary path — revert it before committing. +**E2E:** Detox suite in `examples/E2ETestApp/e2e/` against the shared `stream-chat-test-mock-server`, with robots and page objects mirroring the native iOS/Android suites. Run it with `bundle exec fastlane test_e2e platform:ios|android` from `examples/SampleApp`; see `examples/E2ETestApp/e2e/README.md`. Elements it needs get a `testID` in the SDK (kebab-case, additive). + **Mock builders** (`package/src/mock-builders/`): - `api/initiateClientWithChannels` — creates a test client + channels in one call (fastest path) @@ -371,7 +373,7 @@ Follow `PULL_REQUEST_TEMPLATE.md`. Keep PRs small and focused. ### CI expectations -`.github/workflows/check-pr.yml` (Node 24): `yarn install --immutable` → `yarn build` → `yarn lint` → `yarn typecheck` → `yarn test:coverage`. Other workflows: `changelog-preview`, `lint-pr-title`, `release`, `sample-distribution`, `sdk-size-metrics`. +`.github/workflows/check-pr.yml` (Node 24): `yarn install --immutable` → `yarn build` → `yarn lint` → `yarn typecheck` → `yarn test:coverage`. Other workflows: `changelog-preview`, `e2e-test` (Detox on `examples/E2ETestApp`, gated by `is_check_required`; require its `Test E2E Result` check), `lint-pr-title`, `release`, `sample-distribution`, `sdk-size-metrics`. Failing or flaky tests: fix them, or quarantine with a justification comment and a follow-up. diff --git a/eslint.config.mjs b/eslint.config.mjs index 97425a89b6..468d782eab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,10 @@ export default tsEslint.config( '**/android/', '**/fastlane/', + // Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts + 'examples/E2ETestApp/stream-chat-test-mock-server/', + 'examples/E2ETestApp/artifacts/', + // Generated SDK assets 'package/src/components/docs/', 'package/src/theme/generated/', diff --git a/examples/E2ETestApp/.detoxrc.js b/examples/E2ETestApp/.detoxrc.js new file mode 100644 index 0000000000..2e090e80c7 --- /dev/null +++ b/examples/E2ETestApp/.detoxrc.js @@ -0,0 +1,67 @@ +/** @type {Detox.DetoxConfig} */ +module.exports = { + testRunner: { + args: { + $0: 'jest', + config: 'e2e/jest.config.js', + }, + jest: { + setupTimeout: 300000, + }, + }, + apps: { + 'ios.debug': { + type: 'ios.app', + binaryPath: 'ios/build/detox/Build/Products/Debug-iphonesimulator/E2ETestApp.app', + build: + 'xcodebuild -workspace ios/E2ETestApp.xcworkspace -scheme E2ETestApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build/detox', + }, + 'ios.release': { + type: 'ios.app', + binaryPath: 'ios/build/detox/Build/Products/Release-iphonesimulator/E2ETestApp.app', + build: + 'xcodebuild -workspace ios/E2ETestApp.xcworkspace -scheme E2ETestApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build/detox', + }, + 'android.debug': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', + testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk', + build: + 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..', + reversePorts: [8081], + }, + 'android.release': { + type: 'android.apk', + binaryPath: 'android/app/build/outputs/apk/release/app-release.apk', + testBinaryPath: + 'android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk', + build: + 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..', + }, + }, + devices: { + simulator: { + type: 'ios.simulator', + // CI pins the simulator it boots early (DETOX_IOS_UDID), so Detox reuses it. + device: process.env.DETOX_IOS_UDID + ? { id: process.env.DETOX_IOS_UDID } + : { type: process.env.DETOX_IOS_DEVICE || 'iPhone 16 Pro' }, + }, + emulator: { + type: 'android.emulator', + device: { avdName: process.env.DETOX_AVD_NAME || 'Pixel_API_37' }, + }, + attached: { + type: 'android.attached', + device: { adbName: process.env.DETOX_ADB_NAME || '.*' }, + }, + }, + configurations: { + 'ios.sim.debug': { app: 'ios.debug', device: 'simulator' }, + 'ios.sim.release': { app: 'ios.release', device: 'simulator' }, + 'android.emu.debug': { app: 'android.debug', device: 'emulator' }, + 'android.emu.release': { app: 'android.release', device: 'emulator' }, + 'android.att.debug': { app: 'android.debug', device: 'attached' }, + 'android.att.release': { app: 'android.release', device: 'attached' }, + }, +}; diff --git a/examples/E2ETestApp/.gitignore b/examples/E2ETestApp/.gitignore new file mode 100644 index 0000000000..9be6361de6 --- /dev/null +++ b/examples/E2ETestApp/.gitignore @@ -0,0 +1,79 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Detox e2e: mock server checkout cloned by `fastlane start_mock_server`, test artifacts +/stream-chat-test-mock-server/ +/artifacts/ diff --git a/examples/E2ETestApp/.watchmanconfig b/examples/E2ETestApp/.watchmanconfig new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/examples/E2ETestApp/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/examples/E2ETestApp/App.tsx b/examples/E2ETestApp/App.tsx new file mode 100644 index 0000000000..653961dc5f --- /dev/null +++ b/examples/E2ETestApp/App.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { Channel as ChannelType, ChannelFilters, ChannelSort, StreamChat } from 'stream-chat'; +import { + Channel, + ChannelList, + Chat, + MessageComposer, + MessageList, + OverlayProvider, +} from 'stream-chat-react-native'; + +import { apiKey, appUser, appUserToken, mockServerUrl } from './src/config'; + +// Same opt-in as the SampleApp, so the e2e suite sees the same accessibility tree. +const accessibility = { enabled: true }; +const sort: ChannelSort = { last_message_at: -1 }; + +/** Lists the mock server's app user, like the SampleApp's user selector. */ +const LoginScreen = ({ onLogin }: { onLogin: () => void }) => ( + + E2E Test App + {mockServerUrl ? ( + + {appUser.name} + + ) : ( + Launch with the MOCK_SERVER_URL argument (see e2e/README.md). + )} + +); + +const ChannelScreen = ({ channel, onBack }: { channel: ChannelType; onBack: () => void }) => { + const { top } = useSafeAreaInsets(); + + useEffect(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + onBack(); + return true; + }); + return () => subscription.remove(); + }, [onBack]); + + // Laid out like the SampleApp's channel screen: the header lives inside , which sizes + // itself to the screen and handles the bottom inset and keyboard for the composer. + return ( + + + + + ‹ Back + + + {channel.id} + + + + + + + ); +}; + +const ChatScreens = ({ client }: { client: StreamChat }) => { + const [channel, setChannel] = useState(); + const filters = useMemo( + () => ({ members: { $in: [client.userID ?? appUser.id] } }), + [client.userID], + ); + const closeChannel = useCallback(() => setChannel(undefined), []); + + return ( + + {/* The list stays mounted under the channel, as in a navigation stack. */} + + + + {channel ? : null} + + ); +}; + +const App = () => { + const [client, setClient] = useState(); + + const login = useCallback(async () => { + const chatClient = StreamChat.getInstance(apiKey, { baseURL: mockServerUrl, timeout: 6000 }); + await chatClient.connectUser(appUser, appUserToken); + setClient(chatClient); + }, []); + + return ( + + + + {client ? : } + + + + ); +}; + +const styles = StyleSheet.create({ + back: { fontSize: 17, paddingRight: 12 }, + container: { backgroundColor: '#FFFFFF', flex: 1 }, + flex: { flex: 1 }, + header: { + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerTitle: { flex: 1, fontSize: 17, fontWeight: '600' }, + hint: { padding: 16 }, + title: { fontSize: 22, fontWeight: '700', padding: 16 }, + userName: { fontSize: 17 }, + userRow: { borderBottomColor: '#E5E5E5', borderBottomWidth: 1, padding: 16 }, +}); + +export default App; diff --git a/examples/E2ETestApp/android/app/build.gradle b/examples/E2ETestApp/android/app/build.gradle new file mode 100644 index 0000000000..879f64489c --- /dev/null +++ b/examples/E2ETestApp/android/app/build.gradle @@ -0,0 +1,124 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + // + // The command to run when bundling. By default is 'bundle' + // bundleCommand = "ram-bundle" + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = false + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace "io.getstream.reactnative.e2etestapp" + defaultConfig { + applicationId "io.getstream.reactnative.e2etestapp" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + // Detox: builds the instrumentation APK against the variant under test + testBuildType System.getProperty('testBuildType', 'debug') + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } + + androidTestImplementation('com.wix:detox:+') +} diff --git a/examples/E2ETestApp/android/app/debug.keystore b/examples/E2ETestApp/android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..364e105ed39fbfd62001429a68140672b06ec0de GIT binary patch literal 2257 zcmchYXEfYt8;7T1^dLH$VOTZ%2NOdOH5j5LYLtZ0q7x-V8_6gU5)#7dkq{HTmsfNq zB3ZqcAxeY^G10@?efK?Q&)M(qInVv!xjx+IKEL}p*K@LYvIzo#AZG>st5|P)KF1_Z;y){W{<7K{nl!CPuE z_^(!C(Ol0n8 zK13*rzAtW>(wULKPRYLd7G18F8#1P`V*9`(Poj26eOXYyBVZPno~Cvvhx7vPjAuZo zF?VD!zB~QG(!zbw#qsxT8%BSpqMZ4f70ZPn-3y$L8{EVbbN9$H`B&Z1quk9tgp5FM zuxp3pJ0b8u|3+#5bkJ4SRnCF2l7#DyLYXYY8*?OuAwK4E6J{0N=O3QNVzQ$L#FKkR zi-c@&!nDvezOV$i$Lr}iF$XEcwnybQ6WZrMKuw8gCL^U#D;q3t&HpTbqyD%vG=TeDlzCT~MXUPC|Leb-Uk+ z=vnMd(|>ld?Fh>V8poP;q;;nc@en$|rnP0ytzD&fFkCeUE^kG9Kx4wUh!!rpjwKDP zyw_e|a^x_w3E zP}}@$g>*LLJ4i0`Gx)qltL}@;mDv}D*xR^oeWcWdPkW@Uu)B^X&4W1$p6}ze!zudJ zyiLg@uggoMIArBr*27EZV7djDg@W1MaL+rcZ-lrANJQ%%>u8)ZMWU@R2qtnmG(acP z0d_^!t>}5W zpT`*2NR+0+SpTHb+6Js4b;%LJB;B_-ChhnU5py}iJtku*hm5F0!iql8Hrpcy1aYbT z1*dKC5ua6pMX@@iONI?Hpr%h;&YaXp9n!ND7-=a%BD7v&g zOO41M6EbE24mJ#S$Ui0-brR5ML%@|ndz^)YLMMV1atna{Fw<;TF@>d&F|!Z>8eg>>hkFrV)W+uv=`^F9^e zzzM2*oOjT9%gLoub%(R57p-`TXFe#oh1_{&N-YN z<}artH|m=d8TQuKSWE)Z%puU|g|^^NFwC#N=@dPhasyYjoy(fdEVfKR@cXKHZV-`06HsP`|Ftx;8(YD$fFXumLWbGnu$GMqRncXYY9mwz9$ap zQtfZB^_BeNYITh^hA7+(XNFox5WMeG_LtJ%*Q}$8VKDI_p8^pqX)}NMb`0e|wgF7D zuQACY_Ua<1ri{;Jwt@_1sW9zzdgnyh_O#8y+C;LcZq6=4e^cs6KvmK@$vVpKFGbQ= z$)Eux5C|Fx;Gtmv9^#Y-g@7Rt7*eLp5n!gJmn7&B_L$G?NCN`AP>cXQEz}%F%K;vUs{+l4Q{}eWW;ATe2 zqvXzxoIDy(u;F2q1JH7Sf;{jy_j})F+cKlIOmNfjBGHoG^CN zM|Ho&&X|L-36f}Q-obEACz`sI%2f&k>z5c$2TyTSj~vmO)BW~+N^kt`Jt@R|s!){H ze1_eCrlNaPkJQhL$WG&iRvF*YG=gXd1IyYQ9ew|iYn7r~g!wOnw;@n42>enAxBv*A zEmV*N#sxdicyNM=A4|yaOC5MByts}s_Hpfj|y<6G=o=!3S@eIFKDdpR7|FY>L&Wat&oW&cm&X~ z5Bt>Fcq(fgnvlvLSYg&o6>&fY`ODg4`V^lWWD=%oJ#Kbad2u~! zLECFS*??>|vDsNR&pH=Ze0Eo`sC_G`OjoEKVHY|wmwlX&(XBE<@sx3Hd^gtd-fNwUHsylg06p`U2y_={u}Bc + + + + + + + + + + + + diff --git a/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt new file mode 100644 index 0000000000..b59b475ece --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainActivity.kt @@ -0,0 +1,22 @@ +package io.getstream.reactnative.e2etestapp + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "E2ETestApp" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt new file mode 100644 index 0000000000..dd573a42aa --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/java/io/getstream/reactnative/e2etestapp/MainApplication.kt @@ -0,0 +1,27 @@ +package io.getstream.reactnative.e2etestapp + +import android.app.Application +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + }, + ) + } + + override fun onCreate() { + super.onCreate() + loadReactNative(this) + } +} diff --git a/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000000..5c25e728ea --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a2f5908281d070150700378b64a84c7db1f97aa1 GIT binary patch literal 3056 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 axPAxe%7rDusX+w*00006jj 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⁣ zwiG+x#%j}{0gU~J8;<|!B1@-XaB@{KORFwrYg_8rOv({b0EO#DbeQRm;B6_9=mXGf z-x|VL{zd`)#@yN}HkCSJbjbNlE|zL3Wm9Q8HY`sV)}3%pgN>cL^67{Z;PPL(*wT8N zUjXU{@|*hvm}({wsAC=x0^ok0%UAz0;sogW{B!nDqk|JJ5x~4NfTDgP49^zeu`csl?5mY@JdQdISc zFs!E{^grmkLnUk9 zny~m)1vws@5BFI<-0Tuo2JWX(0v`W|t(wg;s--L47WTvTMz-8l#TL^=OJNRS2?_Qj z3AKT+gvbyBi#H*-tJ%tWD|>EV3wy|8qxfzS!5RW;Jpl5*zo&^UBU=fG#2}UvRyNkK zA06Dy9;K1ca@r2T>yThYgI!ont$(G{6q#2QT+00r_x0(b)gsE`lBB?2gr55gq^D3Fi&p%E(p9>U%bv zkg1Jco(RbyTX7FDHOnl7-O@ zI$AaIl?9NJKPm(WiBP`1-#CB1QzU>&hKm)fpa5DKE{2$X0hGz-0uZ?cyTk(YC!Y&| zL=1VrNERSA5NA2jq7FACfX4JfPyj5XXl1yv0>~s;eF7L2$>&oMqeTFT2m$y7FlkON z_yurD1yIOvA;5C6016pyxBznGUt0kJ&k5r#;&>Jow`r)sp9R~PmK~lz$3xH%LT*1U zJdOyABZ3!FvNoR*vN$5ykHS8f`jA4zV+|L}i1C4`B2c{R0;UdYxaU|H)2avz@ z=mEYc|2S<+(B2Tj+FkX+2D+yFI!k9lWMA61DJ{)e;lum$(;O87?vGJJe!KtK04+N_ zI*P~t@dUb>9Xh{dbyl{-ZQ(UMgz7$|QfL5XSPkskt^NgctYC#;4WcZB1@%@wy@2t3 z2z0DI7&%b$*Aw~abe?GxE`ez@+6hOh-6*8fHRV{1os$EL@}uUZeG4h1&Be`98q*7j z=3-v+lhIjfWVo12!<>%V^a6lTgW3+_#W6n|p*~==zOH7z$0{LSZk(Tpd7EaD04hnA zL;#fxS0aD{`5^&D`}>0Uq?byDD-l2=!wm_bLcUl4gc(% za1p|itVANvFF>hghAS07Im1;IK;|b*W)}VDyI;BIp2=K*yu2a)j?B|f<44NI$NbmJ z#dE0>jI$fMr&@>4kN8MLFb4&2O9fEKaQg%(QO$4_1rVQywG^CmBLh#}_7gKW3vd?| z2?1^&KWq8}8I^_S0|)MowU_pw$q@nl@Nkn$z>BQq_KA^9yaR`(R3u{{Ig;cwt z@AJ^{ODQCm^neroM9nKNUAXi9RCK`OsP_LuR0PUR(YZCCX5dNF6VzcoK&=b^r`W?ltt|*F zpkoae%ZT{C1h~EcFui~b7fF`vb<<~j_VquuUA$}QqIKYELPp#;{u?q8Dz}WAG-(3; zjrm$i%7UbyZMM(Y{>!uJ#vNB?R~B{6Htp=>e*<{fQQ5W7V(1coCWlOON!MzZxhum| ztZBQpGR z;~#ur^&PockKdV{Q6R>o`Pl{0x!DEbpZ7y9Y;*ZvE!*gU`V1W3znva{f=?WO5I&>B z&hw6}tjECtaghm5z|C#%M;Yf_*pI^};h}Vl=^r9EN=tVDj86D;C$jIJ?K7VP+00000NkvXXu0mjf D5i!M* literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..459ca609d3ae0d3943ab44cdc27feef9256dc6d7 GIT binary patch literal 7098 zcmV;r8%5-aP)U(QdAI7f)tS=AhH53iU?Q%B}x&gA$2B`o|*LCD1jhW zSQpS0{*?u3iXtkY?&2<)$@#zc%$?qDlF1T~d7k&lWaiv^&wbx>zVm(GIrof<%iY)A zm%|rhEg~Z$Te<*wd9Cb1SB{RkOI$-=MBtc%k*xtvYC~Uito}R@3fRUqJvco z|Bt2r9pSOcJocAEd)UN^Tz-82GUZlqsU;wb|2Q_1!4Rms&HO1Xyquft~#6lJoR z`$|}VSy@{k6U652FJ~bnD9(X%>CS6Wp6U>sn;f}te}%WL`rg)qE4Q=4OOhk^@ykw( ziKr^LHnAd4M?#&SQhw8zaC05q#Mc66K^mxY!dZ=W+#Bq1B}cQ6Y8FWd(n>#%{8Di_8$CHibtvP z-x#-g;~Q?y0vJA*8TW>ZxF?fAy1DuFy7%O1ylLF(t=ah7LjZ$=p!;8(ZLjXAhwEkCR{wF`L=hwm>|vLK2=gR&KM1ZEG9R~53yNCZdabQoQ%VsolX zS#WlesPcpJ)7XLo6>Ly$im38oxyiizP&&>***e@KqUk3q3y+LQN^-v?ZmO>9O{Oq@ z{{He$*Z=Kf_FPR>El3iB*FULYFMnLa#Fl^l&|bFg$Omlh{xVVJ7uHm=4WE6)NflH6 z=>z4w{GV&8#MNnEY3*B7pXU!$9v-tZvdjO}9O=9r{3Wxq2QB}(n%%YI$)pS~NEd}U z)n#nv-V)K}kz9M0$hogDLsa<(OS0Hf5^WUKO-%WbR1W1ID$NpAegxHH;em?U$Eyn1 zU{&J2@WqSUn0tav=jR&&taR9XbV+Izb*PwFn|?cv0mksBdOWeGxNb~oR;`~>#w3bp zrOrEQ+BiW_*f&GARyW|nE}~oh0R>>AOH^>NHNKe%%sXLgWRu1Sy3yW0Q#L{8Y6=3d zKd=By=Nb8?#W6|LrpZm>8Ro)`@cLmU;D`d64nKT~6Z!aLOS{m`@oYwD`9yily@}%yr0A>P!6O4G|ImNbBzI`LJ0@=TfLt^f`M07vw_PvXvN{nx%4 zD8vS>8*2N}`lD>M{`v?2!nYnf%+`GRK3`_i+yq#1a1Yx~_1o~-$2@{=r~q11r0oR* zqBhFFVZFx!U0!2CcItqLs)C;|hZ|9zt3k^(2g32!KB-|(RhKbq-vh|uT>jT@tX8dN zH`TT5iytrZT#&8u=9qt=oV`NjC)2gWl%KJ;n63WwAe%-)iz&bK{k`lTSAP`hr)H$Q`Yq8-A4PBBuP*-G#hSKrnmduy6}G zrc+mcVrrxM0WZ__Y#*1$mVa2y=2I`TQ%3Vhk&=y!-?<4~iq8`XxeRG!q?@l&cG8;X zQ(qH=@6{T$$qk~l?Z0@I4HGeTG?fWL67KN#-&&CWpW0fUm}{sBGUm)Xe#=*#W{h_i zohQ=S{=n3jDc1b{h6oTy=gI!(N%ni~O$!nBUig}9u1b^uI8SJ9GS7L#s!j;Xy*CO>N(o6z){ND5WTew%1lr? znp&*SAdJb5{L}y7q#NHbY;N_1vn!a^3TGRzCKjw?i_%$0d2%AR73CwHf z`h4QFmE-7G=psYnw)B!_Cw^{=!UNZeR{(s47|V$`3;-*gneX=;O+eN@+Efd_Zt=@H3T@v&o^%H z7QgDF8g>X~$4t9pv35G{a_8Io>#>uGRHV{2PSk#Ea~^V8!n@9C)ZH#87~ z#{~PUaRR~4K*m4*PI16)rvzdaP|7sE8SyMQYI6!t(%JNebR%?lc$={$s?VBI0Qk!A zvrE4|#asTZA|5tB{>!7BcxOezR?QIo4U_LU?&9Im-liGSc|TrJ>;1=;W?gG)0pQaw z|6o7&I&PH!*Z=c7pNPkp)1(4W`9Z01*QKv44FkvF^2Kdz3gDNpV=A6R;Q}~V-_sZY zB9DB)F8%iFEjK?Gf4$Cwu_hA$98&pkrJM!7{l+}osR_aU2PEx!1CRCKsS`0v$LlKq z{Pg#ZeoBMv@6BcmK$-*|S9nv50or*2&EV`L7PfW$2J7R1!9Q(1SSe42eSWZ5sYU?g z2v{_QB^^jfh$)L?+|M`u-E7D=Hb?7@9O89!bRUSI7uD?Mxh63j5!4e(v)Kc&TUEqy z8;f`#(hwrIeW);FA0CK%YHz6;(WfJz^<&W#y0N3O2&Qh_yxHu?*8z1y9Ua}rECL!5 z7L1AEXx83h^}+)cY*Ko{`^0g3GtTuMP>b$kq;Aqo+2d&+48mc#DP;Sv z*UL^nR*K7J968xR0_eTaZ`N`u_c#9bFUjTj-}0+_57(gtEJT|7PA12W=2Z>#_a z&Wg@_b=$d~wonN3h~?)gS`qxx<4J&`dI*rH9!mTSiQj(0rF-{YoNJRnOqd5IbP7p} ztDaPu$A;#osxf=z2zVe4>tpa(knS_Mp67nKcE<>Cj$G2orP(Z$Oc4;4DPwbXYZsS^ z;b>59s(LgYmx|tkRD?U{+9VZ$T}{S}L6>lQNR^a|&5joAFXtOrI07Do!vk(e$mu@Y zNdN!djB`Hq1*T8mrC@S)MLwZ`&8aM8YYtVj7i)IY{g&D1sJaY`3e=1DSFnjO+jEHH zj+|@r$$4RtpuJ!8=C`n5X;5BjU2slP9VV&m0gr+{O(I}9pYF32AMU?n$k$=x;X^E# zOb-x}p1_`@IOXAj3>HFxnmvBV9M^^9CfD7UlfuH*y^aOD?X6D82p_r*c>DF)m=9>o zgv_SDeSF6WkoVOI<_mX};FlW9rk3WgQP|vr-eVo8!wH!TiX)aiw+I|dBWJX=H6zxx z_tSI2$ChOM+?XlJwEz3!juYU6Z_b+vP-Y|m1!|ahw>Kpjrii-M_wmO@f@7;aK(I;p zqWgn+X^onc-*f)V9Vfu?AHLHHK!p2|M`R&@4H0x4hD5#l1##Plb8KsgqGZ{`d+1Ns zQ7N(V#t49wYIm9drzw`;WSa|+W+VW8Zbbx*Z+aXHSoa!c!@3F_yVww58NPH2->~Ls z2++`lSrKF(rBZLZ5_ts6_LbZG-W-3fDq^qI>|rzbc@21?)H>!?7O*!D?dKlL z6J@yulp7;Yk6Bdytq*J1JaR1!pXZz4aXQ{qfLu0;TyPWebr3|*EzCk5%ImpjUI4cP z7A$bJvo4(n2km-2JTfRKBjI9$mnJG@)LjjE9dnG&O=S;fC)@nq9K&eUHAL%yAPX7OFuD$pb_H9nhd{iE0OiI4#F-);A|&YT z|A3tvFLfR`5NYUkE?Rfr&PyUeFX-VHzcss2i*w06vn4{k1R%1_1+Ygx2oFt*HwfT> zd=PFdfFtrP1+YRs0AVr{YVp4Bnw2HQX-|P$M^9&P7pY6XSC-8;O2Ia4c{=t{NRD=z z0DeYUO3n;p%k zNEmBntbNac&5o#&fkY1QSYA4tKqBb=w~c6yktzjyk_Po)A|?nn8>HdA31amaOf7jX z2qillM8t8V#qv5>19Cg_X`mlU*O5|C#X-kfAXAHAD*q%6+z%IK(*H6olm-N4%Ic)5 zL`?wQgXfD&qQRxWskoO^Ylb>`jelq;*~ZIwKw|#BQjOSLkgc2uy7|oFEVhC?pcnU+ z^7qz}Z2%F!WOp%JO3y*&_7t;uRfU>)drR1q)c7lX?;A1-TuLTR zyr(`7O19`eW{ev;L%`;BvOzh?m|)Rh?W8&I$KVvUTo?@f@K!du&vf=o6kKb?hA z%e6$T0jWS7doVkN%^_k3QOksfV?aC$Ge$a)z(!C@UVs*@qzDw*OFd*JfX#>5LCXjE z_vfUrLF7D`K$U2Ld#OCnh9U!;r7%GlKo$e__Il-oba06ER{H&f#J&W@x^^5j;y$0` zs2`m6pf+{UiDb{Mjsb$rH+MCM6G_wX92so96`ODFYKD>!Xz^0y@U7Tc1uON4L<>2f-oPe%FRPEZ@S#-yd7Md-i?v z)$Kgtq;%4g@>Kap3Nl2I&jnCIfGmRmcF4CXfF1H}3SfhLg8=!a0ucGaUk&c3*Ykgl z2X_L84cs+FD#cjf-nMJkVDH%XzOoh5!X-Q$K5VZx-hGF7MQ=XKBjhZZQ@1Sh zO^vY`WQ`zi21z-+01na%<^niMFIWm-n|!?hm4X2HEHkba4YS|+HRoIR=`#Xck@PFXaPjnP z=hC4A*0lumS+gpK=TUN!G;{WqICbMz-V=-lTP^@a#C|E!qH;T00SZh7u#?+?08g0< zV1s%-U-`T@8wGh!3pO^`zUIY{nAED7kBqg!qi&GfOp>57f2PGTV19m z0qU@1PYkf%4z_%;Sq4IY94rS+ie~pwT@O3+tg?#k_=5PIk6tV@< zwLoqM0wBVLkI#`|1w=eYMnc^aRR!t?lnUng>WekR#X!!9mYXL3g^gC7`)S7mmo{y} z9*N!d$s32Nu{cZp#O|UxEZK7eY<7hGcI=lc;HrSVL|HA|S$rhhu_DBT&l+`75d`Sj3LaM~H)P zZuk2&jor6yipafklSsPL-vMo?0yAYXpH3=LveBhkno-3{4VLWL16I-@!RM$Po>&}} zm&PX3-$i>$*yx-THZmvK2q`8Qm7B`(NMR;>VSgoGw}W|G6Xd6v04Zf;HIZ0DZU?@- z39vPe0N8w(9kl$2?eG4T?tLgY5V&aFl%~g;2)aSpi!dl?{hDgsz|3<-M(gPtwP_!n z2aB4tV?d0k+>X`+(HMYfK@qtfDK|mIJeg+A<_i-n+5wkrexFs#V0N&~+{+qJ(wggC*52o2daaRwcu7r;S!!KwguB3!Ei7?IEY ze4V$m{8B4Q^(VK4~Ea!V@@}Gs0HGbR5 zy~WI*21hZuoiK`=O$2a|Uce-Zi2%A*pB|?{gv)n8+_B+i&u8Ys)ePY+UwhBDlzbC& z+N00*-?a8DTC26*(3pKgeMO`fOau^-+c6Qqq}3-dpTsEEH}ds! zT^}8XAWO>c5%+qF%#M8#x_0gC+N%q8h6-%w;qidS%gai<T)vpfYuCHXRx6O-TbC|fnj87X zBESvn(9XlXFMj6%{&BaNQ&;xixaKP)+jJ|%u&?HXvYficY}{%hf?0rNDS-X-0_Jcr zjfj~n?T;~RL#sd4ZED2Jf{*Vj+*1eP9-H+~8X^#Jb?HHabLY)EH{QD@Yh-$M`XXt@3_f-L8nBo~*C?L4~n6M92PCuzX=KFgM*j!B66er$F! z+*M(Wkk`UI@uhrL#IUz-C{K@@xtd&n-PQz%kc}7YeE{{&$?}-*yW$eG*E4jp>B_U!2`2oZuvvitN& z%RN>tE$+Yhtqb1q+xQHbp=W4uKSiIj_LZppR0=hEiVj>P0^Vcr^hu2+#Hqum+}zzo znqZ|M4oD|qd=y&JX-qob`=uqt?o%FJPIVY2w0M7BH>#sx>s#OM#9JF1(3LxMAe-vi ztJeU*G)aksP`5sP9_%|~>Pp{NmMMcay>&D+cI%H}$uSx{Su(yz$)2e$*pS%*+!Zo>DNp(P7 zI%w^D2ceEFUGCtQPKfsKr`x%^dy;Rh>lMKuhA^btz=071W=vV`_xz&m;cvd0`|!3+ z2M6uga6CNvy)%Pjw_X}5+xf###jc+?=>6chZI{BMH=haH^7ipT>(?9{weF3apk<4; z_nZFsi`@oFBXCZE^k9B1x+cH2)~9d(MnfEm;GJxG*IB zU@ly{cOTWk*K1ryX+T7m!6A>VwB-*qfH;b>`AUP19lLSA9HbfppW!={L0K)??SymOCA^V>=tOBLn2c5e ksm9QK-qMKdW>5J419kFO%DdQj-T(jq07*qoM6N<$f+5oB`~Uy| literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..8ca12fe024be86e868d14e91120a6902f8e88ac6 GIT binary patch literal 6464 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 B0*L?s literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..8e19b410a1b15ff180f3dacac19395fe3046cdec GIT binary patch literal 10676 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 literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/E2ETestApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4c19a13c239cb67b8a2134ddd5f325db1d2d5bee GIT binary patch literal 15523 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 BIP(Ai literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/app/src/main/res/values/strings.xml b/examples/E2ETestApp/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..a0ea08d876 --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + E2ETestApp + diff --git a/examples/E2ETestApp/android/app/src/main/res/values/styles.xml b/examples/E2ETestApp/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..7ba83a2ad5 --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml b/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..a667072fee --- /dev/null +++ b/examples/E2ETestApp/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + 10.0.2.2 + localhost + + diff --git a/examples/E2ETestApp/android/build.gradle b/examples/E2ETestApp/android/build.gradle new file mode 100644 index 0000000000..49baeef939 --- /dev/null +++ b/examples/E2ETestApp/android/build.gradle @@ -0,0 +1,34 @@ +buildscript { + ext { + buildToolsVersion = "37.0.0" + minSdkVersion = 24 + compileSdkVersion = 37 + targetSdkVersion = 36 + ndkVersion = "27.1.12297006" + kotlinVersion = "2.2.0" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } +} + +ext.REACT_NATIVE_NODE_MODULES_DIR = file("$rootDir/../node_modules/react-native").absolutePath +ext.REACT_NATIVE_WORKLETS_NODE_MODULES_DIR = + file("$rootDir/../node_modules/react-native-worklets").absolutePath + +allprojects { + repositories { + // Detox's Android library ships inside its npm package + maven { + url "$rootDir/../node_modules/detox/Detox-android" + } + } +} + +apply plugin: "com.facebook.react.rootproject" diff --git a/examples/E2ETestApp/android/gradle.properties b/examples/E2ETestApp/android/gradle.properties new file mode 100644 index 0000000000..ab47cc1f7b --- /dev/null +++ b/examples/E2ETestApp/android/gradle.properties @@ -0,0 +1,54 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +# Emulator ABIs only (arm64 for Apple Silicon, x86_64 for CI); CI narrows it to x86_64 with +# ORG_GRADLE_PROJECT_reactNativeArchitectures. +reactNativeArchitectures=arm64-v8a,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Opt out of built-in kotlin and new DSL behavior that ships with AGP 9. +# Starting from AGP 10.x these opt outs will be removed. +android.builtInKotlin=false +android.newDsl=false +# Reuse task outputs across builds (and CI runs, via the Gradle cache) +org.gradle.caching=true +org.gradle.parallel=true diff --git a/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 zcma&NW0WmQwk%w>ZQHhO+qUi6W!pA(xoVef+k2O7+pkXd9rt^$@9p#T8Y9=Q^(R-x zjL3*NQ$ZRS1O)&B0s;U4fbe_$e;)(@NB~(;6+v1_IWc+}NnuerWl>cXPyoQcezKvZ z?Yzc@<~LK@Yhh-7jwvSDadFw~t7KfJ%AUfU*p0wc+3m9#p=Zo4`H`aA_wBL6 z9Q`7!;Ok~8YhZ^Vt#N97bt5aZ#mQc8r~hs3;R?H6V4(!oxSADTK|DR2PL6SQ3v6jM<>eLMh9 zAsd(APyxHNFK|G4hA_zi+YV?J+3K_*DIrdla>calRjaE)4(?YnX+AMqEM!Y|ED{^2 zI5gZ%nG-1qAVtl==8o0&F1N+aPj`Oo99RfDNP#ZHw}}UKV)zw6yy%~8Se#sKr;3?g zJGOkV2luy~HgMlEJB+L<_$@9sUXM7@bI)>-K!}JQUCUwuMdq@68q*dV+{L#Vc?r<( z?Wf1HbqxnI6=(Aw!Vv*Z1H_SoPtQTiy^bDVD8L=rRZ`IoIh@}a`!hY>VN&316I#k} z1Sg~_3ApcIFaoZ+d}>rz0Z8DL*zGq%zU1vF1z1D^YDnQrG3^QourmO6;_SrGg3?qWd9R1GMnKV>0++L*NTt>aF2*kcZ;WaudfBhTaqikS(+iNzDggUqvhh?g ziJCF8kA+V@7zi30n=b(3>X0X^lcCCKT(CI)fz-wfOA1P()V)1OciPu4b_B5ORPq&l zchP6l3u9{2on%uTwo>b-v0sIrRwPOzG;Wcq8mstd&?Pgb9rRqF#Yol1d|Q6 z7O20!+zXL(B%tC}@3QOs&T8B=I*k{!Y74nv#{M<0_g4BCf1)-f)6~`;(P-= zPqqH2%j0LDX2k5|_)zavpD{L1BW?<+s$>F&1VNb3T+gu!Dgd{W+na9(yV`M7UaCBuJZg1Y)y6{U}0=LTvxBDApz@r>dGt(m^v|jy&aLA zdsOeJcquuj3G^NkH)g)z@gTzgpr!zpE$0>$aT^{((&VA>+(nQB!M(NnPvEP}ZRz+6 zE!=UW!r7sbX3>{1{XW1?hSDNsur6cNeYxE{$bFwZzZ597{pDqjr%ag85sIns_Xz%= zqY{h#z8J6GA~vfLQ2-jWWcloE5LA62jta=C*1KxAL}jugoPqj4el4R4g3zC4nE#2-NeS{c3#!2tIS|1h8*|kpw2VSH9OcIQZx0Yh!8~P&p}fI$4Bj9Z zr5Yv?i-PfO#<}clM>mO(D0wHniZZdv8pOuJFW z+-u}BH84PQCgT~VWBM88vtCly1y$uEGJ<7vnW%!2yV>l>dxA0X0q{cN6y3u$8R-*f z-4^OlZ1HmxCv`dFW%quP<7xzAbtiFxvY0M1&2ng&A}QXAVR=prc_5m(D+_?hv#$M^ zG#MQ#fHMc!+S%HgU^Qv7Z9eu6eNqpSr3e8(;No*YfovbJ;60LjCzv9O~^>gFKO>t zGZg9`a5;$hksp*fHp{7&RE@DM&Pa@a>Kwk%*F7UGO|}^Z0ho1U$THOgX9jtCW6N$v zLOm}xcMBtw)CC(;LLX!R9jp|UsBWGfs@HaMiosA3#hFee7(4vLY}IrhD++}>pY zo+=_h+uJ;j^CP*OGQ9$0q+%}UB`4`5c766d#)*Czs<91wxw)jI^IdvyjT%<8OqI=i zNn0OUqW#POg^4ma)e2b?*Xv;dri*N0SJ7_{&0>;S!)!YV1TQuiT1C3ZFDvThe}yTCmErx#6yyQ4X@OAbHhdEV!K2%;7J>tiUZF)>Z|eRVDwtDC~=J z*M8|WEgzsyNH@-5lJE+P6HrurgY!PqtWk z^69SOHZ*}xn|j2FDVg`qRT}ob*1XiGo=x8MDEX)duljcVO}oJjuAbB$Z+f&!{z3k< zO6+{@O#2^s4qT`6k}Nw?DKV1DU~}0jVA)(kNz$c-p`*FNG#Gb&o?ko70F||R^y*hD z6HD|hJzF)G&^K=vuN$@b2fIfHVFw@hC_-0hPnB!1{=Nn~ran4VeTMM(Xx2A3h95U} z&J#Kw4>*V(LHOA<3Dy{sbW-9k5M2<%yDw~ce0+aez8 z04skG8@QEESIL;m-@Mf_hY!)KkEUowHu(>)Inz(pM`@pkxz z1_K#Qs6$E^c$7w=JLy>nSY)>aY;x2z`LW-$$rnY0!suTZSG)^0ZMeT#$0_oER zfZ1Hf>#TP|;J^rzn3V^2)Dy!goj6roAho>c=?28yjzQ>N-yU)XduKq8Lb3+ZA|#-{ z?34)Ml8%)3F1}oF;q9XFxoM}Zn{~2>kr%X_=WMen%b>n))hx6kHWNoKUBAz?($h(m(l;U*Gq7;p5J{B;kfO^C%C9HhtW!=O3-h>$U zI2=uaEymeK^h#QuB8a?1Qr0Gn;ZZ@;otg2l>gf= z$_mO!iis+#(8-GZw`ZiCnt}>qKmghHCb)`6U!8qS*DhBANfGj|U2C->7>*Bqe5h<% zF+9uy>$;#cZB>?Wdz3mqi2Y>+6-#!Dd56@$WF{_^P2?6kNNfaw!r74>MZUNkFAt*H zvS@2hNmT%xnXp}_1gixv9!5#YI3ftgFXG20Vt1IQ(~+HmryrZI+r0(y2Scl+y=G^* zxt$Vvn&S=Vul-rgOlYNio7%ST_3!t`_`N@SCv$ppCqok(Q+i_?OL}2@TU$dr6B$c8 zQ$Z(lS6fp%7f}ymQwJAIdpkN~8$)O3|K7Z;{FD?hBSP-#pJgq0C_SFT;^sBc#da0M z;^UuXXq{!hEwQpp(o9+)jPM6ru1P$u0evVO(NJ;%0FgmMNlJ+BJ zf^`a|U*ab?uN*Ue>tHJ$Pl~chCwRnxi3%X06NxwlIAKa*KReLL^y1B^nuy|^SPj3} z5X|?1divh3@zci;648jb2qEOm!_8Tjh3gi;H%2`d`~Q(IL{Wcl1C18+&P>tU&0!nO z&+7mpvr2SsTj=@sX zxG=;T^f7Rg=c=V*u8X(fo)4;RYax^+=quviOJ{>r6{wgf)g){I&qe`=HL}6J>i6Ne zSZ*h9f&JG>Y`@Bg5Pb&>4&UqFp9I<8o`n4W_V=4AugM`RqUeS-!`OyNLyKMqa_Ct| zON-hyk#-}{lZZx>B1F@dF^8S>x|C*QAjKqn&Ej9H#z@Q#KA*ckBX@^;gIP&?aK15l z*EY@kG57oUcm(d{NyXg6$Kj#xR5XdZ1EBCT+Zy!gyXwN&b_zI&$$>7R#{ zh8U@H8NY-cA*CBfH$OCs^priPwtwrzFjDO}DBn#mgbI~hn}cp2U{yv@S)iy|jR9+E zgd(hF|1cyC#te0P;iFGqpNBqc(k<{p^1>wHE_c8Tr4|&NV4mzpzFe;Cr)C~qpVNjl z^u(^s5=kj{QBae)Y*#^A39jT4`!NuIUQzD#DOyfa!R=PrX6oS@x@kJV)Cn$!xTK9A&VI#F-Slt8I4|=$bcjaC5h=9E{51g8X5q1Qfg~~G>qAgy*7h4-WuqE zlIEx?Hu*%99?$6TheLAD4NIMO=Q@*;gaXDl6yLLXfFX0*1-9KQm42c%WX*AXFo$it z?FwnWn2tBHY&Qj6=PV?ergU$VKzu+`(5pCRqX}IoSFo?P!`sff%u1?N+(KsoL+K={ zi*JGl%_jiuB;&YW+n%1o^%5@!HB9}OlIdQZ*XzQ%vu!8p2gnKW+!X>@oC{gp3lNx^ z82|5Jdg9-B<1j|y(@3J;$D-lqdnf0Q6T~q7;#O}EMPV3k(bi$DpZwj9(UhU%_l&nN zR}8tN_NhDMhs)gtG*76~+W2yQ{!kDTE@X4gft2?W;S$BLp9X z;sh2jpm!mkfPX>Vuqxyt76<@f4fyY%&iuDfS1@#PHgzHqG;=X^`X}t2|Alr^lx^ja z1rhvG(PH(a0THitc?4hk=P*#IS;-`fjOKqJ4kgo@dAD@ob*))H)=)6s3cthp&4Q55 z4dQRdG0EveK*(ZUCFcCjILgS#$@%y=8leYxN-%zQaky@H?kjhyBrLYA!cv>kV5;i1 zZ^w&U7s&K8fNr4Pfy9GyTK2Tiay4Y_PsPWoWW5YA8nfUkoyjU)i@nKj@4rY13sxO6 z_NzYdG=Vr<@08Xi#8rnX&^d{Bl`oHXO6Y3!v2U~ZV>I*30X3X&4@zqqVO~RyF)6?a zD(<+33_9TqeHL)#Y?($m4_zZvaJXWXppZ4?wo?$wF)%M6rEVk2gM=l9k+=*Q+((fI zIUBH6)}M?ahSxD4lgmJ30ygk#4d!O@?%WNEONommx`ZK81ZV)mJpKB`PgQ}F>NGdV zkV|>^}oWQd6@Ay7$&)6!% zOu_p~TZ3A#G_UqiJ85&*$!(+!V*+*{&-JXb53gtc9n3>8)T$jUVXe+M6n$m633Mi? zlh5{_+6iZ<%gMWMrtHyDl(u-hMl^DViUDc50UD;0g_l$F`Hb(F=o+?94B0fjb;|?Q5c~TWX>t8i1RP@>Ccgm z?2=z0coeb?uvn44moKFb^+(#pAdHE7{EW(DxJE=@Z0^Am`dpm98e`*S+-~*zmhdQ7 zCNig0!yUu5U#>KKocrg-xMjQoNzQ`th0f{!0`ammp_KMFh?_zF4#YhF35bPE&Fq~_ z#VnniU6fso{!3Z^1C57q?0i!ok(a zL;-f$YlDk%qi%n637_$=Gw=bBY}8#meS~+#X}Oz~ZKd%q(UE>f%!qca?(u}) z!tLTuQadlAN;a#^A?!@V=T?oeJ1f7yRy)H1zn_+wARewYIYr`zD=^v+D|ObvH4rOB zT@duqF>$Dk6&i|pZh?%Wq-7_kyP4l)-nqBz#G0lqo3J2D%zmbU)>3)5e?sTZy8|~B zPC7!`eD+deR?L6$6 z-e{!ihef=f<4HPZ9rSt&yb=5Q)BFAXWPR^~a&Zru?8146wvlm;<)ugbd|!}O6aE0t z6`#KqcH#S#*yz-K90+!Fhv+ zKH+?!_0yl|gWXSaASLcB9a8g7i%qz*vbO)YW`Q@Nxpp*6TZ*OO8Z|5-UWihd@CUXF zY!aTAZ$c^?4hiaq34=s2il}#Pxu=#c2^=(PbHNAyUqy__kR+n?twKrQe^8l6rk=orf}Mk80viC1NZ^1q zeF~g*iGp0=jKncK%s@#jZcn6=EiR<8S#)yiEOuwbG;SV$4lB^R?7sxOf8)oq$sT)) zA&nBCFJxsnci+)owdCHV#cjP2|1j22xIRsxHrLLBk3GI|OppUv3%r>#;J|26!W>xC z9gq@NQWJ`|gH}F{-QG#R6xlT<;=43amaDT>VaG*;GfPZJ&W*rO8WAQQc^JGw-fz-| zzAe&RAnC(gAP#FoJtt~ynR3Z<)m_<9Oo)XW}CWd50^eI4!1p4}s(zLhBIDi5r zr{UH>YIz2!+&Cy(RI(;ja_>SUC2Q`ohWPlI+sK-6IU}*nIsT)vLnuVPFM%~gdel}S zUlY%>H$?-rQRGTdUM^p^FEkqnwC{^BGl|gM)h9zkXplL90;yOcgt(8&LJwOj!5Qgy zu$@^*k%9JoAzwj@iSB^SNu#YVl@&*g$uYxxsJBvIQ>bfuS97JccQcS7&a z)`1m2^@5c9pD`P$VqH*O*fxkvFRtH-@Pd0@3y2!jW>i=jabBCJ+bW@wwUkWjwx_WR zHH5*XR4hbQ1`D@4@unmyEX)!?^~_}~JQNvP4jO&F)CH9srkFhf8h*=P z;X1&vs_&v03#BGc`|#@!ZONxVj9Ssb#_d63jxA6dX_RBt(s;ig3#s(YU3P3klF;mc z%%@^IJUAlGE=cnsTH+(qb1SxN@HzfAjYcUCb(VU)JV^3ZC;#k!t?XjaC!|68eLE zU_hlvOSNj7Qlr{x)y$S$l^2DPCMA=pzapcSkjfk*r!iWU%T{?<3#Hw6s1ux1^Ao6o zR@5DIfo-|c9AaFw848Y!BVG-+vURe;I29F#hLu$9o}oSa9&2sgG#;lj@@)9|2Z3 zon?%NV&AYSVnd~eW~v0yoF$X^1FR@i2kin0mFLG8-aA>hYK;B%TJ~7%P4?_{Bu<0t zvmI)Uk-MRncVb)A890>OqnYf=wu-J5A~^%4jpK~*xp)=h0BZB4*5uWrP>iRV+|kMX zv+BEskY~(P-K)-!JSHR`$brY)HFI|L@YyrxheT3cgHu}KtF%s%k3B`X)E_lA=E>M4 z2VV3M{c0*)`qZAsJ==)F#D~2Ndzm@hKhSBL_Sf3{ctckh-rB`gkfC?Dp6FdM?p;vv z#UlQMp3H5*)8o#Ys@-aj7O#brUfgQ7BjG`7 ztoE7v-tH2%KVC$xKYf%uvZD!_uf3x>h?8r!zYHkcc7$Gdn(6cDmYL&p3pCfaSfY4$ zG|yuujr6!Wl0}V%* zQ;nY##kEdvo8YY=SVDb)M>^Ub9e#4c$O&urD$uaRtxm-UH=6_s0m^^5y^_+F^Q?;8 z+Fd?+De}er^2EmFNn&e8SyS*`*`e;KFIG&+x5iWCsrEyH*0SFBCMx?`m5~hl1BrT> zr8W3*3}Fwsx@%UOuxNoCSoL%AM{Uj|v@>l{pYYI&D$j`&**;?X`cuOOk~?;U{~xvDUjaiH^d`A+gQL#Z?*lm)x_n6R-S% zf6*=Q1m>mq5|Niefl8s=5F={ncn5S;6~&Ns2)yGZ@wt&u4c+)Sk?hdfI^b77@K-=y zM_k=j5hp&u`2nkJK+2Lw`uLypr4dO?Bm3BTZdtWnQa5unCoTKIiG81t4bG`epBU5| zG{toT`)LE}&j{P+AFj`YZrjF-^>k+`zCM`QcQz^Ba4BEte@S}j=Q_Opx14jq|DB}& zNB44BOJ`?GJM({v`gh9pzbg8-%Un=E@uLfJwGkagLEM^!`ct3s5@-xqq*xd+2C@eu z*1ge`retZK)=bPO<`>@62cLN?^S%v#EsiPQF`cg&I7{}l?)}O$!^wNJp4Zd;1yBbQ zv@_7x7d6aXJvGHkNNcOg?A};m_Nq7H=(+zqf9)e3&yP^EU63Ew!NW4CYj_!=OTVb* z-ijSrv0M)u=MF=@+`3ldT-hzOn$Ng><)WL0vqQ&jH>W7EmLLQY+c?%i9~f_x&{OYX z{?kyyNZ&gT*m$(%-OeDAJeC^c)X!k${D*c;c}9)0_7iWMbfu)!j3+{*!Dj|?C`sGz z2xWha)#`9@p*{-X2MN2a;%FM-WqB2h)GTqQH$ZsGD#Wi`;+$i?fk;23fLpYI^3TT3 z5+Zn3cu-_2Ck*@%3^L3}JpVN`5ZJ;gmKn>gm(Z)b%!v|RYf(qrmGL#0$WHQFw4mJqQ85w=$tn^7(z|eJ$3R0} z2k9^EU<^-$ygq!ZR+7wT0KViK8qkAO7xs*e@1dq{=M3haulHwA0~BYNytr7k2K*(W z755P9a^;Hdl2X;K{c}yWr|QH?PEuh6x)9n{^3m2QUfC_Q*BW&<9#^ZVwOolx@6y9- z-YF=S;mEypj68yxNxfJ56x%ES`z-5$M${V1HX(@#R>%$X`67*Ab8vC6UzvoDOY*P= zFbPXany0%>rqH1gi7d>e`=PWZTG>^=#PQf&iJjJ0&2dO(4b8) zCl%8xJg1mg4__!?t|y_roExn~%u@Eu|p9YFb`8_qP@v#KW#kFs4eVetJ+Q+s|Y0?#D z@?dt_BA7C4tGpjOB~*LFu0!5oU(_xj7xA$meN)Z;q4Z_Rb7jY1rJBzJPr0V=(y99F zh=V-NbK+64rd#ltw~7X-%kP$R896DxRuj)p7Zj@8&>IlP&}ME3s9eV2R>SpUnSxeg zmpm?HQJ^u1T;pvwvlc4F_)>3P~jlTch4+u6;o{@PtpnJcn~p0v_6Po%*KkTXV#2AGc) zv)jvvC?l#s$yvyy=>=7D3pkmV24xhd7<5}f_u5!8gmOU|4555dv`I=rLWW!W!Uxg| zFGXpH3~)9!C2|Y6oB~$gz(;$CTnw&R&psa+E!KNgrE1+WkLM6SOf$>sGW+Y{>u?Fw zTc!xG{pa3c#y@d$d0e7a9~e_xjGcaw5f6Fk>lg$Jm}cFd%BO_YT(9s+_Q;ft%1*k$ z_cXkf&QHkaQr9U?*Gr$r6|bCV>2S)Cedfk3rO?JbyabY zgqxm#BM7Sg6s-`5%(p@SxBJzR6w`O6`+Kuo36wwBzwf6K{0HENVz^^w|E$r zdZM%T0oy8OK|>>2vSzw5rqoqEroCZ%(^OmOSFN84B2-8Z?R1)Pn9|5Xkui(fQRl^zA35EH^(JbuQd@Uh z2FJ6C(5FDD(++_NLOG)1H<+X~pt68d@JiB8iUQSZ+?qc;Jr+aJ8bKF3z`K&zSl&C7 zEgl&!h?sc=}K7 ziEC(3IrY?h7|d= zVjh{@BGW^AaNcdRceoiKmQI+F$ITdcM$YigXtH)6<-7d@5DyyWw}s!`72j`A{QC~e ze-u0a6A;QSPT$vqf3f(kO1j^%GYap*vfWQ@X=n{lR9%HX^R~t+HoeaT5%L7XSTNn` zCzo})tF@DMZ$|t6$KTx+WQqu~PXPa9FL&shBGx3C>FlGz}7gjfv}(NKvjR#r5PL$a1>%asaylWA8^g!KJ=$}_UccHmi zAZd5c{I&Ywpi3a1#27C6TC~zm3y8D>_1an8XHGNgL?uT$p+a<5AdWLR6w9jdhUt9U zz?)93=1p$x;Qiq!CYbX&S}+IITWLkfu%T6X5(pk9-fs8lh9z8h?9+>GlFeFcs*Z>u zJSaL!2?L8LbOu_Ye!=4~ZKL?643lcsNn8>qUT|q&Rv+(z>Z9=tyG&5}zZK&Q?S!nG zR;Ui^<406=jLYA>zl!a-OXH#J-pP4A`=)r%9HV5m1qGZ1m*t^wi>3$JRcH)3Q(LQz z(3}~y3=QsUu!PN$$N~#yBP@=aJ+Bkp_hx8^x1Ou6+(Kk9l1CXr4p~IQvq@AUePuAj zcq5>YDr(JTmrAuLwn6sgohTR-vc^y^#I{grF7 zg}8?&5!^$|{X`C;YrZ7?rKH#`=n0zck(q37+5%U;Hmds2w+dLmm9|@`HqQ<5CUEz{I1eNIL?X~rd{f71y z>_<94#1G+j`d5|fKK@>QDK6|HRR|9UZvO6HdB1afJvuwUf8bw>_Fha)Ii8I}Gqw}p zdS~e^K4j{d%y+A#OBa1C4i0)sM=}tjd8fZ9#uY}{#G7rJp{t6?*5*A^KKhim06i{}OJ%eA@M~zIfA`h_gJ_o%w;FaFQMnVkBT|_ z(`m9r+11~EPh9f7>S=$F7|ibj=4Pt>WVzk6NfGRvI_aG66RHig-(S%WKRLP%_h0He``xT))N^RI@6!ADl=*vsqVb|7 zr~Lwl6qn|u!%is<{YA`Mde2Z${@EAHC^t>4`X;F9za=RC{{$4OcGmw%9+{$i@!cCn z;7w~r8HY->M@3OzYh+L7Z2Lc8AcP*FZbl6VVN*_sp}K zQP|=g@aFthq}*?|+Gm4@wbs_?Fx-HD2%)_UDJ);X88~7ch~d0cJ!<7;mv>iv!RS$a z;(-cYTW=K=|F0gIg3EW0%u2CSr(Kx}yLoki|KSIt$#P(O!=UjBGRzb3L3-?NGr7!! z^VC7_Q(GhT;C*(bLivfhlRDVdz7=h%ABuLA2g$qy)A}U@Kj_L-Jd|--fy#-*ESRo| zgu?*?jGEgs9y>1`t}|^Ucd1I=1N=mOo{8Ph zwZS(F%G?nfI{#%sGayNItK9J5P)Qk+^4$ZoXZJ0G1}hwcckJ0g-QJ<)3%`bF8}(ahYIjKFYMtg3X;e7J18ZvDkV@N=nxvDl zo?}lXoT3pZY;4$QKI`~GFuQKv;G6b<8;o89Hd2yu+|%sU(9C=h8ibwZ zARqZ#lk@kp4*#URe-YmpRc&=-b&QP>5b{9{(tH*)(@ZPKfOslBgwCPx6d*{XMX|Q{y0F!5a^ScCE;h8bQmTJR3*}A>aGcDF0?tU)Tnml z#DgruwAva-fiU3s*POY_ZHiJyW%v+733X`&ocwHz$uqJCOhrM;#u*V2eK$D5HiN(` zII{BEg(PV6#_Nv3rZBUyd+TI!>L72KW_Oml6L=pNv#aOl( zgpYxAH^@2aJQu3urlrCeanwSpHHD_Cxb+=cm49{ZU5Z@;{^{okEJ6&fpDD31w~$`% zcz@_REsC~Vq>3YF7yJ41ZEPBW&%|OwlnfG|QNpiX;fGR0f^3?PEf|-33P&LFGe`8^ zaX3M+*h+?6;s|=$j*d|S-r6PSHnmLqm9oshPNpGzlxV21cFrxcQLidd2%h>n%Mc4{ z|JWBvtbb;(-nhWpPO95hR>(e(H$n%*pCh0k4xE#I%xu=#B)zXSaH+azwCI;0@bY<*-10-Qyaq%5NxSlq_@YJUUwy z*d;qPjW^cuKxdXiOWwP}5FN6SZW~NqB%4?|WifPNZr&XNVkzF0n#Y)pbaEodqNO4F z2Bq#^Gr^Ji3!T9`_!D;a1lW$?!LQ-iYV_A{FQ~^C-Jp`_5uOC)6+mzBr4Nl3fHly% zcXeU3x-?#J`=p$6c~$T~V^!C0Bk_3#WYrtoFCx9_5quCQ*4*?XG0n_9%l_!n`M85^ z7}~Clj~ocls6)V&sWGs?B<`{Ob>vnbXZwdda%ipwbzOJ(V`W>KBF5zdCTE8;mc&xU z^clCzd0(T#8*(})tSYSNP1N{FnNVAU^M1S_pq4VEQ*#5nv`CoYSALMEB zf6egyuRMzK2?r^M0hCD*sU;On6c0^Vh|#tRG*n1p5R)QyVw%Va37nMSV%9&uq^hp| zCHeu}y{m=NsA=naDy;q`fd9t)I$Qd-A1Il$#0KyDc>X)hKJViqNB{HnQyf5D(ZJ*J z{-oGB-%Q|QZ%Pqu34>fCy)Asi}IY7luNR9ebgH4DAjCVvSWfa%PE16 zkC7EIuEK}?IR!jgP%eX%dcxk4%N!zIjW4wYMfIq@s%GetDs^g!^p}DH46EP`Nh_wD z4Rwc4ezh1U$Mc)Fe6ii6eD^*iB2MFp-B-HhGTR0tC2?bq$#^J!v1r+Z0y+& znVub*k=*^0yP(c#mEvX}@Abx%&}!W(1olcWEHAVgskbBrzx(f2v&}4~WkVN?af#yi z4IE-(_^)?4e3(d{F@0<~NV5|e0eaB!?(g%l&Hq$UqzC_Enuest?CL+IrSD`tv8|{C z=79vnL=P6ne+}6X1&cd$kam=jCcv`~^y#R{doTh?6D?H)^M7-P+=D@?H;bt$*V+)K z?+?Ex3Z@8JE3c4eHDYItB^tSot;@2p_fuZ8mW^i^a(L;Xn6K+1GuG0n$v(38;+<78 zC?eMzbQCW2%&;U>j}b>YEH5>RkP44$QlG6k(KwXtq{e#13wnx5Jh=uH?lQIl8%Qxr zq%pDC)mYYKa?N>%aF%YwA}CzV@IOV9&a81d9eiU-6F&lGvz68~%{&4LuwV_5{#km3(tf`fejjs%`{Y`|0p!6|-U z8XQA9Sl=*kM|(2KA!LWOCY3Qq4sZ7r&}__rR*Sj(9W8R1_RxI&4TI+_7RSJF&-363 zJvczH?1(`Jb+RDJL9$Whnj8qJRI+Mz9=Qjvubb=Lz8nWVXG{Te;$%s9-D#$)-!{~w zIM(vkr#OM>2F7W$$Lq%fEYl%e|Tsc>9rB9c8 zQoi4nXomx3&sBI9AwaHkoOp%SMDf2@T#73Bi?|!r!Q?wc(^b_u4ranezYx~=aRV-a zD|_WPK^iJh&=)~h{t<>_$VMXsee;{r-|`#H|1?DZgWvuc*!&C2*(yv(4G5s{8ZRzt zZMC~5gjiU@6fPGMN%X~pL};Q`|IfPfs0m9;RV}xSxjb)*gmvGO1`CQb~W1M1{KwXBLyPz0JQG=JkVX zlPq&zNZS59gf-?*5Z0IFitTX4T$1Oo#_~V%4q2vI?Y@UkSHh}H9xZ1va}^oBrCY{+ z3wwj*FHCsS2}GdSG7W(|k+MWu9h1Qs6cft~RH)n*!;)5HmPX1DqrJ3-Cs%i4q^{$N zC&skM7#8f{&S!9Eq-WqyY$u?uTgrSDt#NU%{3bQZtUSkUof4`Z1P8aLOKJ+^dKh%n zfEfQ zO|P*J>;{=`9@D)qpnt`#NH>}sir*&oFC+W!HR)ecHcPwjF-|)}8+tR#@A+~CLl+Ab zCqp+=Cuc(&VGC1ZYg4CxIXYL>33p^wjIWJSh6R=oq)jD52q3~KVGt=w_z(arS!gx^ zSd|?!rzDu1$>0o0Y0+!iZU=ew^Hr+cq(I(C>9}^sBc++0+S#I;js@_NLD9>MH(tN3 zE5F+J_bYdPfYm5%7-e=lm?!-xlvX~nDkBqu!Zf0ra65JD&@tYDW+c@P3W-YyWe4^6 zhW?FUJ;c{^?b`N)03>!@#JI)r2&!6An27q?*^wyUx3T4uyeIl4*(4CV5OTK#RSnYt zq<+RKCdrYIJtdmNC-NtfH)K&pytbM^Mi6JWjkzJo0TdX>HOjJaIQmQ?Q;l2)8oN@d zVyT=%y@TihQaJX7#B2wY#_ufuaF55-sWO{OwUx$2zRyW$YM(CFBs4Y;YmBk(4u&u- zEf@rIR~4#}IMeq$?T%z3s3RAR7m%M?8No;a=1HXKP?ia#uwy!`4v0GFSjZiMii@ib z#xRmA-v~CSVl8z9cEWVEk;9_BKPS6Y2|bk#PAb|}gPxHs-dt*k`5tU#FZL)FLodY8 zmb!m`DagEJ#q1VKwO~%zmw7;LESf5u!KJNm829pbY_w$P2}16`Bb?0uoL3~V71;_U z`B~wKOB7Bp!Vn!M@o?RHydmah!dHPaT`&idV83kQPxA>E=~YgJC<)rdM1#B$JIgnq z0V{p|Cm3eeMaO58Wrv^9-kAOJ+*HR!;;A9z&>78VsYmF9$U^*ZE=K%d7=MZ~G?~Hz zSHlKWK!Us^%?uE6`E|_XI+nC354jkbUPvedHbh(DkKGkquYf}=-EEB1g>RC{O9ORL371y8V*CR5EW z@lmFq%MWEBdeHR7%(Rpf!Yg52vX%D7#@*^M`fy7Srb z^Ta9wcwf$89uL61@qeg2vc&TAGKSLV>YKI3#5lfs#q5Zm`~Ogef!!CoWWyiA=J;js z%X_n!njeF2MZgaVoMh@S@8%lR)AsYyzmqkj+C8ghxI4G6O7ovK$udULO!2$(|__`2~6JjuoERet}kenJ%I0pU_O@tU*Fsd4gm&hV?p%Y{!;r}{S^Fv z_4EJbVjFv7>+dE9{rBS@8&_vbx9>4!8&g4JV^e2mSwlNR^Z&ujriy)b3jzqfYb35o z!;J+c>%LY+?P!IticwSrP;x2|k>j3Sxg2X%E2%57

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c61a118f7d --- /dev/null +++ b/examples/E2ETestApp/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/E2ETestApp/android/gradlew b/examples/E2ETestApp/android/gradlew new file mode 100755 index 0000000000..739907dfd1 --- /dev/null +++ b/examples/E2ETestApp/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/E2ETestApp/android/gradlew.bat b/examples/E2ETestApp/android/gradlew.bat new file mode 100644 index 0000000000..39baf4d684 --- /dev/null +++ b/examples/E2ETestApp/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/E2ETestApp/android/settings.gradle b/examples/E2ETestApp/android/settings.gradle new file mode 100644 index 0000000000..96b171f560 --- /dev/null +++ b/examples/E2ETestApp/android/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +rootProject.name = 'io.getstream.reactnative.e2etestapp' +include ':app' +includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/examples/E2ETestApp/app.json b/examples/E2ETestApp/app.json new file mode 100644 index 0000000000..73849fcf35 --- /dev/null +++ b/examples/E2ETestApp/app.json @@ -0,0 +1,4 @@ +{ + "name": "E2ETestApp", + "displayName": "E2ETestApp" +} diff --git a/examples/E2ETestApp/babel.config.js b/examples/E2ETestApp/babel.config.js new file mode 100644 index 0000000000..8ba8eb658c --- /dev/null +++ b/examples/E2ETestApp/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], +}; diff --git a/examples/E2ETestApp/e2e/README.md b/examples/E2ETestApp/e2e/README.md new file mode 100644 index 0000000000..7088447cf3 --- /dev/null +++ b/examples/E2ETestApp/e2e/README.md @@ -0,0 +1,119 @@ +# E2E tests (Detox) + +End-to-end tests for the SDK, driving `examples/E2ETestApp` with [Detox](https://wix.github.io/Detox/) against the shared +[`stream-chat-test-mock-server`](https://github.com/GetStream/stream-chat-test-mock-server). The layout mirrors the +native iOS/Android (and Flutter) suites, so tests can be ported almost line by line: + +| Path | What | Native counterpart | +| ------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | +| `mock-server/` | `MockServer` (driver `/start/:test` → per-test server), `DataTypes` | `MockServer.kt`, `StreamMockServer.swift` | +| `robots/BackendRobot.ts` | server-side state via test-control endpoints (`/mock`, `/add_member`, `/jwt/*`, …) | `BackendRobot` | +| `robots/ParticipantRobot.ts` | the other user, `count_dooku` (`/participant/*`) | `ParticipantRobot` | +| `robots/UserRobot.ts` + `UserRobot*Asserts` | UI actions as the app user + assert extensions | `UserRobot` + `UserRobot*Asserts.kt` | +| `pages/` | page objects: `LoginPage`, `ChannelListPage`, `MessageListPage` | `pages/*Page.kt`, `Pages/*Page.swift` | +| `support/` | `setUpStreamTestCase()`, `step()`, uiautomator-like `Element` helpers | `StreamTestCase`, `step {}`, `uiautomator/*` | +| `tests/*.e2e.ts` | the tests | `*Tests.kt`, `*_Tests.swift` | + +Robots are fluent like the native ones; every call is queued and the chain runs when awaited: + +```ts +await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); +await step('WHEN participant sends a message', () => participantRobot.sendMessage('Hi')); +await step('THEN user receives it', () => userRobot.assertMessage('Hi').assertMessageAuthor(false)); +``` + +## The app + +`examples/E2ETestApp` exists only for this suite, like the native SDKs' `StreamChatUITestsApp`. It has the SDK and its +required peer dependencies, and nothing else: no Firebase, maps, video or navigation library. That keeps native builds +short (a clean Release build is ~2 min on an M-series Mac, versus ~15 min for the SampleApp on Android). It has three +screens: a user selector, the channel list, and a channel stacked on top of the list (`App.tsx`), laid out like the +SampleApp's, so the same SDK paths run. + +`setUpStreamTestCase()` starts a mock server per test and relaunches the app with the launch argument +`MOCK_SERVER_URL` (`localhost` on iOS, `10.0.2.2` on the Android emulator), read in `src/config.ts` via +`react-native-launch-arguments`. The app only lists the mock user (`luke_skywalker`), never persists a login and runs +without offline storage, so every test starts clean. + +## Running + +The fastlane lanes live with the repo's other lanes in `examples/SampleApp/fastlane` (run them from +`examples/SampleApp`). The e2e app has no Gemfile of its own; `e2e_pod_install` installs its pods with that bundle. + +```bash +bundle exec fastlane e2e_pod_install # once, and after native dependency changes +# Everything: clones + starts the mock server, builds, tests, stops the mock server +bundle exec fastlane test_e2e platform:ios +bundle exec fastlane test_e2e platform:android # AVD from DETOX_AVD_NAME (default Pixel_API_37) +# Options: configuration:debug, skip_build:true, local_server:, +# mock_server_branch:, test:e2e/tests/MessageListTests.e2e.ts + +# Or by hand (yarn commands in examples/E2ETestApp) +bundle exec fastlane start_mock_server # driver on MOCK_DRIVER_PORT (default 4569) +yarn e2e:build -c ios.sim.release # or android.emu.release +MOCK_DRIVER_PORT=4569 yarn e2e:test -c ios.sim.release +bundle exec fastlane stop_mock_server +``` + +Android builds only the `arm64-v8a` and `x86_64` ABIs (emulators); set `ORG_GRADLE_PROJECT_reactNativeArchitectures` to +narrow it further. + +Release configurations bundle the JS, so no Metro is needed. Debug configurations need `yarn start`. Android builds need +JDK 17 or 21 (`JAVA_HOME`). + +## CI + +`.github/workflows/e2e-test.yml` runs on pull requests and on demand (`workflow_dispatch`, with iOS/Android toggles and +a mock server branch). Like the iOS SDK, a cheap `Check E2E Required` job first runs `fastlane e2e_check_required` +(`is_check_required` over the `e2e` sources in the Fastfile's `impact_analysis`). The simulator/emulator jobs run +only when the PR touches those sources, or when the last change to them hasn't passed yet. `Test E2E Result` is the +job to require in branch protection: it passes when the e2e jobs pass or are skipped as not required. + +iOS runs on `macos-15` (Xcode 26.2, iPhone 17 Pro, `ios.sim.release`). Android runs on `ubuntu-latest` with KVM and an +API 36 x86_64 emulator (`android.att.release`). Both call `fastlane test_e2e skip_build:true`, which on CI adds +`--retries 2 --headless` and keeps failure logs and screenshots in `artifacts/`. Those and the mock server logs are +uploaded when a job fails. + +What CI caches. The workflow doesn't run on `develop`, and GitHub only lets a PR restore caches from its own branch or +the base branch, so each PR fills its own: its first run builds cold, and later pushes to it reuse the caches. + +- **The built app** (`.app`; release + test `.apk`), keyed on the app and SDK sources. A change that only touches + `e2e/` reuses the binary and skips pods, Gradle and Xcode entirely. +- `node_modules` of `yarn workspaces focus e2etestapp`, so only the e2e app's dependency tree is installed. +- iOS: CocoaPods, ccache (the RN ccache wrappers need `CCACHE_BINARY` in the environment, which the workflow sets), the + Detox framework. +- Android: Gradle (`gradle/actions/setup-gradle` with `cache-read-only: false`, `org.gradle.caching`), a single ABI + (`x86_64`). The emulator matches the native Android SDK's e2e jobs (API 36 AOSP image, `pixel` profile, no snapshot); + on API 34 (Google APIs image, then AOSP) it died mid-run with no crash output. +- The mock server's gems, keyed on the mock server revision. + +## Gotchas + +- iOS runs with Detox's idle synchronization off (`detoxEnableSynchronization: 0` in `StreamTestCase.ts`): it can wedge + on RN's UIManager shadow queue, whose pending-work counter underflows to 2^64-1, so the app looks busy until the test + times out. Robots wait explicitly instead, and taps retry while the target is not hittable yet (e.g. under the + keyboard as it animates in). Android keeps Espresso's synchronization. +- Don't pass `permissions` to `device.launchApp` unless a test needs one: on iOS Detox applies each with + `applesimutils --restartSB`, which restarts SpringBoard on every launch. That cost 1.5–3 min per test on CI (hitting the + hook timeout) and ~15s locally. +- CI boots the simulator at the start of the job and pins it with `DETOX_IOS_UDID` (`.detoxrc.js`), so the boot + overlaps with setup instead of delaying the first test by ~7 min. +- A freshly booted CI emulator can show "Quickstep isn't responding" (a launcher ANR). The dialog takes window focus, and + every Espresso action then fails with "Waited for the root of the view hierarchy to have window focus". The + `test_e2e` lane hides error dialogs and waits up to 60s for the emulator to settle, and `StreamTestCase` dismisses + any ANR dialog before each Android launch (`support/AndroidSystemDialogs.ts`). Read the focus from + `dumpsys window`: `dumpsys window windows` no longer prints `mCurrentFocus`. +- `detox test --reuse` does not reinstall the app. After rebuilding, run without it, or you test the old binary. +- Yarn's `enableScripts: false` skips Detox's postinstall; run `yarn detox build-framework-cache` once on a new machine. +- The SDK's shared native sources are synced into `stream-chat-react-native` and gitignored; `yarn e2e:build` and + `e2e_pod_install` run `yarn sync-native` first. +- Don't commit `ios/E2ETestApp.xcodeproj` after `USE_CCACHE=1 pod install`: it writes machine-specific ccache paths into + it. +- Detox replaces Jest's global `expect`. Use it for element expectations and `expect as jestExpect` from + `@jest/globals` for values. +- Fabric mounts some views that carry a `testID` (`message-list-item-*`, `message-text-container`) empty, with their + content as siblings. They are not usable as `withAncestor` scopes or visibility targets; the page objects scope message + elements to `message-content-wrapper` or the message list instead. +- Detox iOS never reports avatars as visible (their remote image renders transparent until loaded), so avatar asserts + check existence. +- A ported test keeps the native expectation. If the SDK disagrees, fix the SDK or skip the test against a ticket. diff --git a/examples/E2ETestApp/e2e/jest.config.js b/examples/E2ETestApp/e2e/jest.config.js new file mode 100644 index 0000000000..5456675188 --- /dev/null +++ b/examples/E2ETestApp/e2e/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('jest').Config} */ +module.exports = { + rootDir: '..', + testMatch: ['/e2e/tests/**/*.e2e.ts'], + testTimeout: 180000, + maxWorkers: 1, + globalSetup: 'detox/runners/jest/globalSetup', + globalTeardown: 'detox/runners/jest/globalTeardown', + reporters: ['detox/runners/jest/reporter'], + testEnvironment: 'detox/runners/jest/testEnvironment', + verbose: true, + transform: { + '\\.ts$': [ + 'babel-jest', + { babelrc: false, configFile: false, presets: ['module:@react-native/babel-preset'] }, + ], + }, +}; diff --git a/examples/E2ETestApp/e2e/mock-server/DataTypes.ts b/examples/E2ETestApp/e2e/mock-server/DataTypes.ts new file mode 100644 index 0000000000..a13549c994 --- /dev/null +++ b/examples/E2ETestApp/e2e/mock-server/DataTypes.ts @@ -0,0 +1,25 @@ +export const forbiddenWord = 'wth'; + +export enum AttachmentType { + IMAGE = 'image', + VIDEO = 'video', + FILE = 'file', +} + +export enum ReactionType { + LOVE = 'love', + LOL = 'haha', + WOW = 'wow', + SAD = 'sad', + LIKE = 'like', +} + +export enum MessageDeliveryStatus { + READ = 'read', + DELIVERED = 'delivered', + PENDING = 'pending', + SENT = 'sent', + FAILED = 'failed', + /** No delivery status icon is expected. */ + NIL = 'nil', +} diff --git a/examples/E2ETestApp/e2e/mock-server/MockServer.ts b/examples/E2ETestApp/e2e/mock-server/MockServer.ts new file mode 100644 index 0000000000..1d273d12cf --- /dev/null +++ b/examples/E2ETestApp/e2e/mock-server/MockServer.ts @@ -0,0 +1,84 @@ +declare const process: { env: Record }; + +/** + * Client for `stream-chat-test-mock-server`. The driver (`driver.rb`) spawns one mock server per + * test on its own port; `start` asks it for one and `stop` shuts it down. + * + * Unlike the native suites, the test code runs on the host, so robots always talk to `localhost` + * while the app gets `appUrl`, which is the host as the device sees it. + */ +export class MockServer { + static readonly driverPort = process.env.MOCK_DRIVER_PORT ?? '4569'; + private static readonly driverUrl = `http://localhost:${MockServer.driverPort}`; + + private port?: string; + + /** The mock server as the test runner (host) sees it. */ + get url(): string { + if (!this.port) { + throw new Error('MockServer is not started'); + } + return `http://localhost:${this.port}`; + } + + /** The mock server as the app under test sees it: the Android emulator reaches the host via 10.0.2.2. */ + get appUrl(): string { + const host = device.getPlatform() === 'android' ? '10.0.2.2' : 'localhost'; + return this.url.replace('localhost', host); + } + + /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */ + async start(testName: string): Promise { + const response = await this.execute( + `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}`, + ); + this.port = (await response.text()).trim(); + await this.waitUntilReady(); + } + + async stop(): Promise { + if (!this.port) { + return; + } + await this.getRequest('stop').catch(() => undefined); + this.port = undefined; + } + + async postRequest(endpoint: string, body = ''): Promise { + const response = await this.execute(`${this.url}/${endpoint}`, { + body, + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', + }); + return response.text(); + } + + async getRequest(endpoint: string): Promise { + const response = await this.execute(`${this.url}/${endpoint}`); + return response.text(); + } + + private async waitUntilReady(timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(`${this.url}/ping`); + if (response.ok) { + return; + } + } catch { + // The server process is still booting. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`MockServer did not start within ${timeoutMs}ms on ${this.url}`); + } + + private async execute(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + throw new Error(`${init?.method ?? 'GET'} ${url} failed: ${response.status}`); + } + return response; + } +} diff --git a/examples/E2ETestApp/e2e/pages/ChannelListPage.ts b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts new file mode 100644 index 0000000000..4cc136b9b0 --- /dev/null +++ b/examples/E2ETestApp/e2e/pages/ChannelListPage.ts @@ -0,0 +1,32 @@ +export const ChannelListPage = { + ChannelList: { + get channelList() { + return by.id('channel-list-view'); + }, + get channels() { + return by.id('channel-preview-button'); + }, + + Channel: { + get avatar() { + return by.id('avatar-image').withAncestor(by.id('channel-preview-button')); + }, + get name() { + return by.id('channel-preview-title'); + }, + nameWithText(text: string) { + return by.id('channel-preview-title').and(by.text(text)); + }, + get messagePreview() { + return by.id('channel-preview-message'); + }, + /** "You:" or ":" in front of the preview text, rendered as its own text. */ + get messageSender() { + return by.id('channel-preview-sender'); + }, + get unreadCount() { + return by.id('unread-count').withAncestor(by.id('channel-preview-button')); + }, + }, + }, +}; diff --git a/examples/E2ETestApp/e2e/pages/LoginPage.ts b/examples/E2ETestApp/e2e/pages/LoginPage.ts new file mode 100644 index 0000000000..069eab4a75 --- /dev/null +++ b/examples/E2ETestApp/e2e/pages/LoginPage.ts @@ -0,0 +1,11 @@ +/** The mock server's app user (`appUser` in `src/config.ts`), the only one listed in e2e mode. */ +export const appUserId = 'luke_skywalker'; + +export const LoginPage = { + get userSelector() { + return by.id('user-selector-screen'); + }, + get loginButton() { + return by.id(`user-selector-button-${appUserId}`); + }, +}; diff --git a/examples/E2ETestApp/e2e/pages/MessageListPage.ts b/examples/E2ETestApp/e2e/pages/MessageListPage.ts new file mode 100644 index 0000000000..4559f48590 --- /dev/null +++ b/examples/E2ETestApp/e2e/pages/MessageListPage.ts @@ -0,0 +1,87 @@ +const messageCell = () => by.id(/^message-list-item-/); +// Fabric mounts `message-list-item-*` empty with its content beside it, so the list, not the cell, +// is the ancestor that scopes message elements (the channel list stays mounted underneath). +const inMessageList = (matcher: Detox.NativeMatcher) => + matcher.withAncestor(by.id(/^message-(flat|flash)-list$/)); + +export const MessageListPage = { + Header: { + get backButton() { + return by.id('back-button'); + }, + }, + + Composer: { + get inputField() { + return by.id('auto-complete-text-input'); + }, + get sendButton() { + return by.id('send-button'); + }, + get attachmentsButton() { + return by.id('attach-button'); + }, + get cooldownIndicator() { + return by.id('cooldown-timer'); + }, + }, + + MessageList: { + get messageList() { + return by.id(/^message-(flat|flash)-list$/); + }, + get messages() { + return messageCell(); + }, + get dateSeparator() { + return by.id('date-separator'); + }, + get typingIndicator() { + return by.id('typing-indicator'); + }, + get scrollToBottomButton() { + return by.id('scroll-to-bottom-button'); + }, + + Message: { + /** Wraps the author's avatar; only rendered for other users' messages. */ + get avatar() { + return by.id('message-author'); + }, + text(text: string) { + // Not under `message-text-container`: Fabric mounts that view empty, with the text beside it. + return by.text(text).withAncestor(by.id('message-content-wrapper')); + }, + get timestamp() { + return by.id('message-status-time'); + }, + get deliveryStatusIsRead() { + return inMessageList(by.id('message-status-read')); + }, + get deliveryStatusIsDelivered() { + return inMessageList(by.id('message-status-delivered')); + }, + get deliveryStatusIsPending() { + return inMessageList(by.id('message-status-sending')); + }, + get deliveryStatusIsSent() { + return inMessageList(by.id('message-status-sent')); + }, + get deliveryStatusIsFailed() { + return inMessageList(by.id('message-error')); + }, + get deletedMessage() { + return by.id('message-deleted'); + }, + get threadRepliesLabel() { + return by.id('message-replies'); + }, + + Reactions: { + get reactions() { + return by.id('reaction-list-item'); + }, + }, + }, + }, +}; diff --git a/examples/E2ETestApp/e2e/robots/BackendRobot.ts b/examples/E2ETestApp/e2e/robots/BackendRobot.ts new file mode 100644 index 0000000000..c62e3eea5c --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/BackendRobot.ts @@ -0,0 +1,129 @@ +import { Robot } from './Robot'; + +import type { MockServer } from '../mock-server/MockServer'; + +/** Drives server-side state through the mock server's test-control endpoints. */ +export class BackendRobot extends Robot { + constructor(private readonly mockServer: MockServer) { + super(); + } + + generateChannels({ + channelsCount, + messagesCount = 0, + repliesCount = 0, + messagesText, + repliesText, + channelNames = [], + withDirectMessageChannel = false, + }: { + channelsCount: number; + messagesCount?: number; + repliesCount?: number; + messagesText?: string; + repliesText?: string; + channelNames?: string[]; + withDirectMessageChannel?: boolean; + }): this { + const params = new URLSearchParams(); + if (messagesText !== undefined) params.set('messages_text', messagesText); + if (repliesText !== undefined) params.set('replies_text', repliesText); + if (channelNames.length > 0) params.set('channel_names', channelNames.join(',')); + if (withDirectMessageChannel) params.set('dm', 'true'); + params.set('channels', `${channelsCount}`); + params.set('messages', `${messagesCount}`); + params.set('replies', `${repliesCount}`); + return this.post(`mock?${params}`); + } + + failNewMessages(): this { + return this.post('fail_messages'); + } + + freezeNewMessages(): this { + return this.post('freeze_messages'); + } + + setCooldown(enabled: boolean, duration: number): this { + return this.post(`config/cooldown?enabled=${enabled}&duration=${duration}`); + } + + /** + * Truncates the currently open channel on the server side. The app receives `channel.truncated`. + * + * @param withMessage also delivers a "Channel truncated" system message. + */ + truncateChannel(withMessage: boolean): this { + return this.post(`truncate_channel?with_message=${withMessage}`); + } + + /** Enables or disables read events on every channel. Call before the channel is opened. */ + setReadEvents(enabled: boolean): this { + return this.post(`config/read_events?value=${enabled}`); + } + + /** Adds a member to the currently open channel. The app receives `member.added` + `channel.updated`. */ + addMember(userId: string): this { + return this.post(`add_member?user_id=${encodeURIComponent(userId)}`); + } + + /** Removes a member from the currently open channel. The app receives `member.removed` + `channel.updated`. */ + removeMember(userId: string): this { + return this.post(`remove_member?user_id=${encodeURIComponent(userId)}`); + } + + /** + * Creates a reminder for the last message of the currently open channel. + * + * @param remindAtSeconds how far in the future it is due; omit for a "saved for later" reminder. + */ + createReminder(remindAtSeconds?: number): this { + return this.post( + remindAtSeconds === undefined + ? 'create_reminder' + : `create_reminder?remind_at=${remindAtSeconds}`, + ); + } + + revokeToken(duration = 5): this { + return this.post(`jwt/revoke_token?duration=${duration}`); + } + + invalidateToken(duration = 5): this { + return this.post(`jwt/invalidate_token?duration=${duration}`); + } + + invalidateTokenDate(duration = 5): this { + return this.post(`jwt/invalidate_token_date?duration=${duration}`); + } + + invalidateTokenSignature(duration = 5): this { + return this.post(`jwt/invalidate_token_signature?duration=${duration}`); + } + + breakTokenGeneration(duration = 5): this { + return this.post(`jwt/break_token_generation?duration=${duration}`); + } + + /** + * Waits until the app closes its WebSocket. The SDK keeps the socket open for a while after the + * app goes to background, so call this before server-side events that must not arrive live. + */ + waitForWebSocketDisconnection(timeoutMs = 10000): this { + return this.perform(async () => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const status = JSON.parse(await this.mockServer.getRequest('ws/status')); + if (!status.connected) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`WebSocket was not disconnected within ${timeoutMs}ms`); + }); + } + + private post(endpoint: string): this { + return this.perform(() => this.mockServer.postRequest(endpoint)); + } +} diff --git a/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts new file mode 100644 index 0000000000..0615c8a469 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/ParticipantRobot.ts @@ -0,0 +1,161 @@ +import { Robot } from './Robot'; + +import type { AttachmentType, ReactionType } from '../mock-server/DataTypes'; +import type { MockServer } from '../mock-server/MockServer'; + +const MULTIPLE_MESSAGES_INTERVAL_MS = 300; + +const quoteParam = (last: boolean) => (last ? 'quote_last=true' : 'quote_first=true'); + +/** Acts as the other channel member (`count_dooku`) through the mock server's participant endpoints. */ +export class ParticipantRobot extends Robot { + static readonly userName = 'Count Dooku'; + static readonly userId = 'count_dooku'; + + constructor(private readonly mockServer: MockServer) { + super(); + } + + startTyping(): this { + return this.post('participant/typing/start'); + } + + startTypingInThread(): this { + return this.post('participant/typing/start?thread=true'); + } + + stopTyping(): this { + return this.post('participant/typing/stop'); + } + + stopTypingInThread(): this { + return this.post('participant/typing/stop?thread=true'); + } + + readMessage(): this { + return this.post('participant/read'); + } + + /** Marks the newest message as delivered to the participant (`message.delivered`). */ + markMessagesDelivered(): this { + return this.post('participant/delivered'); + } + + sendMessage(text: string, delay = 0): this { + return this.post( + delay > 0 ? `participant/message?delay=${delay}` : 'participant/message', + text, + ); + } + + /** Sends `count` messages named `${text}-1` through `${text}-${count}`, 300ms apart. */ + sendMultipleMessages(text: string, count: number): this { + for (let index = 1; index <= count; index++) { + this.sendMessage(`${text}-${index}`).sleep(MULTIPLE_MESSAGES_INTERVAL_MS); + } + return this; + } + + sendMessageInThread(text: string, alsoSendInChannel = false): this { + return this.post( + `participant/message?thread=true&thread_and_channel=${alsoSendInChannel}`, + text, + ); + } + + editMessage(text: string): this { + return this.post('participant/message?action=edit', text); + } + + deleteMessage(hard = false): this { + return this.post(`participant/message?action=delete&hard_delete=${hard}`); + } + + quoteMessage(text: string, last = true): this { + return this.post(`participant/message?${quoteParam(last)}`, text); + } + + quoteMessageInThread(text: string, alsoSendInChannel = false, last = true): this { + return this.post( + `participant/message?${quoteParam(last)}&thread=true&thread_and_channel=${alsoSendInChannel}`, + text, + ); + } + + uploadGiphy(): this { + return this.post('participant/message?giphy=true'); + } + + uploadGiphyInThread(): this { + return this.post('participant/message?giphy=true&thread=true'); + } + + quoteMessageWithGiphy(last = true): this { + return this.post(`participant/message?giphy=true&${quoteParam(last)}`); + } + + quoteMessageWithGiphyInThread(alsoSendInChannel = false, last = true): this { + return this.post( + `participant/message?giphy=true&${quoteParam(last)}&thread=true&thread_and_channel=${alsoSendInChannel}`, + ); + } + + pinMessage(): this { + return this.post('participant/message?action=pin'); + } + + unpinMessage(): this { + return this.post('participant/message?action=unpin'); + } + + uploadAttachment(type: AttachmentType, count = 1): this { + return this.post(`participant/message?${type}=${count}`); + } + + quoteMessageWithAttachment(type: AttachmentType, count = 1, last = true): this { + return this.post(`participant/message?${quoteParam(last)}&${type}=${count}`); + } + + uploadAttachmentInThread(type: AttachmentType, count = 1, alsoSendInChannel = false): this { + return this.post( + `participant/message?${type}=${count}&thread=true&thread_and_channel=${alsoSendInChannel}`, + ); + } + + quoteMessageWithAttachmentInThread( + type: AttachmentType, + count = 1, + alsoSendInChannel = false, + last = true, + ): this { + return this.post( + `participant/message?${quoteParam(last)}&${type}=${count}&thread=true&thread_and_channel=${alsoSendInChannel}`, + ); + } + + /** Votes for `option` in the poll of the newest poll message. */ + castPollVote(option: string): this { + return this.post(`participant/poll_vote?option=${encodeURIComponent(option)}`); + } + + /** Adds an answer (comment) to the poll of the newest poll message. */ + addPollAnswer(answer: string): this { + return this.post(`participant/poll_vote?answer=${encodeURIComponent(answer)}`); + } + + addReaction(type: ReactionType, delay = 0): this { + return this.post( + delay > 0 + ? `participant/reaction?type=${type}&delay=${delay}` + : `participant/reaction?type=${type}`, + ); + } + + deleteReaction(type: ReactionType): this { + return this.post(`participant/reaction?type=${type}&delete=true`); + } + + private post(endpoint: string, body?: string): this { + return this.perform(() => this.mockServer.postRequest(endpoint, body)); + } +} diff --git a/examples/E2ETestApp/e2e/robots/Robot.ts b/examples/E2ETestApp/e2e/robots/Robot.ts new file mode 100644 index 0000000000..44dbaaecb7 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/Robot.ts @@ -0,0 +1,33 @@ +/** + * Base for the fluent robots. Native robots chain synchronous calls + * (`userRobot.login().openChannel()`); Detox is async, so each call queues its action and + * returns `this`, and awaiting the robot runs the queue: + * + * await userRobot.login().openChannel().assertMessage('Hi'); + * + * A failed action rejects the awaited chain and skips the rest of it. + */ +export abstract class Robot implements PromiseLike { + private queue: Promise = Promise.resolve(); + + /** Queues `action` after the previous ones. Used by the robots and their assert extensions. */ + perform(action: () => Promise): this { + this.queue = this.queue.then(async () => { + await action(); + }); + return this; + } + + sleep(ms: number): this { + return this.perform(() => new Promise((resolve) => setTimeout(resolve, ms))); + } + + then( + onfulfilled?: ((value: void) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + const queue = this.queue; + this.queue = Promise.resolve(); + return queue.then(onfulfilled, onrejected); + } +} diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts new file mode 100644 index 0000000000..f8e99e6bee --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -0,0 +1,135 @@ +import { Robot } from './Robot'; + +import { ChannelListPage } from '../pages/ChannelListPage'; +import { LoginPage } from '../pages/LoginPage'; +import { MessageListPage } from '../pages/MessageListPage'; +import { + waitToAppear, + waitToAppearAndClick, + waitToAppearAndLongPress, + waitToDisappear, +} from '../support/Element'; + +const { ChannelList } = ChannelListPage; +const { Composer, Header, MessageList } = MessageListPage; + +/** Drives the app as the logged-in user. Asserts live in the `UserRobot*Asserts` extensions. */ +export class UserRobot extends Robot { + login(): this { + return this.perform(() => waitToAppearAndClick(LoginPage.loginButton)); + } + + waitForChannelListToLoad(): this { + return this.perform(() => waitToAppear(ChannelList.channels, { index: 0 })); + } + + waitForMessageListToLoad(): this { + return this.perform(() => waitToAppear(Composer.inputField)); + } + + openChannel(channelCellIndex = 0): this { + return this.perform(() => + waitToAppearAndClick(ChannelList.channels, { index: channelCellIndex }), + ).waitForMessageListToLoad(); + } + + openChannelWithName(channelName: string): this { + return this.perform(() => + waitToAppearAndClick(ChannelList.Channel.nameWithText(channelName)), + ).waitForMessageListToLoad(); + } + + openContextMenu(messageText: string): this { + return this.perform(() => waitToAppearAndLongPress(MessageList.Message.text(messageText))); + } + + /** + * iOS types key by key, like XCUITest. Android sets the text, like the native suite's + * `typeText` (UiAutomator `setText`): typing through the soft keyboard leaves the text in the + * IME's composing state, which the composer doesn't pick up, so the send button never shows. + */ + typeText(text: string): this { + return this.perform(async () => { + const inputField = await waitToAppear(Composer.inputField); + if (device.getPlatform() === 'android') { + await inputField.replaceText(text); + } else { + await inputField.typeText(text); + } + }); + } + + clearComposer(): this { + return this.perform(async () => { + const inputField = await waitToAppear(Composer.inputField); + await inputField.clearText(); + }); + } + + tapOnSendButton(): this { + return this.perform(() => waitToAppearAndClick(Composer.sendButton)); + } + + sendMessage(text: string): this { + return this.typeText(text).tapOnSendButton(); + } + + tapOnBackButton(): this { + return this.perform(() => waitToAppearAndClick(Header.backButton)); + } + + /** Android hardware back; iOS has none, so it taps the header's back button there. */ + pressBack(): this { + return this.perform(async () => { + if (device.getPlatform() === 'android') { + await device.pressBack(); + } else { + await waitToAppearAndClick(Header.backButton); + } + }); + } + + tapOnScrollToBottomButton(): this { + return this.perform(() => waitToAppearAndClick(MessageList.scrollToBottomButton)); + } + + moveToChannelListFromMessageList(): this { + return this.tapOnBackButton() + .perform(() => waitToDisappear(Composer.inputField)) + .waitForChannelListToLoad(); + } + + scrollChannelListDown(times = 3): this { + return this.swipe(ChannelList.channelList, 'up', times); + } + + scrollChannelListUp(times = 3): this { + return this.swipe(ChannelList.channelList, 'down', times); + } + + scrollMessageListDown(times = 3): this { + // The message list is inverted: newer messages are at the bottom, reached by swiping down. + return this.swipe(MessageList.messageList, 'down', times); + } + + scrollMessageListUp(times = 3): this { + return this.swipe(MessageList.messageList, 'up', times); + } + + backgroundApp(): this { + return this.perform(() => device.sendToHome()); + } + + foregroundApp(): this { + return this.perform(() => device.launchApp({ newInstance: false })); + } + + private swipe(list: Detox.NativeMatcher, direction: Detox.Direction, times: number): this { + return this.perform(async () => { + const target = await waitToAppear(list); + for (let i = 0; i < times; i++) { + await target.swipe(direction, 'fast', 0.6); + } + }); + } +} diff --git a/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts new file mode 100644 index 0000000000..068ed8c6e6 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/UserRobotChannelListAsserts.ts @@ -0,0 +1,72 @@ +import { expect as jestExpect } from '@jest/globals'; + +import { ParticipantRobot } from './ParticipantRobot'; +import { UserRobot } from './UserRobot'; + +import { ChannelListPage } from '../pages/ChannelListPage'; +import { waitForCount, waitForExistence, waitForText, waitToDisappear } from '../support/Element'; + +const { ChannelList } = ChannelListPage; +const { Channel } = ChannelList; + +declare module './UserRobot' { + interface UserRobot { + assertChannelAvatar(): this; + assertChannelCount(count: number): this; + assertChannelListIsEmpty(): this; + assertChannelName(name: string, channelCellIndex?: number): this; + assertChannelUnreadCount(count: number): this; + /** + * @param fromCurrentUser `true` expects a "You: " prefix, `false` the participant's name, + * `undefined` only the text. + */ + assertMessageInChannelPreview(text: string, fromCurrentUser?: boolean): this; + } +} + +UserRobot.prototype.assertChannelAvatar = function () { + return this.perform(async () => { + jestExpect(await waitForExistence(Channel.avatar, { index: 0 })).toBe(true); + }); +}; + +UserRobot.prototype.assertChannelCount = function (count) { + return this.perform(async () => { + jestExpect(await waitForCount(ChannelList.channels, count)).toBe(count); + }); +}; + +UserRobot.prototype.assertChannelListIsEmpty = function () { + return this.perform(async () => { + await waitToDisappear(ChannelList.channels); + jestExpect(await waitForCount(ChannelList.channels, 0)).toBe(0); + }); +}; + +UserRobot.prototype.assertChannelName = function (name, channelCellIndex = 0) { + return this.perform(async () => { + jestExpect(await waitForText(Channel.name, name, { index: channelCellIndex })).toBe(name); + }); +}; + +UserRobot.prototype.assertChannelUnreadCount = function (count) { + return this.perform(async () => { + jestExpect(await waitForText(Channel.unreadCount, `${count}`)).toBe(`${count}`); + }); +}; + +UserRobot.prototype.assertMessageInChannelPreview = function (text, fromCurrentUser) { + const expectedSender = + fromCurrentUser === true + ? 'You:' + : fromCurrentUser === false + ? `${ParticipantRobot.userName}:` + : undefined; + return this.perform(async () => { + const preview = await waitForText(Channel.messagePreview, text, { mustBeEqual: false }); + jestExpect(preview.trimEnd()).toContain(text); + if (expectedSender !== undefined) { + jestExpect(await waitForText(Channel.messageSender, expectedSender)).toBe(expectedSender); + } + }); +}; diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts new file mode 100644 index 0000000000..7596ce79c8 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts @@ -0,0 +1,105 @@ +import { expect as jestExpect } from '@jest/globals'; + +import { UserRobot } from './UserRobot'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { MessageListPage } from '../pages/MessageListPage'; +import { + defaultTimeout, + isDisplayed, + waitForCount, + waitForExistence, + waitToAppear, + waitToDisappear, +} from '../support/Element'; + +const { MessageList } = MessageListPage; +const { Message } = MessageList; + +declare module './UserRobot' { + interface UserRobot { + assertMessage(text: string, isDisplayed?: boolean): this; + assertMessageAuthor(isCurrentUser: boolean): this; + assertMessageDeliveryStatus(status: MessageDeliveryStatus, count?: number): this; + assertMessageFailedIcon(isDisplayed: boolean): this; + assertTypingIndicator(isDisplayed: boolean): this; + assertScrollToBottomButton(isDisplayed: boolean): this; + } +} + +export const assertVisibility = async ( + selector: Detox.NativeMatcher, + shouldBeDisplayed: boolean, + timeout = defaultTimeout, +) => { + if (shouldBeDisplayed) { + await waitToAppear(selector, { index: 0, timeout }); + } else { + await waitToDisappear(selector, { index: 0, timeout }); + jestExpect(await isDisplayed(selector, 0)).toBe(false); + } +}; + +const assertDeliveryStatusCount = async (selector: Detox.NativeMatcher, count?: number) => { + await assertVisibility(selector, true, 30000); + if (count !== undefined) { + jestExpect(await waitForCount(selector, count)).toBe(count); + } +}; + +UserRobot.prototype.assertMessage = function (text, shouldBeDisplayed = true) { + return this.perform(async () => { + await assertVisibility(Message.text(text), shouldBeDisplayed); + if (shouldBeDisplayed) { + jestExpect(await isDisplayed(Message.timestamp, 0)).toBe(true); + } + }); +}; + +/** The current user's messages carry no avatar; other users' do. */ +UserRobot.prototype.assertMessageAuthor = function (isCurrentUser) { + return this.perform(async () => { + const exists = await waitForExistence(Message.avatar, { + index: 0, + shouldExist: !isCurrentUser, + }); + jestExpect(exists).toBe(!isCurrentUser); + }); +}; + +UserRobot.prototype.assertMessageDeliveryStatus = function (status, count) { + return this.perform(async () => { + switch (status) { + case MessageDeliveryStatus.READ: + return assertDeliveryStatusCount(Message.deliveryStatusIsRead, count); + case MessageDeliveryStatus.DELIVERED: + return assertDeliveryStatusCount(Message.deliveryStatusIsDelivered, count); + case MessageDeliveryStatus.PENDING: + return assertDeliveryStatusCount(Message.deliveryStatusIsPending, count); + case MessageDeliveryStatus.SENT: + return assertDeliveryStatusCount(Message.deliveryStatusIsSent, count); + case MessageDeliveryStatus.FAILED: + return assertDeliveryStatusCount(Message.deliveryStatusIsFailed, count); + case MessageDeliveryStatus.NIL: + await assertVisibility(Message.deliveryStatusIsRead, false); + await assertVisibility(Message.deliveryStatusIsDelivered, false); + await assertVisibility(Message.deliveryStatusIsPending, false); + await assertVisibility(Message.deliveryStatusIsSent, false); + return; + default: + throw new Error(`Unsupported delivery status: ${status}`); + } + }); +}; + +UserRobot.prototype.assertMessageFailedIcon = function (shouldBeDisplayed) { + return this.perform(() => assertVisibility(Message.deliveryStatusIsFailed, shouldBeDisplayed)); +}; + +UserRobot.prototype.assertTypingIndicator = function (shouldBeDisplayed) { + return this.perform(() => assertVisibility(MessageList.typingIndicator, shouldBeDisplayed)); +}; + +UserRobot.prototype.assertScrollToBottomButton = function (shouldBeDisplayed) { + return this.perform(() => assertVisibility(MessageList.scrollToBottomButton, shouldBeDisplayed)); +}; diff --git a/examples/E2ETestApp/e2e/robots/index.ts b/examples/E2ETestApp/e2e/robots/index.ts new file mode 100644 index 0000000000..bc92c7f124 --- /dev/null +++ b/examples/E2ETestApp/e2e/robots/index.ts @@ -0,0 +1,7 @@ +// The assert files extend UserRobot's prototype, so they must be loaded with it. +import './UserRobotMessageListAsserts'; +import './UserRobotChannelListAsserts'; + +export { BackendRobot } from './BackendRobot'; +export { ParticipantRobot } from './ParticipantRobot'; +export { UserRobot } from './UserRobot'; diff --git a/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts new file mode 100644 index 0000000000..a7d2c61dcd --- /dev/null +++ b/examples/E2ETestApp/e2e/support/AndroidSystemDialogs.ts @@ -0,0 +1,43 @@ +// Node built-ins, typed locally: the e2e project's types are limited to `detox/globals`. +declare const process: { env: Record }; +declare const require: (module: 'child_process') => { + execFileSync: (file: string, args: string[], options: { encoding: 'utf8' }) => string; +}; + +const { execFileSync } = require('child_process'); + +const sdkRoot = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT; +const adbPath = sdkRoot ? `${sdkRoot}/platform-tools/adb` : 'adb'; + +const adb = (...args: string[]) => { + const serial = process.env.DETOX_ADB_NAME; + const deviceArgs = serial && !serial.includes('*') ? ['-s', serial] : []; + return execFileSync(adbPath, [...deviceArgs, ...args], { encoding: 'utf8' }); +}; + +/** + * Dismisses an " isn't responding" dialog, if one is showing. On a slow CI emulator the + * launcher (Quickstep) can hit an ANR; its dialog takes window focus, and Espresso then fails every + * test waiting for the app's window to get it. Same handling as the `wait_android_emu_idle` fastlane + * action: Enter picks "Close app", except for System UI, where it picks "Wait". + */ +export const dismissAnrDialog = () => { + try { + const focus = adb('shell', 'dumpsys', 'window') + .split('\n') + .find( + (line) => line.includes('mCurrentFocus') && line.includes('Application Not Responding'), + ); + if (!focus) { + return; + } + console.warn(`Dismissing an ANR dialog: ${focus.trim()}`); + if (focus.includes('com.android.systemui')) { + adb('shell', 'input', 'keyevent', 'KEYCODE_DPAD_DOWN'); + } + adb('shell', 'input', 'keyevent', 'KEYCODE_ENTER'); + } catch (error) { + // Best effort: a failed check must not fail the test on its own. + console.warn(`Could not check for ANR dialogs: ${String(error)}`); + } +}; diff --git a/examples/E2ETestApp/e2e/support/Element.ts b/examples/E2ETestApp/e2e/support/Element.ts new file mode 100644 index 0000000000..edefaf1414 --- /dev/null +++ b/examples/E2ETestApp/e2e/support/Element.ts @@ -0,0 +1,164 @@ +/** + * Element helpers mirroring the Android suite's `uiautomator` extensions (`Element.kt`, `Wait.kt`), + * so robot code reads the same: `selector.waitToAppearAndClick()` becomes + * `await waitToAppearAndClick(selector)`. + */ + +export const defaultTimeout = 15000; + +type Selector = Detox.NativeMatcher; + +type WaitOptions = { + /** Picks one match when the selector matches several elements. */ + index?: number; + timeout?: number; +}; + +const findElement = (selector: Selector, index?: number) => + index === undefined ? element(selector) : element(selector).atIndex(index); + +export const waitToAppear = async ( + selector: Selector, + { index, timeout = defaultTimeout }: WaitOptions = {}, +) => { + const target = findElement(selector, index); + await waitFor(target).toBeVisible().withTimeout(timeout); + return target; +}; + +/** + * Retries `action` while Detox reports the element as not hittable (e.g. covered by the keyboard + * while it animates in). iOS runs without Detox's idle synchronization, which used to absorb that. + */ +const retryWhileNotHittable = async (action: () => Promise, timeout = defaultTimeout) => { + const deadline = Date.now() + timeout; + for (;;) { + try { + return await action(); + } catch (error) { + if (Date.now() > deadline || !/not hittable/i.test(String(error))) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + } +}; + +export const waitToAppearAndClick = async (selector: Selector, options?: WaitOptions) => { + const target = await waitToAppear(selector, options); + await retryWhileNotHittable(() => target.tap(), options?.timeout); + return target; +}; + +export const waitToAppearAndLongPress = async (selector: Selector, options?: WaitOptions) => { + const target = await waitToAppear(selector, options); + await retryWhileNotHittable(() => target.longPress(), options?.timeout); + return target; +}; + +/** Waits until nothing matching `selector` is visible. Resolves (does not throw) on timeout. */ +export const waitToDisappear = async ( + selector: Selector, + { index, timeout = defaultTimeout }: WaitOptions = {}, +) => { + try { + await waitFor(findElement(selector, index)).not.toBeVisible().withTimeout(timeout); + } catch { + // The caller asserts on `isDisplayed` afterwards, like `waitToDisappear().isDisplayed()` on Android. + } +}; + +/** + * Waits until `selector` exists (or, with `shouldExist: false`, until it no longer does), and + * returns whether it exists then. For elements Detox iOS never reports as visible, such as avatars + * whose remote image has not loaded: its visibility check needs rendered pixels. + */ +export const waitForExistence = async ( + selector: Selector, + { + index, + shouldExist = true, + timeout = defaultTimeout, + }: WaitOptions & { shouldExist?: boolean } = {}, +) => { + const target = findElement(selector, index); + try { + if (shouldExist) { + await waitFor(target).toExist().withTimeout(timeout); + } else { + await waitFor(target).not.toExist().withTimeout(timeout); + } + return shouldExist; + } catch { + return !shouldExist; + } +}; + +export const isDisplayed = async (selector: Selector, index?: number) => { + try { + await expect(findElement(selector, index)).toBeVisible(); + return true; + } catch { + return false; + } +}; + +/** Polls until `predicate` holds, then returns `true`; returns `false` once `timeout` elapses. */ +export const waitUntil = async ( + predicate: () => Promise, + timeout = defaultTimeout, + interval = 500, +) => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + return predicate(); +}; + +/** Number of elements currently matching `selector` (Detox has no count query of its own). */ +export const count = async (selector: Selector) => { + try { + const attributes = await element(selector).getAttributes(); + return 'elements' in attributes ? attributes.elements.length : 1; + } catch { + return 0; + } +}; + +/** Waits until `selector` matches exactly `expected` elements and returns the last count seen. */ +export const waitForCount = async (selector: Selector, expected: number, timeout?: number) => { + let actual = 0; + await waitUntil(async () => { + actual = await count(selector); + return actual === expected; + }, timeout); + return actual; +}; + +/** Waits until `selector` (or its `index`-th match) shows `text` and returns the text it shows. */ +export const waitForText = async ( + selector: Selector, + text: string, + { index = 0, mustBeEqual = true, timeout }: WaitOptions & { mustBeEqual?: boolean } = {}, +) => { + let actual = ''; + await waitUntil(async () => { + actual = await textOf(selector, index); + return mustBeEqual ? actual === text : actual.includes(text); + }, timeout); + return actual; +}; + +export const textOf = async (selector: Selector, index = 0) => { + try { + const attributes = await element(selector).getAttributes(); + const match = 'elements' in attributes ? attributes.elements[index] : attributes; + return match?.text ?? match?.label ?? ''; + } catch { + return ''; + } +}; diff --git a/examples/E2ETestApp/e2e/support/StreamTestCase.ts b/examples/E2ETestApp/e2e/support/StreamTestCase.ts new file mode 100644 index 0000000000..4a4ddb1b9a --- /dev/null +++ b/examples/E2ETestApp/e2e/support/StreamTestCase.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, expect as jestExpect } from '@jest/globals'; + +import { dismissAnrDialog } from './AndroidSystemDialogs'; + +import { MockServer } from '../mock-server/MockServer'; +import { BackendRobot, ParticipantRobot, UserRobot } from '../robots'; + +/** + * Per-test fixture, the counterpart of `StreamTestCase` on iOS and Android: before each test it + * starts a fresh mock server and relaunches the app pointed at it; after each test it stops the + * server. + * + * describe('MessageListTests', () => { + * const { userRobot, participantRobot, backendRobot } = setUpStreamTestCase(); + * it('test_...', async () => { ... }); + * }); + */ +export class StreamTestCase { + readonly mockServer = new MockServer(); + readonly userRobot = new UserRobot(); + readonly backendRobot = new BackendRobot(this.mockServer); + readonly participantRobot = new ParticipantRobot(this.mockServer); +} + +/** `describe` + `it` names `MessageListTests test_x` → `test_x`, which names the mock server log. */ +const currentTestName = () => + (jestExpect.getState().currentTestName ?? 'unknown') + .split(' ') + .pop()! + .replace(/[^\w-]/g, '_'); + +export const setUpStreamTestCase = (): StreamTestCase => { + const testCase = new StreamTestCase(); + + beforeEach(async () => { + await testCase.mockServer.start(currentTestName()); + if (device.getPlatform() === 'android') { + dismissAnrDialog(); + } + await device.launchApp({ + launchArgs: { + MOCK_SERVER_URL: testCase.mockServer.appUrl, + // iOS: Detox's idle tracking can wedge on RN's UIManager shadow queue (its pending-work + // counter underflows to 2^64-1), leaving the app "busy" until the test times out. Every + // robot action and assert waits explicitly, like the native XCUITest suites, so it runs + // without that synchronization. Android's Espresso synchronization stays on. + detoxEnableSynchronization: device.getPlatform() === 'ios' ? 0 : 1, + }, + newInstance: true, + // No `permissions`: the app asks for none, and on iOS Detox applies each one with + // `applesimutils --restartSB`, restarting SpringBoard per permission on every launch + // (minutes per test on CI). + }); + }); + + afterEach(async () => { + await testCase.mockServer.stop(); + }); + + return testCase; +}; diff --git a/examples/E2ETestApp/e2e/support/step.ts b/examples/E2ETestApp/e2e/support/step.ts new file mode 100644 index 0000000000..6ff6d18688 --- /dev/null +++ b/examples/E2ETestApp/e2e/support/step.ts @@ -0,0 +1,14 @@ +/** + * A GIVEN / WHEN / THEN step, like `step("...") { }` on Android and `GIVEN("...") { }` on iOS. + * A failure is reported with the step it happened in. + */ +export const step = async (name: string, body: () => PromiseLike) => { + try { + await body(); + } catch (error) { + if (error instanceof Error) { + error.message = `[${name}] ${error.message}`; + } + throw error; + } +}; diff --git a/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts new file mode 100644 index 0000000000..9a742a8e80 --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/ChannelListTests.e2e.ts @@ -0,0 +1,43 @@ +import { describe, it } from '@jest/globals'; + +import { step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('ChannelListTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + it('test_channelListShowsGeneratedChannels', async () => { + await step('GIVEN backend has 3 channels', () => + backendRobot.generateChannels({ channelsCount: 3, channelNames: ['Alpha', 'Beta', 'Gamma'] }), + ); + await step('WHEN user logs in', () => userRobot.login().waitForChannelListToLoad()); + await step('THEN user observes all channels in the list', () => + userRobot.assertChannelCount(3).assertChannelAvatar(), + ); + }); + + it('test_channelPreviewUpdates_whenParticipantSendsMessage', async () => { + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); + await step('AND user goes back to the channel list', () => + userRobot.assertMessage(sampleText).moveToChannelListFromMessageList(), + ); + await step('THEN user observes the new message in preview', () => + userRobot.assertMessageInChannelPreview(sampleText, false).assertChannelAvatar(), + ); + }); + + it('test_channelPreviewUpdates_whenUserSendsMessage', async () => { + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a message', () => + userRobot.sendMessage(sampleText).assertMessage(sampleText), + ); + await step('AND user goes back to the channel list', () => + userRobot.moveToChannelListFromMessageList(), + ); + await step('THEN user observes the new message in preview', () => + userRobot.assertMessageInChannelPreview(sampleText, true), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts new file mode 100644 index 0000000000..265ed0d70d --- /dev/null +++ b/examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts @@ -0,0 +1,44 @@ +import { describe, it } from '@jest/globals'; + +import { MessageDeliveryStatus } from '../mock-server/DataTypes'; +import { step } from '../support/step'; +import { setUpStreamTestCase } from '../support/StreamTestCase'; + +describe('MessageListTests', () => { + const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase(); + const sampleText = 'Test'; + + it('test_messageListUpdates_whenUserSendsMessage', async () => { + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN user sends a message', () => userRobot.sendMessage(sampleText)); + await step('THEN message list updates', () => + userRobot + .assertMessage(sampleText) + .assertMessageAuthor(true) + .assertMessageDeliveryStatus(MessageDeliveryStatus.SENT), + ); + }); + + it('test_messageListUpdates_whenParticipantSendsMessage', async () => { + await step('GIVEN user opens a channel', () => userRobot.login().openChannel()); + await step('WHEN participant sends a message', () => participantRobot.sendMessage(sampleText)); + await step('THEN user receives a message', () => + userRobot.assertMessage(sampleText).assertMessageAuthor(false), + ); + }); + + it('test_typingIndicator', async () => { + await step('GIVEN user opens the channel', async () => { + await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 1 }); + await userRobot.login().openChannel(); + }); + await step('WHEN participant starts typing', () => participantRobot.startTyping()); + await step('THEN user observes typing indicator is shown', () => + userRobot.assertTypingIndicator(true), + ); + await step('WHEN participant stops typing', () => participantRobot.stopTyping()); + await step('THEN user observes typing indicator has disappeared', () => + userRobot.assertTypingIndicator(false), + ); + }); +}); diff --git a/examples/E2ETestApp/e2e/tsconfig.json b/examples/E2ETestApp/e2e/tsconfig.json new file mode 100644 index 0000000000..2f08e6cc4d --- /dev/null +++ b/examples/E2ETestApp/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": ["detox/globals"] + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/examples/E2ETestApp/index.js b/examples/E2ETestApp/index.js new file mode 100644 index 0000000000..d2660dcb40 --- /dev/null +++ b/examples/E2ETestApp/index.js @@ -0,0 +1,7 @@ +import 'react-native-gesture-handler'; +import { AppRegistry } from 'react-native'; + +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/examples/E2ETestApp/ios/.xcode.env b/examples/E2ETestApp/ios/.xcode.env new file mode 100644 index 0000000000..3d5782c715 --- /dev/null +++ b/examples/E2ETestApp/ios/.xcode.env @@ -0,0 +1,11 @@ +# This `.xcode.env` file is versioned and is used to source the environment +# used when running script phases inside Xcode. +# To customize your local environment, you can create an `.xcode.env.local` +# file that is not versioned. + +# NODE_BINARY variable contains the PATH to the node executable. +# +# Customize the NODE_BINARY variable here. +# For example, to use nvm with brew, add the following line +# . "$(brew --prefix nvm)/nvm.sh" --no-use +export NODE_BINARY=$(command -v node) diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..756385091b --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/project.pbxproj @@ -0,0 +1,498 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 0C80B921A6F3F58F76C31292 /* libPods-E2ETestApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */; }; + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 761780EF2CA45674006654EF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EE2CA45674006654EF /* SceneDelegate.swift */; }; + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + AE4AFD92A8A3756E73E2B1AB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* E2ETestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = E2ETestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = E2ETestApp/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = E2ETestApp/Info.plist; sourceTree = ""; }; + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = E2ETestApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2ETestApp.debug.xcconfig"; path = "Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp.debug.xcconfig"; sourceTree = ""; }; + 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-E2ETestApp.release.xcconfig"; path = "Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp.release.xcconfig"; sourceTree = ""; }; + 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-E2ETestApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = E2ETestApp/AppDelegate.swift; sourceTree = ""; }; + 761780EE2CA45674006654EF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = E2ETestApp/SceneDelegate.swift; sourceTree = ""; }; + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = E2ETestApp/LaunchScreen.storyboard; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C80B921A6F3F58F76C31292 /* libPods-E2ETestApp.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* E2ETestApp */ = { + isa = PBXGroup; + children = ( + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 761780EC2CA45674006654EE /* AppDelegate.swift */, + 761780EE2CA45674006654EF /* SceneDelegate.swift */, + 13B07FB61A68108700A75B9A /* Info.plist */, + 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, + 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + ); + name = E2ETestApp; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 5DCACB8F33CDC322A6C60F78 /* libPods-E2ETestApp.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* E2ETestApp */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + BBD78D7AC51CEA395F1C20DB /* Pods */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* E2ETestApp.app */, + ); + name = Products; + sourceTree = ""; + }; + BBD78D7AC51CEA395F1C20DB /* Pods */ = { + isa = PBXGroup; + children = ( + 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */, + 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* E2ETestApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "E2ETestApp" */; + buildPhases = ( + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = E2ETestApp; + productName = E2ETestApp; + productReference = 13B07F961A680F5B00A75B9A /* E2ETestApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1210; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1120; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "E2ETestApp" */; + compatibilityVersion = "Xcode 12.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* E2ETestApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + AE4AFD92A8A3756E73E2B1AB /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/.xcode.env", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; + }; + 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-E2ETestApp-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-E2ETestApp/Pods-E2ETestApp-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, + 761780EF2CA45674006654EF /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-E2ETestApp.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = E2ETestApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.reactnative.e2etestapp; + PRODUCT_NAME = E2ETestApp; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-E2ETestApp.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = E2ETestApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.reactnative.e2etestapp; + PRODUCT_NAME = E2ETestApp; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 83CBBA201A601CBA00E9B192 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + USE_HERMES = true; + }; + name = Debug; + }; + 83CBBA211A601CBA00E9B192 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = ( + "\"$(SDKROOT)/usr/lib/swift\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(OTHER_CFLAGS)", + "-DFOLLY_NO_CONFIG", + "-DFOLLY_MOBILE=1", + "-DFOLLY_USE_LIBCPP=1", + "-DFOLLY_CFG_NO_COROUTINES=1", + "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "E2ETestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "E2ETestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme new file mode 100644 index 0000000000..cd9bdbadeb --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp.xcodeproj/xcshareddata/xcschemes/E2ETestApp.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata b/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..bd672bb7e5 --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift b/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift new file mode 100644 index 0000000000..0d14d1b0ac --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/AppDelegate.swift @@ -0,0 +1,11 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + true + } +} diff --git a/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..81213230de --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json new file mode 100644 index 0000000000..2d92bd53fd --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/examples/E2ETestApp/ios/E2ETestApp/Info.plist b/examples/E2ETestApp/ios/E2ETestApp/Info.plist new file mode 100644 index 0000000000..cf6c5bcfd6 --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/Info.plist @@ -0,0 +1,78 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + E2ETestApp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocationWhenInUseUsageDescription + + RCTNewArchEnabled + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard b/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard new file mode 100644 index 0000000000..600e8dc84b --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/LaunchScreen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..fc90547a7a --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/PrivacyInfo.xcprivacy @@ -0,0 +1,37 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift b/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift new file mode 100644 index 0000000000..b5c4d45824 --- /dev/null +++ b/examples/E2ETestApp/ios/E2ETestApp/SceneDelegate.swift @@ -0,0 +1,56 @@ +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UIKit + +class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate { + var window: UIWindow? + var reactNativeFactory: RCTReactNativeFactory? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + dependencyProvider = RCTAppDependencyProvider() + reactNativeFactory = RCTReactNativeFactory(delegate: self) + window = UIWindow(windowScene: windowScene) + + #if DEBUG + let devMenuConfiguration = RCTDevMenuConfiguration( + devMenuEnabled: true, + shakeGestureEnabled: true, + keyboardShortcutsEnabled: true + ) + reactNativeFactory?.devMenuConfiguration = devMenuConfiguration + #endif + + reactNativeFactory?.startReactNative( + withModuleName: "E2ETestApp", + in: window, + connectionOptions: connectionOptions + ) + + + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + RCTLinkingManager.scene(scene, openURLContexts: URLContexts) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + RCTLinkingManager.scene(scene, continue: userActivity) + } + + override func bundleURL() -> URL? { + #if DEBUG + RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") + #else + Bundle.main.url(forResource: "main", withExtension: "jsbundle") + #endif + } +} diff --git a/examples/E2ETestApp/ios/Podfile b/examples/E2ETestApp/ios/Podfile new file mode 100644 index 0000000000..10e8a8c808 --- /dev/null +++ b/examples/E2ETestApp/ios/Podfile @@ -0,0 +1,45 @@ +# Resolve react_native_pods.rb with node to allow for hoisting +require Pod::Executable.execute_command('node', ['-p', + 'require.resolve( + "react-native/scripts/react_native_pods.rb", + {paths: [process.argv[1]]}, + )', __dir__]).strip + +platform :ios, min_ios_version_supported +prepare_react_native_project! + +linkage = ENV['USE_FRAMEWORKS'] +if linkage != nil + Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green + use_frameworks! :linkage => linkage.to_sym +end + +target 'E2ETestApp' do + config = use_native_modules! + + use_react_native!( + :path => config[:reactNativePath], + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/.." + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + # CI sets USE_CCACHE=1 so compiled objects are reused across runs. + :ccache_enabled => ENV['USE_CCACHE'] == '1', + ) + + # Some pods pin an iOS deployment target (e.g. RNSVG at 12.4) below the range supported by + # current Xcode. Clamp them up to the app's minimum, as the SampleApp does. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < min_ios_version_supported.to_f + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported + end + end + end + end +end diff --git a/examples/E2ETestApp/ios/Podfile.lock b/examples/E2ETestApp/ios/Podfile.lock new file mode 100644 index 0000000000..cb27ec2bdd --- /dev/null +++ b/examples/E2ETestApp/ios/Podfile.lock @@ -0,0 +1,2554 @@ +PODS: + - boost (1.84.0): + - ReactNativeDependencies + - DoubleConversion (1.1.6): + - ReactNativeDependencies + - fast_float (8.0.0): + - ReactNativeDependencies + - FBLazyVector (0.88.0): + - React-Core-prebuilt + - fmt (12.1.0): + - ReactNativeDependencies + - glog (0.3.5): + - ReactNativeDependencies + - hermes-engine (260318099.0.4): + - hermes-engine/Pre-built (= 260318099.0.4) + - hermes-engine/Pre-built (260318099.0.4) + - RCT-Folly (2024.11.18.00): + - RCT-Folly/Default (= 2024.11.18.00) + - ReactNativeDependencies + - RCT-Folly/Default (2024.11.18.00): + - ReactNativeDependencies + - RCTDeprecation (0.88.0): + - React-Core-prebuilt + - RCTRequired (0.88.0): + - React-Core-prebuilt + - RCTSwiftUI (0.88.0) + - RCTSwiftUIWrapper (0.88.0): + - RCTSwiftUI + - RCTTypeSafety (0.88.0): + - FBLazyVector (= 0.88.0) + - RCTRequired (= 0.88.0) + - React-Core (= 0.88.0) + - React (0.88.0): + - React-Core (= 0.88.0) + - React-Core/DevSupport (= 0.88.0) + - React-Core/RCTWebSocket (= 0.88.0) + - React-RCTActionSheet (= 0.88.0) + - React-RCTAnimation (= 0.88.0) + - React-RCTBlob (= 0.88.0) + - React-RCTImage (= 0.88.0) + - React-RCTLinking (= 0.88.0) + - React-RCTNetwork (= 0.88.0) + - React-RCTSettings (= 0.88.0) + - React-RCTText (= 0.88.0) + - React-RCTVibration (= 0.88.0) + - React-bridging (0.88.0): + - React-callinvoker + - React-Core-prebuilt + - React-jsi + - React-timing + - ReactNativeDependencies + - React-callinvoker (0.88.0): + - React-callinvoker/callinvokerUmbrella (= 0.88.0) + - React-cxxstableapi + - React-callinvoker/callinvokerUmbrella (0.88.0): + - React-cxxstableapi + - React-Core (0.88.0): + - React-Core-prebuilt + - React-Core/Default (= 0.88.0) + - React-Core-prebuilt (0.88.0): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/Default (0.88.0): + - React-Core-prebuilt + - React-Core/DevSupport (0.88.0): + - React-Core-prebuilt + - React-Core/RCTActionSheetHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTBlobHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTImageHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTLinkingHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTTextHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders (0.88.0): + - React-Core-prebuilt + - React-Core/RCTWebSocket (0.88.0): + - React-Core-prebuilt + - React-CoreModules (0.88.0): + - RCTTypeSafety (= 0.88.0) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.88.0) + - React-debug + - React-featureflags + - React-jsi (= 0.88.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-networking + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.88.0) + - React-runtimeexecutor + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.88.0): + - hermes-engine + - React-callinvoker (= 0.88.0) + - React-Core-prebuilt + - React-debug (= 0.88.0) + - React-jserrorhandler (= 0.88.0) + - React-jsi (= 0.88.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.88.0) + - React-perflogger (= 0.88.0) + - React-runtimeexecutor + - React-timing (= 0.88.0) + - React-utils + - ReactNativeDependencies + - React-cxxstableapi (0.88.0) + - React-debug (0.88.0): + - React-cxxstableapi + - React-debug/debugUmbrella (= 0.88.0) + - React-debug/redbox (= 0.88.0) + - React-debug/debugUmbrella (0.88.0): + - React-cxxstableapi + - React-debug/redbox (0.88.0): + - React-cxxstableapi + - React-defaultsnativemodule (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-domnativemodule + - React-Fabric/animated + - React-featureflags + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-intersectionobservernativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-mutationobservernativemodule + - React-RCTFBReactNativeSpec + - React-resizeobservernativemodule + - React-viewtransitionnativemodule + - React-webperformancenativemodule + - ReactNativeDependencies + - Yoga + - React-domnativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxstableapi + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/animated (= 0.88.0) + - React-Fabric/animationbackend (= 0.88.0) + - React-Fabric/animations (= 0.88.0) + - React-Fabric/attributedstring (= 0.88.0) + - React-Fabric/bridging (= 0.88.0) + - React-Fabric/componentregistry (= 0.88.0) + - React-Fabric/componentregistrynative (= 0.88.0) + - React-Fabric/components (= 0.88.0) + - React-Fabric/consistency (= 0.88.0) + - React-Fabric/core (= 0.88.0) + - React-Fabric/dom (= 0.88.0) + - React-Fabric/imagemanager (= 0.88.0) + - React-Fabric/leakchecker (= 0.88.0) + - React-Fabric/mounting (= 0.88.0) + - React-Fabric/observers (= 0.88.0) + - React-Fabric/scheduler (= 0.88.0) + - React-Fabric/telemetry (= 0.88.0) + - React-Fabric/uimanager (= 0.88.0) + - React-Fabric/viewtransition (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animated (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/animationbackend + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animationbackend (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.88.0) + - React-Fabric/components/root (= 0.88.0) + - React-Fabric/components/scrollview (= 0.88.0) + - React-Fabric/components/view (= 0.88.0) + - React-Fabric/components/viewUmbrella (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/components/viewUmbrella (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/consistency (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-jsinspectortracing + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/observers/events (= 0.88.0) + - React-Fabric/observers/intersection (= 0.88.0) + - React-Fabric/observers/mutation (= 0.88.0) + - React-Fabric/observers/resize (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/intersection (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/mutation (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/resize (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/animationbackend + - React-Fabric/observers/events + - React-Fabric/viewtransition + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancecdpmetrics + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric/uimanager/consistency (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/viewtransition (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.88.0) + - React-FabricComponents/textlayoutmanager (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.88.0) + - React-FabricComponents/components/iostextinput (= 0.88.0) + - React-FabricComponents/components/modal (= 0.88.0) + - React-FabricComponents/components/rncore (= 0.88.0) + - React-FabricComponents/components/safeareaview (= 0.88.0) + - React-FabricComponents/components/switch (= 0.88.0) + - React-FabricComponents/components/text (= 0.88.0) + - React-FabricComponents/components/textinput (= 0.88.0) + - React-FabricComponents/components/unimplementedview (= 0.88.0) + - React-FabricComponents/components/virtualview (= 0.88.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.88.0): + - hermes-engine + - RCTRequired (= 0.88.0) + - RCTTypeSafety (= 0.88.0) + - React-Core-prebuilt + - React-cxxstableapi + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.88.0) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-featureflags/featureflagsUmbrella (= 0.88.0) + - ReactNativeDependencies + - React-featureflags/featureflagsUmbrella (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - ReactNativeDependencies + - React-featureflagsnativemodule (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-hermes (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.88.0) + - React-cxxstableapi + - React-jsi + - React-jsiexecutor (= 0.88.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-oscompat + - React-perflogger (= 0.88.0) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxstableapi + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.88.0): + - React-Core-prebuilt + - React-Core/Default + - React-cxxstableapi + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-intersectionobservernativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-Fabric + - React-Fabric/bridging + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-jserrorhandler (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-featureflags + - React-jsi + - ReactNativeDependencies + - React-jsi (0.88.0): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-jserrorhandler + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspector (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.88.0) + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspectorcdp (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - ReactNativeDependencies + - React-jsinspectornetwork (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-jsinspectorcdp + - ReactNativeDependencies + - React-jsinspectortracing (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-jsi + - React-jsinspectornetwork + - React-oscompat + - React-timing + - React-utils + - ReactNativeDependencies + - React-jsitooling (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.88.0) + - React-cxxstableapi + - React-debug + - React-jsi (= 0.88.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsitracing (0.88.0): + - React-jsi + - React-logger (0.88.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Mapbuffer/mapbufferUmbrella (= 0.88.0) + - ReactNativeDependencies + - React-Mapbuffer/mapbufferUmbrella (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-mutationobservernativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-Fabric + - React-Fabric/bridging + - React-Fabric/observers/mutation + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-launch-arguments (4.1.1): + - React + - react-native-netinfo (12.0.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context (5.10.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.10.1) + - react-native-safe-area-context/fabric (= 5.10.1) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.10.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.10.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.88.0): + - hermes-engine + - React-bridging + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-networking (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-oscompat (0.88.0): + - React-cxxstableapi + - React-perflogger (0.88.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancecdpmetrics (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-jsi + - React-performancetimeline + - React-runtimeexecutor + - React-timing + - ReactNativeDependencies + - React-performancetimeline (0.88.0): + - React-Core-prebuilt + - React-cxxstableapi + - React-featureflags + - React-jsinspector + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.88.0): + - React-Core/RCTActionSheetHeaders (= 0.88.0) + - React-RCTAnimatedModuleProvider (0.88.0): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-Fabric/animated + - React-featureflags + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTAnimation (0.88.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-debug + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTAnimatedModuleProvider + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTLinking + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.88.0): + - React-Core-prebuilt + - React-RCTFBReactNativeSpec (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.88.0) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.88.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.88.0): + - React-Core/RCTLinkingHeaders (= 0.88.0) + - React-jsi (= 0.88.0) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.88.0) + - React-RCTNetwork (0.88.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-networking + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.88.0): + - React-Core-prebuilt + - React-RCTSettings (0.88.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.88.0): + - React-Core/RCTTextHeaders (= 0.88.0) + - Yoga + - React-RCTVibration (0.88.0): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.88.0) + - React-renderercss (0.88.0): + - React-debug + - React-utils + - React-rendererdebug (0.88.0): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-resizeobservernativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-Fabric + - React-Fabric/bridging + - React-Fabric/observers/resize + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-RuntimeApple (0.88.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-cxxstableapi + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.88.0): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.88.0) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxstableapi + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.88.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-debug + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.88.0): + - React-cxxstableapi + - React-debug + - React-timing/timingUmbrella (= 0.88.0) + - React-timing/timingUmbrella (0.88.0): + - React-cxxstableapi + - React-debug + - React-utils (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.88.0) + - ReactNativeDependencies + - React-viewtransitionnativemodule (0.88.0): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-webperformancenativemodule (0.88.0): + - hermes-engine + - React-bridging + - React-Core-prebuilt + - React-cxxreact + - React-cxxstableapi + - React-jsi + - React-jsiexecutor + - React-performancetimeline + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactAppDependencyProvider (0.88.0): + - ReactCodegen + - ReactCodegen (0.88.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.88.0): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.88.0) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.88.0): + - hermes-engine + - React-callinvoker (= 0.88.0) + - React-Core-prebuilt + - React-jsi (= 0.88.0) + - React-logger (= 0.88.0) + - React-perflogger (= 0.88.0) + - ReactCommon/turbomodule/core (= 0.88.0) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.88.0): + - hermes-engine + - React-bridging + - React-callinvoker (= 0.88.0) + - React-Core-prebuilt + - React-cxxreact (= 0.88.0) + - React-debug (= 0.88.0) + - React-featureflags (= 0.88.0) + - React-jsi (= 0.88.0) + - React-logger (= 0.88.0) + - React-perflogger (= 0.88.0) + - React-utils (= 0.88.0) + - ReactNativeDependencies + - ReactNativeDependencies (0.88.0) + - RNGestureHandler (3.4.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets (>= 0.8.0) + - Yoga + - RNReanimated (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/apple (= 4.7.1) + - RNReanimated/common (= 4.7.1) + - RNReanimated/view (= 4.7.1) + - RNWorklets + - Yoga + - RNReanimated/apple (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNReanimated/common (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNReanimated/view (4.7.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNSVG (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNSVG/common (= 15.15.5) + - Yoga + - RNSVG/common (15.15.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/apple (= 0.13.0) + - RNWorklets/common (= 0.13.0) + - Yoga + - RNWorklets/apple (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets/common (0.13.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - SocketRocket (0.7.1): + - ReactNativeDependencies + - stream-chat-react-native (9.10.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - Teleport (1.2.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Teleport/common (= 1.2.2) + - Yoga + - Teleport/common (1.2.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - Yoga (0.0.0): + - React-Core-prebuilt + +DEPENDENCIES: + - boost (from `build/rndeps-facades/boost`) + - DoubleConversion (from `build/rndeps-facades/DoubleConversion`) + - fast_float (from `build/rndeps-facades/fast_float`) + - FBLazyVector (from `build/rncore-facades/FBLazyVector`) + - fmt (from `build/rndeps-facades/fmt`) + - glog (from `build/rndeps-facades/glog`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - RCT-Folly (from `build/rndeps-facades/RCT-Folly`) + - RCTDeprecation (from `build/rncore-facades/RCTDeprecation`) + - RCTRequired (from `build/rncore-facades/RCTRequired`) + - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) + - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-bridging (from `../node_modules/react-native/ReactCommon/react/bridging`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `build/rncore-facades/React-Core`) + - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `build/rncore-facades/React-Core`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-cxxstableapi (from `../node_modules/react-native/ReactCommon/react/cxxstableapi`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) + - react-native-launch-arguments (from `../node_modules/react-native-launch-arguments`) + - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimatedModuleProvider (from `../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `build/rncore-facades/React-RCTFabric`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `build/rncore-facades/React-RCTRuntime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-resizeobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/resizeobserver`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-viewtransitionnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition`) + - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) + - ReactCodegen (from `build/generated/ios/ReactCodegen`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) + - RNReanimated (from `../node_modules/react-native-reanimated`) + - RNSVG (from `../node_modules/react-native-svg`) + - RNWorklets (from `../node_modules/react-native-worklets`) + - SocketRocket (from `build/rndeps-facades/SocketRocket`) + - stream-chat-react-native (from `../node_modules/stream-chat-react-native`) + - Teleport (from `../node_modules/react-native-teleport`) + - Yoga (from `build/rncore-facades/Yoga`) + +EXTERNAL SOURCES: + boost: + :path: build/rndeps-facades/boost + DoubleConversion: + :path: build/rndeps-facades/DoubleConversion + fast_float: + :path: build/rndeps-facades/fast_float + FBLazyVector: + :path: build/rncore-facades/FBLazyVector + fmt: + :path: build/rndeps-facades/fmt + glog: + :path: build/rndeps-facades/glog + hermes-engine: + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-v260318099.0.2 + RCT-Folly: + :path: build/rndeps-facades/RCT-Folly + RCTDeprecation: + :path: build/rncore-facades/RCTDeprecation + RCTRequired: + :path: build/rncore-facades/RCTRequired + RCTSwiftUI: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUI" + RCTSwiftUIWrapper: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" + RCTTypeSafety: + :path: "../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../node_modules/react-native/" + React-bridging: + :path: "../node_modules/react-native/ReactCommon/react/bridging" + React-callinvoker: + :path: "../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: build/rncore-facades/React-Core + React-Core-prebuilt: + :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../node_modules/react-native/ReactCommon/cxxreact" + React-cxxstableapi: + :path: "../node_modules/react-native/ReactCommon/react/cxxstableapi" + React-debug: + :path: "../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-intersectionobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" + React-jserrorhandler: + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + React-mutationobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" + react-native-launch-arguments: + :path: "../node_modules/react-native-launch-arguments" + react-native-netinfo: + :path: "../node_modules/@react-native-community/netinfo" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" + React-NativeModulesApple: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-networking: + :path: "../node_modules/react-native/ReactCommon/react/networking" + React-oscompat: + :path: "../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../node_modules/react-native/ReactCommon/reactperflogger" + React-performancecdpmetrics: + :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + React-performancetimeline: + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimatedModuleProvider: + :path: "../node_modules/react-native/ReactApple/RCTAnimatedModuleProvider" + React-RCTAnimation: + :path: "../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: build/rncore-facades/React-RCTFabric + React-RCTFBReactNativeSpec: + :path: "../node_modules/react-native/React" + React-RCTImage: + :path: "../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: build/rncore-facades/React-RCTRuntime + React-RCTSettings: + :path: "../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + React-resizeobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/resizeobserver" + React-RuntimeApple: + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../node_modules/react-native/ReactCommon/react/utils" + React-viewtransitionnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition" + React-webperformancenativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + ReactAppDependencyProvider: + :path: build/generated/ios/ReactAppDependencyProvider + ReactCodegen: + :path: build/generated/ios/ReactCodegen + ReactCommon: + :path: "../node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNGestureHandler: + :path: "../node_modules/react-native-gesture-handler" + RNReanimated: + :path: "../node_modules/react-native-reanimated" + RNSVG: + :path: "../node_modules/react-native-svg" + RNWorklets: + :path: "../node_modules/react-native-worklets" + SocketRocket: + :path: build/rndeps-facades/SocketRocket + stream-chat-react-native: + :path: "../node_modules/stream-chat-react-native" + Teleport: + :path: "../node_modules/react-native-teleport" + Yoga: + :path: build/rncore-facades/Yoga + +SPEC CHECKSUMS: + boost: 8502bfdc46e2804408af2e163049ece181009af4 + DoubleConversion: 501a575c40c0b50c89685762dd3e415e6bb26fb0 + fast_float: 7c925d1cebf8328d4018d061f49377e9f887e3b4 + FBLazyVector: fdebac2dfe9c53bca49f8218fbdf1e24e36c8be0 + fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93 + glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4 + hermes-engine: a1158910bb7e300b9e3d2f1e2d433f817ec1a4b5 + RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4 + RCTDeprecation: e134f2a2c096a6450f5fcbbe949531f913a2ff04 + RCTRequired: f1a68295837ee8f91e112ed1b5694d12e7ff1784 + RCTSwiftUI: 4b21ec5b1f86acb9c96bb429100ad88d4023ed76 + RCTSwiftUIWrapper: 68bd9fac4e710259cb2b202472adc5f4d113e872 + RCTTypeSafety: 77078a9c26030dd84a6bdae74ed900159f89a098 + React: 8dafd0ef058191e9e7238cfa6b3deaa24398e5dd + React-bridging: 658efeb1b4704d5f60ea2fb6e8ca826ab0ae3234 + React-callinvoker: 4e40c208c1ea46686c084a7c23da6a65cbb99482 + React-Core: a831be15fcea0e345a24cb10d6cc0b87e9913d03 + React-Core-prebuilt: 0fd2555a42f40e7770ced1c7dfc5ee1dbfe041cd + React-CoreModules: a86fbe698fd817c17b3936832dc1106a130c4694 + React-cxxreact: 48ca56bd4f26de869478a803a5544b101a57cde6 + React-cxxstableapi: f35502b4c542a0d44031b42f91c4ca813d267e16 + React-debug: 238e15d17dfded9419a6d739d4c8b60968bad02e + React-defaultsnativemodule: c028edc7e48914344b823931aec1cc4ffabe631e + React-domnativemodule: b09770fc69f5ce5759de98f8065cab900b6d87d6 + React-Fabric: a157c978603fa2796f349f3a27d17861e55804c5 + React-FabricComponents: c7c58a1deea5db215e9d5a2aec890e208d5d5075 + React-FabricImage: 234346bf5a3ef918f3873e845b57b93b2b1f31c0 + React-featureflags: a00ac5c38e63d5f86de895ca923f07f5ff76de66 + React-featureflagsnativemodule: 6b93ba0aef46757b9d54166475abc5e4fa80e70d + React-graphics: 634bc2e5884b4a29f56aa8aef719e03171a23f6b + React-hermes: 9ce7aaf6784709e196ac51bfd89dd6c551e3fb4c + React-idlecallbacksnativemodule: e02e194086278377ced48b7fd7816b6c925b9e7e + React-ImageManager: 452d4b3587f21d3384868e2abaab7c0a952b1d1c + React-intersectionobservernativemodule: 3ddbc206bc95c086f1df6eea04aa617ae02627d2 + React-jserrorhandler: a0d842e1f74cc10e597fde2a6447860483df2b13 + React-jsi: 489de0620905b88a17ccb0ac2328046af43e7ebd + React-jsiexecutor: f0e2ae6d98b587d3505ebde246599ee3ad97cee1 + React-jsinspector: 515bf11caeda5df444f39aec63e377ef77ad7696 + React-jsinspectorcdp: be9ac71a77880a6fcc95375e7e61a79e152a3203 + React-jsinspectornetwork: a4bc1475897a2301d114d7a8605af3e8760f9e52 + React-jsinspectortracing: 7d35952c944dc337d1a4167605c1338108880219 + React-jsitooling: 1886dc5058e9feded2136b0d9ba5c189c6af5faa + React-jsitracing: 76b58feee3fb0539c8967e5b9de1a40ad8014f09 + React-logger: 3d2b406a780fed50ad0bbd07452b373c2a515308 + React-Mapbuffer: a2dae4f2cb335ebb97f2240494d0c2bab84cd339 + React-microtasksnativemodule: 8b3aaa5f3b907ecf071b105589868d9f027a448d + React-mutationobservernativemodule: 11d77d3f7360578f07847843544ada23cc473049 + react-native-launch-arguments: eab68037b01ea7c78a0b3b55da3a5e967e9aaf92 + react-native-netinfo: 687391ecedb199c6d67fbc46c87cbae3277de967 + react-native-safe-area-context: b1c9f15a36e597e9cef4c5526e209ef2645bc2e0 + React-NativeModulesApple: 52f3def085f129de1307e3eb78e26729ff70f421 + React-networking: 1e6441349ac72bfd4f7a79c300c3dd76993a7058 + React-oscompat: e64123dd8199f9e99cede19fa542c3da3445481e + React-perflogger: f1466e0f46ce706aef73a559337eca85f51a40b7 + React-performancecdpmetrics: f238752e99eb7c69c782020827bff38a4b1b2ef2 + React-performancetimeline: f1b216fa02200c859cd18ef345d4230cd712137a + React-RCTActionSheet: 81662769b1f576e9c2e074deb41d6b83d1c7e29f + React-RCTAnimatedModuleProvider: a9829773197f782c19ad86c5438a2cc199203774 + React-RCTAnimation: ad9dd2c6c3b4d07e583c2b79354858c4f34481a0 + React-RCTAppDelegate: 70398a06e10ada27cdf77dc2b43f883126a91ac0 + React-RCTBlob: f3d86b491b8f28c36ced0cb750878b62f72e1380 + React-RCTFabric: 3767f97183c40ba3dadf007b17a01be2cd4e632a + React-RCTFBReactNativeSpec: d7ad5d9edc1865f38b9aac5b7091c3abb6978eed + React-RCTImage: 46ad408f777dce991d4095d7e73a568212be6baa + React-RCTLinking: 547427cc885bfa74b3cbba405971c5270defde7a + React-RCTNetwork: 52c00c0fe07327df9b9fe9881b4f5550dc5ced37 + React-RCTRuntime: 081fd457d305c3aee76c335c568e693ed107f48e + React-RCTSettings: 9cd0b2be41404574ec280b6b8e73c2208c842050 + React-RCTText: 5a6d38456eabc0a1a7c0d47d592390d35c6de57c + React-RCTVibration: c1815b4f471462954a4b7d8835155e053cec5412 + React-rendererconsistency: b7996da82c8d3f4a7928b39be3e437b72ad92012 + React-renderercss: 4c256900eba87661178771766da0171493f88848 + React-rendererdebug: 4d9df8cf2cac6eee7002a4d14358c79fdf6ad8c5 + React-resizeobservernativemodule: 750d334554e7c44bacf14568a8a0d0048cc4c278 + React-RuntimeApple: 2045a6206895ad5ce90d141d1bd384888fd06c42 + React-RuntimeCore: b051facffbc7ca038097278cabf810d6d86dd328 + React-runtimeexecutor: b06ec9bb745a97cbd3ce25e3e02be7b8058fb374 + React-RuntimeHermes: fcfa8baca49a6d37d331147568d51d5b8b0a175d + React-runtimescheduler: c2afe74d1db006bafd9270186101f5378b1bef81 + React-timing: 6a1a8cfb0caae8de243aba16fa92fa81de23efaf + React-utils: 2384e5db98378df8c0bf2c0d5489ec1b8e80b398 + React-viewtransitionnativemodule: 8505b253586f792c6c722869a664889797ecae3e + React-webperformancenativemodule: 67f32fde92ed7e8470ee1c669bdb7c2fcb760141 + ReactAppDependencyProvider: 782ee9546c9cd3b53b73f1e9e3dd728540085fc7 + ReactCodegen: 7b71f4464442e9b6eae41094d11076d56c51c0e3 + ReactCommon: 145a14b942b19fc2fe38927f261955ada56978aa + ReactNativeDependencies: d3b7423ba1e52525e42085c8470109abde583d2e + RNGestureHandler: b5512a093e6923dd1c31fbdd8e6186cd0ebb3541 + RNReanimated: 3018727dbf272754d0f31ef73f619e49ade7baf0 + RNSVG: 8b5f2fdb9f0245a863cdd6e9c48a7689821a08b0 + RNWorklets: 488806637a21eb17812a48dadca7cb8c698c8cfd + SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 + stream-chat-react-native: 7f1fdf2d1ce87c0b8a6c1d581308dff0c84e8e21 + Teleport: 90f0d7e81c6b40db9b253e1ef73b2f4f4f3f6b53 + Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e + +PODFILE CHECKSUM: 84fecefd0a807b1cfa8b4184065b9bbd44fa58d1 + +COCOAPODS: 1.15.2 diff --git a/examples/E2ETestApp/metro.config.js b/examples/E2ETestApp/metro.config.js new file mode 100644 index 0000000000..c96816d4be --- /dev/null +++ b/examples/E2ETestApp/metro.config.js @@ -0,0 +1,81 @@ +const { getDefaultConfig } = require('@react-native/metro-config'); +const { resolveUniqueModule } = require('@rnx-kit/metro-config'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * @type {import('@react-native/metro-config').MetroConfig} + */ +const config = getDefaultConfig(__dirname); + +const PATH = require('path'); +const metroConfigPackageJsonPath = require.resolve('metro-config/package.json', { + paths: [__dirname], +}); +const metroExclusionList = require( + PATH.join(PATH.dirname(metroConfigPackageJsonPath), 'src/defaults/exclusionList.js'), +); +const exclusionList = metroExclusionList.default || metroExclusionList; +const packageDirPath = PATH.resolve(__dirname, '../../package'); +const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); + +const symlinked = { + 'stream-chat-react-native': nativePackageDirPath, + 'stream-chat-react-native-core': packageDirPath, +}; + +// find what all modules need to be unique for the app (mainly react and react-native) +// note: we filter the symlinked modules as they are already unique +// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect +const dependencyPackageNames = Object.keys(require('./package.json').dependencies); + +const uniqueModules = dependencyPackageNames.map((packageName) => { + if (symlinked[packageName]) { + const modulePath = symlinked[packageName]; + const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\'); + + // exclude the symlinked package from being resolved from node_modules + // example: .*\/node_modules\/stream-chat-react-native-core\/.* + // the above would avoid native-package to resolve core from its own node_modules + const exclusionRE = new RegExp( + `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`, + ); + + return { + packageName, // name of the package + modulePath, + blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved + }; + } + const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname); + // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's + // exclusionList anchors every alternation branch with `$`, which would require file paths to + // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file + // instead of end-of-directory, otherwise the blocklist silently matches nothing. + const fixedBlockPattern = new RegExp(blockPattern.source + '.*'); + return { + packageName, // name of the package + modulePath, // actual path to the module in the project's node modules + blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved + }; +}); + +// block the other paths for unique modules from being resolved +const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); + +// provide the path for the unique modules +const extraNodeModules = uniqueModules.reduce((acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; +}, {}); + +config.resolver.blockList = exclusionList(blockList); +config.resolver.extraNodeModules = extraNodeModules; + +config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; + +// add the package dir for metro to access the package folder +config.watchFolders = [packageDirPath]; + +module.exports = config; diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json new file mode 100644 index 0000000000..84093f57a0 --- /dev/null +++ b/examples/E2ETestApp/package.json @@ -0,0 +1,50 @@ +{ + "name": "e2etestapp", + "version": "0.0.1", + "private": true, + "description": "Minimal app driven by the Detox e2e suite (e2e/). Only the SDK and its required peer dependencies.", + "scripts": { + "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh native-package && bash ../../package/scripts/sync-shared-native.sh native-package", + "android": "yarn sync-native && react-native run-android", + "ios": "yarn sync-native && react-native run-ios", + "start": "yarn sync-native && react-native start", + "typecheck": "tsc --noEmit && tsc --noEmit -p e2e", + "e2e:build": "yarn sync-native && detox build", + "e2e:test": "detox test" + }, + "dependencies": { + "@react-native-community/netinfo": "^12.0.1", + "react": "19.3.0", + "react-native": "0.88.0", + "react-native-gesture-handler": "^3.4.0", + "react-native-launch-arguments": "^4.1.1", + "react-native-reanimated": "4.7.1", + "react-native-safe-area-context": "^5.10.1", + "react-native-svg": "^15.15.5", + "react-native-teleport": "^1.2.2", + "react-native-worklets": "^0.13.0", + "stream-chat": "^9.54.2", + "stream-chat-react-native": "workspace:^" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/runtime": "^7.29.2", + "@jest/globals": "^30.4.1", + "@react-native-community/cli": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", + "@react-native/babel-preset": "0.88.0", + "@react-native/metro-config": "0.88.0", + "@react-native/typescript-config": "0.88.0", + "@rnx-kit/metro-config": "^2.2.6", + "@stream-io/typescript-config": "workspace:^", + "@types/react": "^19.3.0", + "babel-jest": "30.0.0", + "detox": "^20.51.4", + "jest": "^30.4.2", + "typescript": "6.0.3" + }, + "engines": { + "node": ">= 22.11.0" + } +} diff --git a/examples/E2ETestApp/src/config.ts b/examples/E2ETestApp/src/config.ts new file mode 100644 index 0000000000..8d10c2d95b --- /dev/null +++ b/examples/E2ETestApp/src/config.ts @@ -0,0 +1,26 @@ +import { LaunchArguments } from 'react-native-launch-arguments'; + +import type { UserResponse } from 'stream-chat'; + +/** + * Launch arguments passed by the Detox suite (`e2e/support/StreamTestCase.ts`). The counterpart of + * `USE_MOCK_SERVER` + `MOCK_SERVER_*` on iOS and the `BASE_URL` intent extra on Android. + */ +type E2ELaunchArguments = { + /** Base URL of the per-test `stream-chat-test-mock-server` instance, e.g. `http://localhost:54321`. */ + MOCK_SERVER_URL?: string; +}; + +export const mockServerUrl = LaunchArguments.value().MOCK_SERVER_URL; + +/** The app user every mock server fixture is recorded for. */ +export const appUser: UserResponse = { + id: 'luke_skywalker', + image: 'https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg', + name: 'Luke Skywalker', +}; + +// The mock server accepts any key and token; these match the native SDKs' e2e apps. +export const apiKey = '8br4watad788'; +export const appUserToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.kFSLHRB5X62t0Zlc7nwczWUfsQMwfkpylC6jCUZ6Mc0'; diff --git a/examples/E2ETestApp/tsconfig.json b/examples/E2ETestApp/tsconfig.json new file mode 100644 index 0000000000..d1be171213 --- /dev/null +++ b/examples/E2ETestApp/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": ["@react-native/typescript-config", "@stream-io/typescript-config/base.json"], + "compilerOptions": { + "types": [] + }, + "exclude": ["node_modules", "e2e"] +} diff --git a/examples/SampleApp/fastlane/Fastfile b/examples/SampleApp/fastlane/Fastfile index f2680950ee..c491a81025 100644 --- a/examples/SampleApp/fastlane/Fastfile +++ b/examples/SampleApp/fastlane/Fastfile @@ -5,6 +5,7 @@ github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-react-native' root_path = File.absolute_path('../../../') sdk_size_ext = 'KB' @force_check = false +@impact = nil build_output_directory = "./app-build" # iOS Platform Configuration @@ -20,6 +21,7 @@ apk_path = "#{build_output_directory}/#{output_apk_name}" before_all do + @impact = impact_analysis if is_ci setup_ci setup_git_config @@ -183,9 +185,109 @@ platform :android do end end -########################## -###### Common lanes ###### -########################## +####################### +###### E2E lanes ###### +####################### + +# The e2e suite drives the dedicated examples/E2ETestApp; its lanes live here with the repo's other lanes. +e2e_app_path = File.absolute_path('../../E2ETestApp') +mock_server_repo_name = 'stream-chat-test-mock-server' +mock_server_driver_port = ENV['MOCK_DRIVER_PORT'] || '4569' + +desc 'Starts the stream-chat-test-mock-server driver. Options: local_server (path to a checkout), branch' +lane :start_mock_server do |options| + Dir.chdir(e2e_app_path) do + mock_server_repo = options[:local_server] || mock_server_repo_name + stop_mock_server unless is_ci + + unless options[:local_server] + branch = options[:branch].to_s.empty? ? 'main' : options[:branch] + sh("rm -rf #{mock_server_repo}") if File.directory?(mock_server_repo) + sh("git clone --depth 1 -b #{branch} https://github.com/#{github_repo.split('/').first}/#{mock_server_repo}.git") + end + + Dir.chdir(mock_server_repo) do + FileUtils.mkdir_p('logs') + # The mock server has its own Gemfile; don't let the SampleApp bundle leak into it. + Bundler.with_unbundled_env do + # CI caches the mock server's gems in MOCK_SERVER_BUNDLE_PATH. + sh("bundle config set --local path '#{ENV['MOCK_SERVER_BUNDLE_PATH']}'") if ENV['MOCK_SERVER_BUNDLE_PATH'] + sh('bundle install') + sh("bundle exec ruby driver.rb #{mock_server_driver_port} > logs/driver.log 2>&1 &") + end + end + + wait_for_mock_server_driver(port: mock_server_driver_port) + end +end + +lane :stop_mock_server do + Net::HTTP.get_response(URI("http://localhost:#{mock_server_driver_port}/stop")) rescue nil +end + +private_lane :wait_for_mock_server_driver do |options| + require 'socket' + ready = false + 60.times do + begin + TCPSocket.new('localhost', options[:port].to_i).close + ready = true + break + rescue Errno::ECONNREFUSED + sleep(1) + end + end + UI.user_error!("Mock server driver did not start on port #{options[:port]}") unless ready +end + +desc 'Writes `required=true|false` to $GITHUB_OUTPUT, so CI can skip the e2e jobs before building anything' +lane :e2e_check_required do + required = is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + File.write(ENV['GITHUB_OUTPUT'], "required=#{required}\n", mode: 'a') if ENV['GITHUB_OUTPUT'] + required +end + +desc 'Installs the E2ETestApp pods with this bundle (the e2e app has no Gemfile of its own)' +lane :e2e_pod_install do + Dir.chdir(e2e_app_path) do + sh('yarn sync-native') + sh('bundle exec pod install --project-directory=ios') + end +end + +# A freshly booted CI emulator is slow enough for the launcher to hit an ANR ("Quickstep isn't +# responding"). The dialog takes window focus, and Espresso then fails every test waiting for it. +private_lane :prepare_android_emulator do + sh('adb shell settings put global hide_error_dialogs 1') + # Gives the post-boot CPU load a minute to settle, dismissing ANR dialogs meanwhile. Capped: the + # load can stay above the threshold for minutes, and each test dismisses ANR dialogs too. + wait_android_emu_idle(timeout: 60) +end + +desc 'Runs the Detox e2e tests. Options: platform (ios|android), configuration (release|debug), ' \ + 'device (android: emu|att), skip_build, test (a test file or -t filter), local_server, mock_server_branch' +lane :test_e2e do |options| + next unless is_check_required(sources: @impact[:e2e][:sources], force_check: @force_check, required_checks: @impact[:e2e][:checks]) + + platform = options[:platform] || 'ios' + device = platform == 'ios' ? 'sim' : (options[:device] || 'emu') + detox_config = "#{platform}.#{device}.#{options[:configuration] || 'release'}" + + start_mock_server(local_server: options[:local_server], branch: options[:mock_server_branch]) + # An attached emulator is already booted; Detox boots `emu` devices itself. + prepare_android_emulator if platform == 'android' && device == 'att' + + begin + Dir.chdir(e2e_app_path) do + sh("yarn e2e:build -c #{detox_config}") unless options[:skip_build] + # On CI: retry flaky tests, run the simulator headless, and keep logs + screenshots of failures in ./artifacts. + ci_flags = is_ci ? '--retries 2 --headless --record-logs failing --take-screenshots failing' : '' + sh("MOCK_DRIVER_PORT=#{mock_server_driver_port} yarn detox test -c #{detox_config} --cleanup #{ci_flags} #{options[:test]}") + end + ensure + stop_mock_server + end +end lane :file_size do |options| File.size(options[:path]) / 1024.0 @@ -213,10 +315,7 @@ lane :frameworks_sizes do end lane :show_frameworks_sizes do |options| - # Besides the SDK itself, changes to how the size is measured (this lane, its plugin, the root - # `minify-*` scripts) re-run the check too. - sources = ['package/', 'package.json', 'examples/SampleApp/fastlane/', 'examples/SampleApp/Gemfile.lock'] - next unless is_check_required(sources: sources, force_check: @force_check) + next unless is_check_required(sources: @impact[:size][:sources], force_check: @force_check) sizes = options[:sizes] || frameworks_sizes show_sdk_size(branch_sizes: sizes, github_repo: github_repo, size_ext: sdk_size_ext) @@ -232,3 +331,15 @@ lane :update_img_shields_sdk_sizes do |options| size_ext: sdk_size_ext ) end + +lane :impact_analysis do + { + e2e: { + sources: ['package/', 'examples/E2ETestApp/', 'examples/SampleApp/fastlane/', 'package.json'], + checks: ['Test E2E Result'] + }, + size: { + sources: ['package/', 'package.json'] + } + } +end diff --git a/package.json b/package.json index 73df92c8a5..ee702cd07b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "package/native-package", "package/expo-package", "examples/SampleApp", + "examples/E2ETestApp", "examples/ExpoMessaging", "examples/ExpoChatGPTSample" ], @@ -71,7 +72,7 @@ "lint-fix": "prettier --write . && eslint . --fix --max-warnings 0", "prettier": "prettier --list-different .", "prettier-fix": "prettier --write .", - "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck", + "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' run typecheck", "build": "yarn workspace stream-chat-react-native-core build", "test:coverage": "yarn workspace stream-chat-react-native-core test:coverage", "test:unit": "yarn workspace stream-chat-react-native-core test:unit", diff --git a/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx b/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx index 30d323c839..e005c42649 100644 --- a/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx +++ b/package/src/components/ChannelPreview/ChannelLastMessagePreview.tsx @@ -29,7 +29,7 @@ export const ChannelLastMessagePreview = ({ message }: ChannelLastMessagePreview width={16} /> ) : null} - + {messagePreviewTitle} diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 96ea6dc072..0a08077eda 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -81,7 +81,11 @@ export const ChannelMessagePreviewDeliveryStatus = ({ } if (!isLastMessageByCurrentUser) { - return {message?.user?.name || message?.user?.id}:; + return ( + + {message?.user?.name || message?.user?.id}: + + ); } return ( @@ -107,7 +111,9 @@ export const ChannelMessagePreviewDeliveryStatus = ({ ) : status === MessageDeliveryStatus.SENT ? ( ) : null} - {t('You')}: + + {t('You')}: + ); diff --git a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx index e91d33b41f..28e1b7982c 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx @@ -21,7 +21,7 @@ export const ChannelPreviewTitle = ({ channel, title }: ChannelPreviewTitleProps const displayName = useChannelPreviewDisplayName(channel); return ( - + {title ?? displayName} ); diff --git a/package/src/components/Message/MessageItemView/MessageStatus.tsx b/package/src/components/Message/MessageItemView/MessageStatus.tsx index db86a63105..b9bb309ea5 100644 --- a/package/src/components/Message/MessageItemView/MessageStatus.tsx +++ b/package/src/components/Message/MessageItemView/MessageStatus.tsx @@ -73,6 +73,7 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => { @@ -80,13 +81,26 @@ const MessageStatusWithContext = (props: MessageStatusPropsWithContext) => { ) : sending ? ( - + ) : sent ? ( - + ) : null} diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx index acf705c17f..7985e04baf 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageStatus.test.tsx @@ -125,4 +125,25 @@ describe('MessageStatus', () => { }); }, ); + + it.each([ + [1, 1, 'sending', 'message-status-sending'], + [2, 2, 'received', 'message-status-read'], + [1, 1, 'received', 'message-status-sent'], + [2, 1, 'received', 'message-status-delivered'], + ] as [number, number, string, string][])( + 'should expose a status testID when deliveredToCount is %s, readBy is %s and status is %s', + async (deliveredToCount, readBy, status, testID) => { + const message = generateMessage({ user: generateUser() }); + const { getByTestId } = renderMessageStatus({ + deliveredToCount, + message: { ...message, status }, + readBy, + }); + // The icon row is hidden from screen readers in favour of the hidden text label. + await waitFor(() => { + expect(getByTestId(testID, { includeHiddenElements: true })).toBeTruthy(); + }); + }, + ); }); diff --git a/yarn.lock b/yarn.lock index 303410fa0c..44741c2181 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1610,6 +1610,20 @@ __metadata: languageName: node linkType: hard +"@colors/colors@npm:1.6.0": + version: 1.6.0 + resolution: "@colors/colors@npm:1.6.0" + checksum: 10c0/9328a0778a5b0db243af54455b79a69e3fb21122d6c15ef9e9fcc94881d8d17352d8b2b2590f9bdd46fac5c2d6c1636dcfc14358a20c70e22daf89e1a759b629 + languageName: node + linkType: hard + +"@colors/colors@npm:^1.6.0": + version: 1.6.1 + resolution: "@colors/colors@npm:1.6.1" + checksum: 10c0/8fb995f2421210284305fd07b4b70e9873a417cda8b0d66ba3f7beccd2e0f4ebbe0b2ba3afbef8f07740ad707c379d94e836cffd6a8df394387e210b4338b4c9 + languageName: node + linkType: hard + "@commitlint/cli@npm:^21.0.0": version: 21.0.0 resolution: "@commitlint/cli@npm:21.0.0" @@ -1839,6 +1853,17 @@ __metadata: languageName: node linkType: hard +"@dabh/diagnostics@npm:^2.0.8": + version: 2.0.9 + resolution: "@dabh/diagnostics@npm:2.0.9" + dependencies: + "@so-ric/colorspace": "npm:^1.1.6" + enabled: "npm:2.0.x" + kuler: "npm:^2.0.0" + checksum: 10c0/3d341a52fbf2277883c4aa1153ce092932d9ecad79a54749553206b83e568cd563f66c6e1cc1416a403bd5662335193a19e8bcc1082f047f28b6402eeb16c01d + languageName: node + linkType: hard + "@emnapi/core@npm:^1.4.3": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -3060,6 +3085,13 @@ __metadata: languageName: node linkType: hard +"@flatten-js/interval-tree@npm:^1.1.2": + version: 1.1.4 + resolution: "@flatten-js/interval-tree@npm:1.1.4" + checksum: 10c0/e4055e99b1b5958e0a1adea7413d9602f3d7ebed0dd47fcac1ad995a97151e02187fe1aa778c14be56c7faa3a29b2a71ece84be43312eff6850ebf316b3490b3 + languageName: node + linkType: hard + "@gorhom/bottom-sheet@npm:^5.2.14": version: 5.2.14 resolution: "@gorhom/bottom-sheet@npm:5.2.14" @@ -3544,6 +3576,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/diff-sequences@npm:30.5.0" + checksum: 10c0/a8d4fef9b266cb025b2f1c1be9ae2e807077def93f36e34a628aff383910fa7a26ba0921a43744841231ead54ea87469fb2acf89de47c12ad4fead802e0ccfc3 + languageName: node + linkType: hard + "@jest/environment@npm:30.4.1": version: 30.4.1 resolution: "@jest/environment@npm:30.4.1" @@ -3556,6 +3595,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/environment@npm:30.5.2" + dependencies: + "@jest/fake-timers": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + jest-mock: "npm:30.5.2" + checksum: 10c0/f060f2d94dfccd7de4890a419e803735921d8358a3ecc8e424e5f6a632da80fc37372f6e54f1397876b721cec5bae2da515e6dce2ac6558bd90f0c1cbeb5c105 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -3577,6 +3628,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/expect-utils@npm:30.5.2" + dependencies: + "@jest/get-type": "npm:30.5.0" + checksum: 10c0/0db00595907f6705c5acc8d10ee9d11d74960cd459beaae66bd7a1d6845c7df1d123295e957bb56b6882b380f2e7d55d277ced7646e0229fc4a67abeb713c048 + languageName: node + linkType: hard + "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -3587,6 +3647,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/expect@npm:30.5.2" + dependencies: + expect: "npm:30.5.2" + jest-snapshot: "npm:30.5.2" + checksum: 10c0/ceebb291be34707e3d2ac99bc5e4a5039927e01d28650b1dc51888dfa3963a3a75a3c72f0abe4df9aa243dc3b31af72e467072d75b52bd11b80a84ebe765d8f9 + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -3601,6 +3671,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/fake-timers@npm:30.5.2" + dependencies: + "@jest/types": "npm:30.5.1" + "@sinonjs/fake-timers": "npm:^15.4.0" + "@types/node": "npm:*" + jest-message-util: "npm:30.5.1" + jest-mock: "npm:30.5.2" + jest-util: "npm:30.5.1" + checksum: 10c0/c8ff08f541743210abaf07b75bfe6224458c816a500d5be954690fd1661cd14b054201dfe2a6ebdc0083290d4c03d73bbac4f3c1fbef5c3b16d1981642dfff57 + languageName: node + linkType: hard + "@jest/fake-timers@npm:^29.7.0": version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" @@ -3622,6 +3706,13 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/get-type@npm:30.5.0" + checksum: 10c0/820a2af9902522d169ae4ede17a99249baad59ff6aead02f8100acbc937b082090b04d5f32ec0d9938491cf89c9fc99930ed469202700c110c0239c7f7ac67a2 + languageName: node + linkType: hard + "@jest/globals@npm:30.4.1": version: 30.4.1 resolution: "@jest/globals@npm:30.4.1" @@ -3634,6 +3725,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:^30.4.1": + version: 30.5.2 + resolution: "@jest/globals@npm:30.5.2" + dependencies: + "@jest/environment": "npm:30.5.2" + "@jest/expect": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + jest-mock: "npm:30.5.2" + checksum: 10c0/e60738080d4866350b2152d98ddf99728cc1ac19983b80bea5d5ab219f82ef271fadd102b0cee2c01808367d6e054bea1c4ec4aa7c69e45b4e14b79a30f76538 + languageName: node + linkType: hard + "@jest/pattern@npm:30.0.0": version: 30.0.0 resolution: "@jest/pattern@npm:30.0.0" @@ -3654,6 +3757,30 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/pattern@npm:30.5.0" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.5.0" + checksum: 10c0/3e960b25c7a32fee97a2cdf9ea810ada41f676c84db3c1afab138ee089e0e1cf4849372dd576acb18ac5219309695991c38e23ddacd9b95802ee3f76b4caaea4 + languageName: node + linkType: hard + +"@jest/react-is-18@npm:react-is@^18.3.1, react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + +"@jest/react-is-19@npm:react-is@^19.2.5": + version: 19.3.1 + resolution: "react-is@npm:19.3.1" + checksum: 10c0/428e02d986d56f1859c57756084489f6d2900fd686905003c405b32f1168738b78772bfa4bb6c706f5ac821199171522c28bf6434281f524f901459390a19606 + languageName: node + linkType: hard + "@jest/reporters@npm:30.4.1": version: 30.4.1 resolution: "@jest/reporters@npm:30.4.1" @@ -3708,6 +3835,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/schemas@npm:30.5.0" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10c0/632c45a06a0984051c908fa9b3c9445bac7be8b7a6d1cc7ce2ecae7d4e44c612509ad41fca16449607c4ea49a333e0d3f10bb1510bafb4b6d9051ca15d9b4b14 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -3729,6 +3865,18 @@ __metadata: languageName: node linkType: hard +"@jest/snapshot-utils@npm:30.5.1": + version: 30.5.1 + resolution: "@jest/snapshot-utils@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + natural-compare: "npm:^1.4.0" + checksum: 10c0/0e31aa45c4be7b6e7dabcccc7316458f53bda262ab42f23b749430110d0ac908d4905db993708723a4f6457bc9208c636fd7b886e3ffc26974d55126cf597d6e + languageName: node + linkType: hard + "@jest/source-map@npm:30.0.1": version: 30.0.1 resolution: "@jest/source-map@npm:30.0.1" @@ -3809,6 +3957,28 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/transform@npm:30.5.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/types": "npm:30.5.1" + "@jridgewell/trace-mapping": "npm:^0.3.31" + babel-plugin-istanbul: "npm:^8.0.0" + chalk: "npm:^4.1.2" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.5.1" + jest-regex-util: "npm:30.5.0" + jest-util: "npm:30.5.1" + pirates: "npm:^4.0.7" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^5.0.1" + checksum: 10c0/be3f9aa812a442402ed41a37d0f11d174a0a9037038dc1326c32d1d55e22fc7bc5f5146284c5a18db3da572afb81739f0a93d3f1d519e811d25dacaeb380c647 + languageName: node + linkType: hard + "@jest/transform@npm:^29.7.0": version: 29.7.0 resolution: "@jest/transform@npm:29.7.0" @@ -3862,6 +4032,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.5.1": + version: 30.5.1 + resolution: "@jest/types@npm:30.5.1" + dependencies: + "@jest/pattern": "npm:30.5.0" + "@jest/schemas": "npm:30.5.0" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10c0/da9e46449371ddd43b89627c93fb7088fdad01e9f57dce376c65914a6f4617a558f54ec51a917ca10c9b9794a1e76c3cb450308eca303f830bb21e9529b1719a + languageName: node + linkType: hard + "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -4399,6 +4584,140 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-android-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-android-arm64@npm:2.6.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-arm64@npm:2.6.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-x64@npm:2.6.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-freebsd-x64@npm:2.6.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-arm64@npm:2.6.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-x64@npm:2.6.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher@npm:2.6.0" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-x64": "npm:2.6.0" + "@parcel/watcher-freebsd-x64": "npm:2.6.0" + "@parcel/watcher-linux-arm-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm-musl": "npm:2.6.0" + "@parcel/watcher-linux-arm64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm64-musl": "npm:2.6.0" + "@parcel/watcher-linux-x64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-x64-musl": "npm:2.6.0" + "@parcel/watcher-win32-arm64": "npm:2.6.0" + "@parcel/watcher-win32-x64": "npm:2.6.0" + detect-libc: "npm:^2.0.3" + is-glob: "npm:^4.0.3" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + picomatch: "npm:^4.0.4" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10c0/0d6799bdf7d59a1ab5e4a79859035f387a66078586d9543eef0b9bd01c9d61cebe3b64eb96f0bd1f8bf50f07ed7266a1628604b66dc63025a3cee19be2e60d65 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -6056,6 +6375,16 @@ __metadata: languageName: node linkType: hard +"@so-ric/colorspace@npm:^1.1.6": + version: 1.1.6 + resolution: "@so-ric/colorspace@npm:1.1.6" + dependencies: + color: "npm:^5.0.2" + text-hex: "npm:1.0.x" + checksum: 10c0/f3ad26afefbb8d6101ea7c385cd5f402d4291c2ffc9cabe37030d5fdb8bda980ee534a0d7c250f8233fc3a59b99272410177cd98b219f6b3770f91a0fdb6eb3e + languageName: node + linkType: hard + "@stream-io/state-store@npm:^1.1.6": version: 1.1.6 resolution: "@stream-io/state-store@npm:1.1.6" @@ -6569,6 +6898,13 @@ __metadata: languageName: node linkType: hard +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 10c0/d5d7f25da612f6d79266f4f1bb9c1ef8f1684e9f60abab251e1261170631062b656ba26ff22631f2760caeafd372abc41e64867cde27fba54fafb73a35b9056a + languageName: node + linkType: hard + "@types/ungap__structured-clone@npm:^1.2.0": version: 1.2.0 resolution: "@types/ungap__structured-clone@npm:1.2.0" @@ -6920,6 +7256,33 @@ __metadata: languageName: node linkType: hard +"@wix-pilot/core@npm:^3.4.2": + version: 3.4.2 + resolution: "@wix-pilot/core@npm:3.4.2" + dependencies: + chalk: "npm:^4.1.0" + pngjs: "npm:^7.0.0" + winston: "npm:^3.17.0" + peerDependencies: + expect: "*" + peerDependenciesMeta: + expect: + optional: true + checksum: 10c0/606dcd0951cfdfb6bda69718512e6daedda22b15dcc4b2029739e3d02b2d676b30d5d97a6d6558d12a8cfae332b6beb48e32ac620eae4ca5eefa43543dd9e14a + languageName: node + linkType: hard + +"@wix-pilot/detox@npm:^1.0.13": + version: 1.0.13 + resolution: "@wix-pilot/detox@npm:1.0.13" + peerDependencies: + "@wix-pilot/core": ^3.4.1 + detox: ">=20.33.0" + expect: 29.x.x || 28.x.x || ^27.2.5 + checksum: 10c0/210b1834ce628cfacdc7417ed8596455045bd6c87bc4ef01b99558ddeeab00648d8ed73abdd212f7cb3d96110b0d36bf8812fd31e1d61a4d3b8f7cad41ab69bf + languageName: node + linkType: hard + "@xmldom/xmldom@npm:^0.8.8": version: 0.8.13 resolution: "@xmldom/xmldom@npm:0.8.13" @@ -7090,7 +7453,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.11.0": +"ajv@npm:^8.11.0, ajv@npm:^8.6.3": version: 8.20.0 resolution: "ajv@npm:8.20.0" dependencies: @@ -7442,6 +7805,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.3": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10c0/36484bb15ceddf07078688d95e27076379cc2f87b10c03b6dd8a83e89475a3c8df5848859dd06a4c95af1e4c16fc973de0171a77f18ea00be899aca2a4f85e70 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -7565,6 +7935,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-istanbul@npm:^8.0.0": + version: 8.0.2 + resolution: "babel-plugin-istanbul@npm:8.0.2" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-instrument: "npm:^6.0.2" + test-exclude: "npm:^7.0.1" + checksum: 10c0/795577336196114ea19d2cd9efae41cde66b08bc8b7a326ed35bfe7da85b5756ebe19d9fcf3c98d3bd0e194d1258229178414a79fe3a623a66a449e468cf7af5 + languageName: node + linkType: hard + "babel-plugin-jest-hoist@npm:30.0.0": version: 30.0.0 resolution: "babel-plugin-jest-hoist@npm:30.0.0" @@ -7926,6 +8309,13 @@ __metadata: languageName: node linkType: hard +"bluebird@npm:^3.5.4": + version: 3.7.2 + resolution: "bluebird@npm:3.7.2" + checksum: 10c0/680de03adc54ff925eaa6c7bb9a47a0690e8b5de60f4792604aae8ed618c65e6b63a7893b57ca924beaf53eee69c5af4f8314148c08124c550fe1df1add897d2 + languageName: node + linkType: hard + "body-parser@npm:^2.2.2": version: 2.3.0 resolution: "body-parser@npm:2.3.0" @@ -8021,6 +8411,13 @@ __metadata: languageName: node linkType: hard +"browser-process-hrtime@npm:^1.0.0": + version: 1.0.0 + resolution: "browser-process-hrtime@npm:1.0.0" + checksum: 10c0/65da78e51e9d7fa5909147f269c54c65ae2e03d1cf797cc3cfbbe49f475578b8160ce4a76c36c1a2ffbff26c74f937d73096c508057491ddf1a6dfd11143f72d + languageName: node + linkType: hard + "browserslist@npm:^4.20.4, browserslist@npm:^4.24.0, browserslist@npm:^4.25.0, browserslist@npm:^4.28.1": version: 4.28.2 resolution: "browserslist@npm:4.28.2" @@ -8069,6 +8466,87 @@ __metadata: languageName: node linkType: hard +"bunyamin@npm:^1.5.2": + version: 1.6.3 + resolution: "bunyamin@npm:1.6.3" + dependencies: + "@flatten-js/interval-tree": "npm:^1.1.2" + multi-sort-stream: "npm:^1.0.4" + stream-json: "npm:^1.7.5" + trace-event-lib: "npm:^1.3.1" + peerDependencies: + "@types/bunyan": ^1.8.8 + bunyan: ^1.8.15 || ^2.0.0 + peerDependenciesMeta: + "@types/bunyan": + optional: true + bunyan: + optional: true + checksum: 10c0/150a93cbf0ea83ff38f73de42742fc162e24d87551d9b5454769a4c2966b900a4e2a63dbd3d1f616a1178e65f0966cd76b2a48981764de7ae93b55de79410c83 + languageName: node + linkType: hard + +"bunyan-debug-stream@npm:^3.1.0": + version: 3.1.1 + resolution: "bunyan-debug-stream@npm:3.1.1" + dependencies: + chalk: "npm:^4.1.2" + peerDependencies: + bunyan: "*" + peerDependenciesMeta: + bunyan: + optional: true + checksum: 10c0/3682454c08910ffbe4b9d9c3e4ba97d36ca5d78e9bac5d71f639c12e35cfa921df76dd736d08c061c673c5cb232fcc3ecb05cbcc4868cd60c816edc4e6f06028 + languageName: node + linkType: hard + +"bunyan@npm:^1.8.12": + version: 1.8.15 + resolution: "bunyan@npm:1.8.15" + dependencies: + dtrace-provider: "npm:~0.8" + moment: "npm:^2.19.3" + mv: "npm:~2" + safe-json-stringify: "npm:~1" + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: 10c0/c7b3adc07a4db3256f857dcba42b97dd6c35ab054cb26766643aae2b90e1b614795cdf231774ddaf374572d952f52ef4f4205047e15414e155e478aa0672e041 + languageName: node + linkType: hard + +"bunyan@npm:^2.0.5": + version: 2.0.5 + resolution: "bunyan@npm:2.0.5" + dependencies: + dtrace-provider: "npm:~0.8" + exeunt: "npm:1.1.0" + moment: "npm:^2.19.3" + mv: "npm:~2" + safe-json-stringify: "npm:~1" + dependenciesMeta: + dtrace-provider: + optional: true + moment: + optional: true + mv: + optional: true + safe-json-stringify: + optional: true + bin: + bunyan: bin/bunyan + checksum: 10c0/b3a2f0ccc4473c7a83d94f64805dd4ae70e4f7afff8658262a782a8a41ad71e062749e67ec7be57ab3ae724d3d199247ef4b6976ac6c0c67a114f442224977aa + languageName: node + linkType: hard + "bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" @@ -8095,6 +8573,13 @@ __metadata: languageName: node linkType: hard +"caf@npm:^15.0.1": + version: 15.0.1 + resolution: "caf@npm:15.0.1" + checksum: 10c0/9a852c3c93c44499ab1e2cc58c264dd99bb2eaeb17b46df84e6fa5302d6829f776e84a1c13da5bbc2830ba274087eddbde9fe23334f7eb00ce89da9333d1a5cc + languageName: node + linkType: hard + "call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" @@ -8166,7 +8651,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": +"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710 @@ -8533,6 +9018,15 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^3.1.3": + version: 3.1.3 + resolution: "color-convert@npm:3.1.3" + dependencies: + color-name: "npm:^2.0.0" + checksum: 10c0/427648b442c6ea6dab5ba03f4962201ee59f128c80b25d5a0f7d9aab0ef52519a9db8a9bb3cf40b73f86eb19b5ca6aeb0ab930665f3d14973ce776d7d0448a15 + languageName: node + linkType: hard + "color-name@npm:1.1.3": version: 1.1.3 resolution: "color-name@npm:1.1.3" @@ -8547,6 +9041,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:^2.0.0": + version: 2.1.1 + resolution: "color-name@npm:2.1.1" + checksum: 10c0/6a1096d761b84ad18bdf1464d449800dbb1e613c914aafd9551514884f5e0cc47b5478fea32048188ec7a28424c6ded55fd4a81f39ca351e2ca4dd7729e05128 + languageName: node + linkType: hard + "color-string@npm:^1.9.0": version: 1.9.1 resolution: "color-string@npm:1.9.1" @@ -8557,6 +9058,15 @@ __metadata: languageName: node linkType: hard +"color-string@npm:^2.1.3": + version: 2.1.4 + resolution: "color-string@npm:2.1.4" + dependencies: + color-name: "npm:^2.0.0" + checksum: 10c0/18a9fefec153d885e0dbfb076f3a65cdcd19f52d96c719f2f261e90e5b7dafd13c51baac399d7099eac290f004d340045ab9467312dcc8afefe6f877ec5c4428 + languageName: node + linkType: hard + "color@npm:^4.2.3": version: 4.2.3 resolution: "color@npm:4.2.3" @@ -8567,6 +9077,16 @@ __metadata: languageName: node linkType: hard +"color@npm:^5.0.2": + version: 5.0.3 + resolution: "color@npm:5.0.3" + dependencies: + color-convert: "npm:^3.1.3" + color-string: "npm:^2.1.3" + checksum: 10c0/f08a03c5113ae4aa36dba9d2438596b194b897e18b961310643cb63872add1da507cd238df264eb434bbdbe3a377ec41f90d877531acca611523cfcd365db1b6 + languageName: node + linkType: hard + "colorette@npm:^1.0.7": version: 1.4.0 resolution: "colorette@npm:1.4.0" @@ -9155,6 +9675,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^4.0.0": + version: 4.0.0 + resolution: "decamelize@npm:4.0.0" + checksum: 10c0/e06da03fc05333e8cd2778c1487da67ffbea5b84e03ca80449519b8fa61f888714bbc6f459ea963d5641b4aa98832130eb5cd193d90ae9f0a27eee14be8e278d + languageName: node + linkType: hard + "decode-named-character-reference@npm:^1.0.0": version: 1.3.0 resolution: "decode-named-character-reference@npm:1.3.0" @@ -9309,6 +9836,58 @@ __metadata: languageName: node linkType: hard +"detox@npm:^20.51.4": + version: 20.51.4 + resolution: "detox@npm:20.51.4" + dependencies: + "@wix-pilot/core": "npm:^3.4.2" + "@wix-pilot/detox": "npm:^1.0.13" + ajv: "npm:^8.6.3" + bunyan: "npm:^1.8.12" + bunyan-debug-stream: "npm:^3.1.0" + caf: "npm:^15.0.1" + chalk: "npm:^4.0.0" + execa: "npm:^5.1.1" + find-up: "npm:^5.0.0" + fs-extra: "npm:^11.0.0" + funpermaproxy: "npm:^1.1.0" + glob: "npm:^8.0.3" + ini: "npm:^1.3.4" + jest-environment-emit: "npm:^1.2.0" + json-cycle: "npm:^1.3.0" + lodash: "npm:^4.17.11" + multi-sort-stream: "npm:^1.0.3" + multipipe: "npm:^4.0.0" + node-ipc: "npm:9.2.1" + promisify-child-process: "npm:^4.1.2" + proper-lockfile: "npm:^3.0.2" + resolve-from: "npm:^5.0.0" + sanitize-filename: "npm:^1.6.1" + semver: "npm:^7.0.0" + serialize-error: "npm:^8.0.1" + shell-quote: "npm:^1.7.2" + signal-exit: "npm:^3.0.3" + stream-json: "npm:^1.7.4" + strip-ansi: "npm:^6.0.1" + telnet-client: "npm:1.2.8" + tmp: "npm:^0.2.1" + trace-event-lib: "npm:^1.3.1" + which: "npm:^1.3.1" + ws: "npm:^7.0.0" + yargs: "npm:^17.0.0" + yargs-parser: "npm:^21.0.0" + yargs-unparser: "npm:^2.0.0" + peerDependencies: + jest: 30.x.x || 29.x.x || 28.x.x || ^27.2.5 + peerDependenciesMeta: + jest: + optional: true + bin: + detox: local-cli/cli.js + checksum: 10c0/4be674954c6ca60d8efb5d396848eefd5241018c78f64b503a462b0a66b9e7805d0af984318546ee8810063a95f9d92892d3e1e3d9b589ce703d2210da80fb16 + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -9402,6 +9981,16 @@ __metadata: languageName: node linkType: hard +"dtrace-provider@npm:~0.8": + version: 0.8.8 + resolution: "dtrace-provider@npm:0.8.8" + dependencies: + nan: "npm:^2.14.0" + node-gyp: "npm:latest" + checksum: 10c0/33bfc18462dd59ae1de094c64b7b093d2f7f67dec48f138df3a7507c09aaed2a964a245e7bdf2bde7d1a6cc467b11d7396e0fb13a6b882642d42a44cc08c61da + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -9413,7 +10002,7 @@ __metadata: languageName: node linkType: hard -"duplexer2@npm:~0.1.0": +"duplexer2@npm:^0.1.2, duplexer2@npm:~0.1.0": version: 0.1.4 resolution: "duplexer2@npm:0.1.4" dependencies: @@ -9422,6 +10011,41 @@ __metadata: languageName: node linkType: hard +"e2etestapp@workspace:examples/E2ETestApp": + version: 0.0.0-use.local + resolution: "e2etestapp@workspace:examples/E2ETestApp" + dependencies: + "@babel/core": "npm:^7.29.0" + "@babel/runtime": "npm:^7.29.2" + "@jest/globals": "npm:^30.4.1" + "@react-native-community/cli": "npm:20.2.0" + "@react-native-community/cli-platform-android": "npm:20.2.0" + "@react-native-community/cli-platform-ios": "npm:20.2.0" + "@react-native-community/netinfo": "npm:^12.0.1" + "@react-native/babel-preset": "npm:0.88.0" + "@react-native/metro-config": "npm:0.88.0" + "@react-native/typescript-config": "npm:0.88.0" + "@rnx-kit/metro-config": "npm:^2.2.6" + "@stream-io/typescript-config": "workspace:^" + "@types/react": "npm:^19.3.0" + babel-jest: "npm:30.0.0" + detox: "npm:^20.51.4" + jest: "npm:^30.4.2" + react: "npm:19.3.0" + react-native: "npm:0.88.0" + react-native-gesture-handler: "npm:^3.4.0" + react-native-launch-arguments: "npm:^4.1.1" + react-native-reanimated: "npm:4.7.1" + react-native-safe-area-context: "npm:^5.10.1" + react-native-svg: "npm:^15.15.5" + react-native-teleport: "npm:^1.2.2" + react-native-worklets: "npm:^0.13.0" + stream-chat: "npm:^9.54.2" + stream-chat-react-native: "workspace:^" + typescript: "npm:6.0.3" + languageName: unknown + linkType: soft + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -9429,6 +10053,13 @@ __metadata: languageName: node linkType: hard +"easy-stack@npm:^1.0.1": + version: 1.0.1 + resolution: "easy-stack@npm:1.0.1" + checksum: 10c0/1eaf066169a20f6cc3cafd2bb36b00baacd60b6414c8d8bf51bfd50bc6f1c487140c8af86bbb8e1ff9ded2faea5e138c55a37867fc79cbbc985bf5a5ebe4b109 + languageName: node + linkType: hard + "ecdsa-sig-formatter@npm:1.0.11": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" @@ -9494,6 +10125,13 @@ __metadata: languageName: node linkType: hard +"enabled@npm:2.0.x": + version: 2.0.0 + resolution: "enabled@npm:2.0.0" + checksum: 10c0/3b2c2af9bc7f8b9e291610f2dde4a75cf6ee52a68f4dd585482fbdf9a55d65388940e024e56d40bb03e05ef6671f5f53021fa8b72a20e954d7066ec28166713f + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -10189,6 +10827,13 @@ __metadata: languageName: node linkType: hard +"event-pubsub@npm:4.3.0": + version: 4.3.0 + resolution: "event-pubsub@npm:4.3.0" + checksum: 10c0/47fa4fb5b55b3ed08b912862cc913e03603fa063cd3ec5cf3dfeb39a19314d3ca327e938a2cf70685254ab3a71af8178969963c705a030c6081d625bec835114 + languageName: node + linkType: hard + "execa@npm:^4.0.3": version: 4.1.0 resolution: "execa@npm:4.1.0" @@ -10260,6 +10905,13 @@ __metadata: languageName: node linkType: hard +"exeunt@npm:1.1.0": + version: 1.1.0 + resolution: "exeunt@npm:1.1.0" + checksum: 10c0/dc2012d9016c01ec321e4d642d2f165ce7dc4a4d141c523a818416035bcfe11bd1d9abc142f6f608d6a7719824295131a1b8c89006eb308aafd3aba385bf40d2 + languageName: node + linkType: hard + "exit-x@npm:^0.2.2": version: 0.2.2 resolution: "exit-x@npm:0.2.2" @@ -10288,6 +10940,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:30.5.2": + version: 30.5.2 + resolution: "expect@npm:30.5.2" + dependencies: + "@jest/expect-utils": "npm:30.5.2" + "@jest/get-type": "npm:30.5.0" + jest-matcher-utils: "npm:30.5.2" + jest-message-util: "npm:30.5.1" + jest-mock: "npm:30.5.2" + jest-util: "npm:30.5.1" + checksum: 10c0/31bf2307499008f267c84992a8ea27f76e97f207f685075439aa12f60b93dd81d024e5fcb0142755ed499233056f27fa51d01843d04cba437dfe7588387c352f + languageName: node + linkType: hard + "expo-asset@npm:~58.0.14": version: 58.0.14 resolution: "expo-asset@npm:58.0.14" @@ -10982,6 +11648,13 @@ __metadata: languageName: node linkType: hard +"fecha@npm:^4.2.0": + version: 4.2.3 + resolution: "fecha@npm:4.2.3" + checksum: 10c0/0e895965959cf6a22bb7b00f0bf546f2783836310f510ddf63f463e1518d4c96dec61ab33fdfd8e79a71b4856a7c865478ce2ee8498d560fe125947703c9b1cf + languageName: node + linkType: hard + "fetch-nodeshim@npm:^0.4.10": version: 0.4.10 resolution: "fetch-nodeshim@npm:0.4.10" @@ -11157,6 +11830,15 @@ __metadata: languageName: node linkType: hard +"flat@npm:^5.0.2": + version: 5.0.2 + resolution: "flat@npm:5.0.2" + bin: + flat: cli.js + checksum: 10c0/f178b13482f0cd80c7fede05f4d10585b1f2fdebf26e12edc138e32d3150c6ea6482b7f12813a1091143bad52bb6d3596bca51a162257a21163c0ff438baa5fe + languageName: node + linkType: hard + "flatted@npm:^3.2.9": version: 3.4.2 resolution: "flatted@npm:3.4.2" @@ -11203,6 +11885,13 @@ __metadata: languageName: node linkType: hard +"fn.name@npm:1.x.x": + version: 1.1.0 + resolution: "fn.name@npm:1.1.0" + checksum: 10c0/8ad62aa2d4f0b2a76d09dba36cfec61c540c13a0fd72e5d94164e430f987a7ce6a743112bbeb14877c810ef500d1f73d7f56e76d029d2e3413f20d79e3460a9a + languageName: node + linkType: hard + "follow-redirects@npm:^1.16.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" @@ -11379,6 +12068,13 @@ __metadata: languageName: node linkType: hard +"funpermaproxy@npm:^1.1.0": + version: 1.1.0 + resolution: "funpermaproxy@npm:1.1.0" + checksum: 10c0/058d5f56499dac0a275d7115c74230e339f7481618b96c21e8cafa8260a6db7c16f1e4127f5be2aeb167876ff5f33846503a07148279b2f68ca45d04771d666e + languageName: node + linkType: hard + "generator-function@npm:^2.0.0": version: 2.0.1 resolution: "generator-function@npm:2.0.1" @@ -11554,7 +12250,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.5.0": +"glob@npm:^10.4.1, glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -11581,6 +12277,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:^6.0.1": + version: 6.0.4 + resolution: "glob@npm:6.0.4" + dependencies: + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:2 || 3" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/520146ebce0f4594b8357338f86281b38ee14214debce398a2902176a28f18e0f98911ea48516d85022de64fbbaa57f074aa13715d1daa5d70e21b82cea22183 + languageName: node + linkType: hard + "glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -11681,7 +12390,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.3, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -13176,6 +13885,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.5.2": + version: 30.5.2 + resolution: "jest-diff@npm:30.5.2" + dependencies: + "@jest/diff-sequences": "npm:30.5.0" + "@jest/get-type": "npm:30.5.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.5.1" + checksum: 10c0/b11524e4b7a0c7b83c5b6a3775f705b1002bf086c211e9bd2e481686e3fa0ee1a87e61fa7453232aef2808f085f254f4f8fcbb41b69681d1b9384ec321cf6002 + languageName: node + linkType: hard + "jest-diff@npm:^29.0.1, jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -13210,6 +13931,39 @@ __metadata: languageName: node linkType: hard +"jest-environment-emit@npm:^1.2.0": + version: 1.2.0 + resolution: "jest-environment-emit@npm:1.2.0" + dependencies: + bunyamin: "npm:^1.5.2" + bunyan: "npm:^2.0.5" + bunyan-debug-stream: "npm:^3.1.0" + funpermaproxy: "npm:^1.1.0" + lodash.merge: "npm:^4.6.2" + node-ipc: "npm:9.2.1" + strip-ansi: "npm:^6.0.0" + tslib: "npm:^2.5.3" + peerDependencies: + "@jest/environment": ">=27.2.5" + "@jest/types": ">=27.2.5" + jest: ">=27.2.5" + jest-environment-jsdom: ">=27.2.5" + jest-environment-node: ">=27.2.5" + peerDependenciesMeta: + "@jest/environment": + optional: true + "@jest/types": + optional: true + jest: + optional: true + jest-environment-jsdom: + optional: true + jest-environment-node: + optional: true + checksum: 10c0/4e1230b447b54c54eecbc6e52782e2133a07b0556a43d34b9f7733b4be8d3a390be7a444863abed31cef638a259815f65b41b588dabc9f9ee478af9ea0ab419b + languageName: node + linkType: hard + "jest-environment-node@npm:30.4.1": version: 30.4.1 resolution: "jest-environment-node@npm:30.4.1" @@ -13290,6 +14044,25 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:30.5.1": + version: 30.5.1 + resolution: "jest-haste-map@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" + "@parcel/watcher": "npm:^2.6.0" + "@types/node": "npm:*" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fdir: "npm:^6.5.0" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.5.0" + jest-util: "npm:30.5.1" + jest-worker: "npm:30.5.1" + picomatch: "npm:^4.0.3" + checksum: 10c0/a9838762f84c54b1d551650e3c956f8d72370a15b2e9291d4b0c9aab0b4d4f3d1758bbc619ffb028af567d5dc53a73c5e72af078c56c5fa47cd73a2a309ca89a + languageName: node + linkType: hard + "jest-haste-map@npm:^29.7.0": version: 29.7.0 resolution: "jest-haste-map@npm:29.7.0" @@ -13335,6 +14108,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:30.5.2": + version: 30.5.2 + resolution: "jest-matcher-utils@npm:30.5.2" + dependencies: + "@jest/get-type": "npm:30.5.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.5.2" + pretty-format: "npm:30.5.1" + checksum: 10c0/30f075c6f897335415817ad25e6caae8994de87c31ad59ae8f501e2c8bf6c925635abb481c562436b6add42a784902f105ee2fa0eca3357bd26e038f7e004cb8 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.0.1, jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -13365,6 +14150,24 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.5.1": + version: 30.5.1 + resolution: "jest-message-util@npm:30.5.1" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.5.1" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-util: "npm:30.5.1" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.5.1" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10c0/f542d07f8ceba19da26c547aa86abc51e6fad518999a18853e7c2acc2a55845ad759aa94cbe650d0453b036b81fcafee710d467b55e1e509f9939db033e76919 + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -13393,6 +14196,18 @@ __metadata: languageName: node linkType: hard +"jest-mock@npm:30.5.2": + version: 30.5.2 + resolution: "jest-mock@npm:30.5.2" + dependencies: + "@jest/expect-utils": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + jest-util: "npm:30.5.1" + checksum: 10c0/24cc9ac6449c536ef5009c531e14ee6b145f5d68db6e4c5616a8f775c5c81173e83e2c6c47922d413da4dbf804eab555b91ab424b0bf918495ee1b29d8a8ebaa + languageName: node + linkType: hard + "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -13430,6 +14245,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.5.0": + version: 30.5.0 + resolution: "jest-regex-util@npm:30.5.0" + checksum: 10c0/f0e5ef257b906862821e1fb58c1d3d7bb3c4fc1fb11915673f04d3693673e0656b8f502c9871900e13e58329039c73e5b5c59a2622cbd6ba5c7fc7463b60ce48 + languageName: node + linkType: hard + "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -13552,6 +14374,35 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:30.5.2": + version: 30.5.2 + resolution: "jest-snapshot@npm:30.5.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/types": "npm:^7.27.3" + "@jest/expect-utils": "npm:30.5.2" + "@jest/get-type": "npm:30.5.0" + "@jest/snapshot-utils": "npm:30.5.1" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + babel-preset-current-node-syntax: "npm:^1.2.0" + chalk: "npm:^4.1.2" + expect: "npm:30.5.2" + graceful-fs: "npm:^4.2.11" + jest-diff: "npm:30.5.2" + jest-matcher-utils: "npm:30.5.2" + jest-message-util: "npm:30.5.1" + jest-util: "npm:30.5.1" + pretty-format: "npm:30.5.1" + semver: "npm:^7.7.2" + synckit: "npm:^0.11.8" + checksum: 10c0/cef6b102f743cbefd5d8ad0cf15b5694c09522904c43ace56537990475805b7c6eaebf82e6c5564e225e8db7617ebdda77340acc9a47d9909f338fbb84fca345 + languageName: node + linkType: hard + "jest-util@npm:30.0.0": version: 30.0.0 resolution: "jest-util@npm:30.0.0" @@ -13570,13 +14421,27 @@ __metadata: version: 30.4.1 resolution: "jest-util@npm:30.4.1" dependencies: - "@jest/types": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e + languageName: node + linkType: hard + +"jest-util@npm:30.5.1": + version: 30.5.1 + resolution: "jest-util@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" "@types/node": "npm:*" chalk: "npm:^4.1.2" ci-info: "npm:^4.2.0" graceful-fs: "npm:^4.2.11" picomatch: "npm:^4.0.3" - checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e + checksum: 10c0/5f3b250f1ada29b877cab75db5808f1318af99ba7f68032e0966ddb6d853186ad2dbaeac679842684c4676dfd35083470f2d26eee65a28161fcd9e43c7146fab languageName: node linkType: hard @@ -13664,6 +14529,19 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:30.5.1": + version: 30.5.1 + resolution: "jest-worker@npm:30.5.1" + dependencies: + "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.5.1" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.1.1" + checksum: 10c0/fe3806769014920c5256ef01e2e9a5aba91a7ded90d7c86a82196f3da03070087c8c6b591f8bbb0b500c184c7cc8c5f22191e51cce504163b24a8b42c4cadd8b + languageName: node + linkType: hard + "jest-worker@npm:^29.6.3, jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" @@ -13733,6 +14611,22 @@ __metadata: languageName: node linkType: hard +"js-message@npm:1.0.7": + version: 1.0.7 + resolution: "js-message@npm:1.0.7" + checksum: 10c0/2dc2ff5a594613a9690c157a921999237164a3c213523f406dee23985ed81c92be9afdc7b34e67a2838d447d7d79cbf1662048effe8367bedcd71a950e4e292e + languageName: node + linkType: hard + +"js-queue@npm:2.0.2": + version: 2.0.2 + resolution: "js-queue@npm:2.0.2" + dependencies: + easy-stack: "npm:^1.0.1" + checksum: 10c0/cdadc65576c6d46817f47c27d7a57a1f3acbe3a4da2a4bc7303218f06d6ee7971358db28ec0d6b6835b4cd93332c52792b535064fee706453ef9d2bfe03b4a1a + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -13797,6 +14691,13 @@ __metadata: languageName: node linkType: hard +"json-cycle@npm:^1.3.0": + version: 1.5.0 + resolution: "json-cycle@npm:1.5.0" + checksum: 10c0/43ba321cf186b22573826ee76d64601bdfe57fe787d74162626c453bd860595c4c62ebfc338b5a64994fb4e4d7a947e6c2ef5f202e3a2ee4b01ca23ebc0cd710 + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -13993,6 +14894,13 @@ __metadata: languageName: node linkType: hard +"kuler@npm:^2.0.0": + version: 2.0.0 + resolution: "kuler@npm:2.0.0" + checksum: 10c0/0a4e99d92ca373f8f74d1dc37931909c4d0d82aebc94cf2ba265771160fc12c8df34eaaac80805efbda367e2795cb1f1dd4c3d404b6b1cf38aec94035b503d2d + languageName: node + linkType: hard + "lan-network@npm:^0.2.1": version: 0.2.1 resolution: "lan-network@npm:0.2.1" @@ -14450,7 +15358,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.18.1, lodash@npm:^4.17.15, lodash@npm:^4.17.4": +"lodash@npm:4.18.1, lodash@npm:^4.17.11, lodash@npm:^4.17.15, lodash@npm:^4.17.4": version: 4.18.1 resolution: "lodash@npm:4.18.1" checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 @@ -14486,6 +15394,20 @@ __metadata: languageName: node linkType: hard +"logform@npm:^2.7.0": + version: 2.7.0 + resolution: "logform@npm:2.7.0" + dependencies: + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" + fecha: "npm:^4.2.0" + ms: "npm:^2.1.1" + safe-stable-stringify: "npm:^2.3.1" + triple-beam: "npm:^1.3.0" + checksum: 10c0/4789b4b37413c731d1835734cb799240d31b865afde6b7b3e06051d6a4127bfda9e88c99cfbf296d084a315ccbed2647796e6a56b66e725bcb268c586f57558f + languageName: node + linkType: hard + "logkitty@npm:^0.7.1": version: 0.7.1 resolution: "logkitty@npm:0.7.1" @@ -15291,6 +16213,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:2 || 3, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 + languageName: node + linkType: hard + "minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.5": version: 10.2.5 resolution: "minimatch@npm:10.2.5" @@ -15300,15 +16231,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.5": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - "minimatch@npm:^5.0.1": version: 5.1.9 resolution: "minimatch@npm:5.1.9" @@ -15433,6 +16355,17 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:~0.5.1": + version: 0.5.6 + resolution: "mkdirp@npm:0.5.6" + dependencies: + minimist: "npm:^1.2.6" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/e2e2be789218807b58abced04e7b49851d9e46e88a2f9539242cc8a92c9b5c3a0b9bab360bd3014e02a140fc4fbc58e31176c408b493f8a2a6f4986bd7527b01 + languageName: node + linkType: hard + "moment-timezone@npm:^0.6.0": version: 0.6.2 resolution: "moment-timezone@npm:0.6.2" @@ -15442,6 +16375,13 @@ __metadata: languageName: node linkType: hard +"moment@npm:^2.19.3": + version: 2.31.0 + resolution: "moment@npm:2.31.0" + checksum: 10c0/3755b186b8ff62c565685ce69dde55bf46003c0329139be5c8dd7845cd567bfd7d02be3a13e379a25acb9e98f6b87136c4776745e440615a4f5ca8dd9a8815b9 + languageName: node + linkType: hard + "moment@npm:^2.29.4": version: 2.30.1 resolution: "moment@npm:2.30.1" @@ -15463,6 +16403,23 @@ __metadata: languageName: node linkType: hard +"multi-sort-stream@npm:^1.0.3, multi-sort-stream@npm:^1.0.4": + version: 1.0.4 + resolution: "multi-sort-stream@npm:1.0.4" + checksum: 10c0/74820fd8f4287798f56f5c0b390d52d0fa0efd46b22a880390c6182c4b2ff6bd608dc90974fe7b0981c9243ae4f857d3389e74ebf6d071d7465056c29772196d + languageName: node + linkType: hard + +"multipipe@npm:^4.0.0": + version: 4.0.0 + resolution: "multipipe@npm:4.0.0" + dependencies: + duplexer2: "npm:^0.1.2" + object-assign: "npm:^4.1.0" + checksum: 10c0/60b547f85a3c692fe24c4e15988bdbb6c7985665fd7c4f6228bbf2630f699badeefd8d492bff5b295ea02a284e12917ff21e324a2f660e3d5667bb656a01792d + languageName: node + linkType: hard + "multitars@npm:^1.0.2": version: 1.0.2 resolution: "multitars@npm:1.0.2" @@ -15477,6 +16434,17 @@ __metadata: languageName: node linkType: hard +"mv@npm:~2": + version: 2.1.1 + resolution: "mv@npm:2.1.1" + dependencies: + mkdirp: "npm:~0.5.1" + ncp: "npm:~2.0.0" + rimraf: "npm:~2.4.0" + checksum: 10c0/5da59a9f4ec16da0867289b5018c81c25c59b06bb9da717bc7bd0b40363d6653dc88d6da32a9434fd7416bfc3f67184c306ea44d3856ff97f3214cc96960efcd + languageName: node + linkType: hard + "mz@npm:^2.4.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -15488,6 +16456,15 @@ __metadata: languageName: node linkType: hard +"nan@npm:^2.14.0": + version: 2.29.0 + resolution: "nan@npm:2.29.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/c90586afb3f101f64c2997e7481f7c2c5528e196399a1c2653e4fff7354a01f35009e10fd43aa567a4b68be0ac8ed45a60794f5ca0a863120b74aecf4a45ad2c + languageName: node + linkType: hard + "nanoid@npm:^3.3.1, nanoid@npm:^3.3.11": version: 3.3.12 resolution: "nanoid@npm:3.3.12" @@ -15529,6 +16506,15 @@ __metadata: languageName: node linkType: hard +"ncp@npm:~2.0.0": + version: 2.0.0 + resolution: "ncp@npm:2.0.0" + bin: + ncp: ./bin/ncp + checksum: 10c0/d515babf9d3205ab9252e7d640af7c3e1a880317016d41f2fce2e6b9c8f60eb8bb6afde30e8c4f8e1e3fa551465f094433c3f364b25a85d6a28ec52c1ad6e067 + languageName: node + linkType: hard + "negotiator@npm:0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" @@ -15594,6 +16580,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9 + languageName: node + linkType: hard + "node-emoji@npm:^2.2.0": version: 2.2.0 resolution: "node-emoji@npm:2.2.0" @@ -15666,6 +16661,17 @@ __metadata: languageName: node linkType: hard +"node-ipc@npm:9.2.1": + version: 9.2.1 + resolution: "node-ipc@npm:9.2.1" + dependencies: + event-pubsub: "npm:4.3.0" + js-message: "npm:1.0.7" + js-queue: "npm:2.0.2" + checksum: 10c0/ed6448e2c643a831b9795a52de727658417a2cbedd3a59988b9be7654383982577dc507e0ec13211d10ed90377124366e3869a137b05093a424caf647eba0523 + languageName: node + linkType: hard + "node-releases@npm:^2.0.36": version: 2.0.44 resolution: "node-releases@npm:2.0.44" @@ -16128,6 +17134,15 @@ __metadata: languageName: node linkType: hard +"one-time@npm:^1.0.0": + version: 1.0.0 + resolution: "one-time@npm:1.0.0" + dependencies: + fn.name: "npm:1.x.x" + checksum: 10c0/6e4887b331edbb954f4e915831cbec0a7b9956c36f4feb5f6de98c448ac02ff881fd8d9b55a6b1b55030af184c6b648f340a76eb211812f4ad8c9b4b8692fdaa + languageName: node + linkType: hard + "onetime@npm:^2.0.0": version: 2.0.1 resolution: "onetime@npm:2.0.1" @@ -16731,6 +17746,13 @@ __metadata: languageName: node linkType: hard +"pngjs@npm:^7.0.0": + version: 7.0.0 + resolution: "pngjs@npm:7.0.0" + checksum: 10c0/0d4c7a0fd476a9c33df7d0a2a73e1d56537628a668841f6995c2bca070cf30819f9254a64363266bc14ef2fee47659dd3b4f2b18eec7ab65143015139f497b38 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -16825,6 +17847,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.5.1": + version: 30.5.1 + resolution: "pretty-format@npm:30.5.1" + dependencies: + "@jest/react-is-18": "npm:react-is@^18.3.1" + "@jest/react-is-19": "npm:react-is@^19.2.5" + "@jest/schemas": "npm:30.5.0" + ansi-styles: "npm:^5.2.0" + checksum: 10c0/bdef1790b9c93d1aa329c93885b0d1b8b6a3273bb680377531c70151b2e1b0a465210cae100661fc19b0bf35f507587f78b1a2f1afcf08791b9fe5630c23c076 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.3, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -16943,6 +17977,13 @@ __metadata: languageName: node linkType: hard +"promisify-child-process@npm:^4.1.2": + version: 4.1.2 + resolution: "promisify-child-process@npm:4.1.2" + checksum: 10c0/e5347137f1c333af4b7b4d2272a091d31007b30320af4b852f30f41776db378e22b3db1e67e40026a18301c243d9c9780e41c211cfce76ba26aedba17e3a385c + languageName: node + linkType: hard + "prompts@npm:^2.3.2, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" @@ -16973,6 +18014,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^3.0.2": + version: 3.2.0 + resolution: "proper-lockfile@npm:3.2.0" + dependencies: + graceful-fs: "npm:^4.1.11" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/b3a0bbf87f1b842ab57d91bee2e8c73d6cb2e90558aad259e83561cf5467f20885a1eb127343ecd9db214c78aaa196867ef8edb76e3681dc07ee43a489fd53bd + languageName: node + linkType: hard + "property-information@npm:^7.0.0": version: 7.2.0 resolution: "property-information@npm:7.2.0" @@ -17165,13 +18217,6 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - "react-is-19@npm:react-is@^19.2.5, react-is@npm:^19.1.0": version: 19.2.6 resolution: "react-is@npm:19.2.6" @@ -17327,6 +18372,16 @@ __metadata: languageName: node linkType: hard +"react-native-launch-arguments@npm:^4.1.1": + version: 4.1.1 + resolution: "react-native-launch-arguments@npm:4.1.1" + peerDependencies: + react: ">=16.8.1" + react-native: ">=0.60.0-rc.0 <1.0.x" + checksum: 10c0/94a88b3c6ba583b20108200c794f2c132a5b94383f0eb22797ff21194d140904bd62510ee9a01ea464ba831ddd69b3c49fb9b2799728d1d7fbaebf0ed683edd2 + languageName: node + linkType: hard + "react-native-lightbox@npm:^0.7.0": version: 0.7.0 resolution: "react-native-lightbox@npm:0.7.0" @@ -17768,7 +18823,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -18094,6 +19149,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:~2.4.0": + version: 2.4.5 + resolution: "rimraf@npm:2.4.5" + dependencies: + glob: "npm:^6.0.1" + bin: + rimraf: ./bin.js + checksum: 10c0/5251a36053165d23248efec5077f9addc13ad7f742a02dcd9ac7adda9e208cbf7523901e96a9ca6c33059bd0b573b97eab3334cf1d9976cc5ddc8b3c24d9ddd7 + languageName: node + linkType: hard + "root@workspace:.": version: 0.0.0-use.local resolution: "root@workspace:." @@ -18187,6 +19253,13 @@ __metadata: languageName: node linkType: hard +"safe-json-stringify@npm:~1": + version: 1.2.0 + resolution: "safe-json-stringify@npm:1.2.0" + checksum: 10c0/9c21c7b63a35a9e52d248eea2ad7bc9e790dde5aa418f0d4eed3c0b4c866e15337425b0d973173d30dd70a9e422271619f17e13574e0c8371d0c240cf72b871f + languageName: node + linkType: hard + "safe-push-apply@npm:^1.0.0": version: 1.0.0 resolution: "safe-push-apply@npm:1.0.0" @@ -18208,6 +19281,13 @@ __metadata: languageName: node linkType: hard +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10c0/baea14971858cadd65df23894a40588ed791769db21bafb7fd7608397dbdce9c5aac60748abae9995e0fc37e15f2061980501e012cd48859740796bea2987f49 + languageName: node + linkType: hard + "safer-buffer@npm:>= 2.1.2 < 3.0.0": version: 2.1.2 resolution: "safer-buffer@npm:2.1.2" @@ -18288,6 +19368,15 @@ __metadata: languageName: node linkType: hard +"sanitize-filename@npm:^1.6.1": + version: 1.6.4 + resolution: "sanitize-filename@npm:1.6.4" + dependencies: + truncate-utf8-bytes: "npm:^1.0.0" + checksum: 10c0/b56415a95e4f90dc992cd126b5f45c7b39d178662fbd0dc48f03203e35c58ab8e9eb3f5cebfaabc46f1438093d65a3a3cc96875e2b036a1474e19593bee9a540 + languageName: node + linkType: hard + "sax@npm:>=0.6.0": version: 1.6.0 resolution: "sax@npm:1.6.0" @@ -18363,6 +19452,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.0.0": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + "semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.7.4": version: 7.8.1 resolution: "semver@npm:7.8.1" @@ -18400,6 +19498,15 @@ __metadata: languageName: node linkType: hard +"serialize-error@npm:^8.0.1": + version: 8.1.0 + resolution: "serialize-error@npm:8.1.0" + dependencies: + type-fest: "npm:^0.20.2" + checksum: 10c0/8cfd89f43ca93e283c5f1d16178a536bdfac9bc6029f4a9df988610cc399bc4f2478d1f10ce40b9dff66b863a5158a19b438fbec929045c96d92174f6bca1e88 + languageName: node + linkType: hard + "serve-static@npm:^1.13.1, serve-static@npm:^1.16.2": version: 1.16.3 resolution: "serve-static@npm:1.16.3" @@ -18507,6 +19614,13 @@ __metadata: languageName: node linkType: hard +"shell-quote@npm:^1.7.2": + version: 1.12.0 + resolution: "shell-quote@npm:1.12.0" + checksum: 10c0/1605a4ed6a758bf13de7cb8197a4184d8015f7a4dbdcd6cd4a717d5b30b3ae7666300cc4ae7f3155cf80ace9739e9e82061ee5aa1a648f53f5fab31a220816d0 + languageName: node + linkType: hard + "side-channel-list@npm:^1.0.0, side-channel-list@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-list@npm:1.0.1" @@ -18847,6 +19961,13 @@ __metadata: languageName: node linkType: hard +"stack-trace@npm:0.0.x": + version: 0.0.10 + resolution: "stack-trace@npm:0.0.10" + checksum: 10c0/9ff3dabfad4049b635a85456f927a075c9d0c210e3ea336412d18220b2a86cbb9b13ec46d6c37b70a302a4ea4d49e30e5d4944dd60ae784073f1cde778ac8f4b + languageName: node + linkType: hard + "stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" @@ -18926,6 +20047,13 @@ __metadata: languageName: node linkType: hard +"stream-chain@npm:^2.2.5": + version: 2.2.5 + resolution: "stream-chain@npm:2.2.5" + checksum: 10c0/c512f50190d7c92d688fa64e7af540c51b661f9c2b775fc72bca38ea9bca515c64c22c2197b1be463741daacbaaa2dde8a8ea24ebda46f08391224f15249121a + languageName: node + linkType: hard + "stream-chat-expo@workspace:^, stream-chat-expo@workspace:package/expo-package": version: 0.0.0-use.local resolution: "stream-chat-expo@workspace:package/expo-package" @@ -19165,6 +20293,15 @@ __metadata: languageName: node linkType: hard +"stream-json@npm:^1.7.4, stream-json@npm:^1.7.5": + version: 1.9.1 + resolution: "stream-json@npm:1.9.1" + dependencies: + stream-chain: "npm:^2.2.5" + checksum: 10c0/0521e5cb3fb6b0e2561d715975e891bd81fa77d0239c8d0b1756846392bc3c7cdd7f1ddb0fe7ed77e6fdef58daab9e665d3b39f7d677bd0859e65a2bff59b92c + languageName: node + linkType: hard + "string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -19546,6 +20683,15 @@ __metadata: languageName: node linkType: hard +"telnet-client@npm:1.2.8": + version: 1.2.8 + resolution: "telnet-client@npm:1.2.8" + dependencies: + bluebird: "npm:^3.5.4" + checksum: 10c0/f6700268a9734305655e305540b21e18f0ab05923d4917fcf8c3192ced596ce4675c61e8b8d6ad12c89bf7eb2d38f2792ead5eb721c7055c4e924f9312913a34 + languageName: node + linkType: hard + "temp-dir@npm:^3.0.0": version: 3.0.0 resolution: "temp-dir@npm:3.0.0" @@ -19600,6 +20746,24 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.2 + resolution: "test-exclude@npm:7.0.2" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^10.2.2" + checksum: 10c0/b79b855af9168c6a362146015ccf40f5e3a25e307304ba9bea930818507f6319d230380d5d7b5baa659c981ccc11f1bd21b6f012f85606353dec07e02dee67c9 + languageName: node + linkType: hard + +"text-hex@npm:1.0.x": + version: 1.0.0 + resolution: "text-hex@npm:1.0.0" + checksum: 10c0/57d8d320d92c79d7c03ffb8339b825bb9637c2cbccf14304309f51d8950015c44464b6fd1b6820a3d4821241c68825634f09f5a2d9d501e84f7c6fd14376860d + languageName: node + linkType: hard + "text-table@npm:~0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -19675,6 +20839,13 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.1": + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10c0/59eb55584f2f07210d3231b6a1f6b5c2b9794d8a7b509c8ee867ed2acad6d2245ee2448b7937b676ffbff3155a70077edde8a69f9d7cf0f90c86a62e8910c357 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -19712,6 +20883,15 @@ __metadata: languageName: node linkType: hard +"trace-event-lib@npm:^1.3.1": + version: 1.4.1 + resolution: "trace-event-lib@npm:1.4.1" + dependencies: + browser-process-hrtime: "npm:^1.0.0" + checksum: 10c0/301faf760c8d956d9879f1eaf8a949d480126a1af0d88d33299f1580401e32358d48726750ac8903a4e9be1d0c5f68bdc13453e067ca2bc93565ea381123c4bd + languageName: node + linkType: hard + "traverse@npm:0.6.8": version: 0.6.8 resolution: "traverse@npm:0.6.8" @@ -19726,6 +20906,22 @@ __metadata: languageName: node linkType: hard +"triple-beam@npm:^1.3.0": + version: 1.4.1 + resolution: "triple-beam@npm:1.4.1" + checksum: 10c0/4bf1db71e14fe3ff1c3adbe3c302f1fdb553b74d7591a37323a7badb32dc8e9c290738996cbb64f8b10dc5a3833645b5d8c26221aaaaa12e50d1251c9aba2fea + languageName: node + linkType: hard + +"truncate-utf8-bytes@npm:^1.0.0": + version: 1.0.2 + resolution: "truncate-utf8-bytes@npm:1.0.2" + dependencies: + utf8-byte-length: "npm:^1.0.1" + checksum: 10c0/af2b431fc4314f119b551e5fccfad49d4c0ef82e13ba9ca61be6567801195b08e732ce9643542e8ad1b3df44f3df2d7345b3dd34f723954b6bb43a14584d6b3c + languageName: node + linkType: hard + "ts-api-utils@npm:^2.5.0": version: 2.5.0 resolution: "ts-api-utils@npm:2.5.0" @@ -19747,7 +20943,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.4.0": +"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.5.3": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -19797,6 +20993,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 10c0/dea9df45ea1f0aaa4e2d3bed3f9a0bfe9e5b2592bddb92eb1bf06e50bcf98dbb78189668cd8bc31a0511d3fc25539b4cd5c704497e53e93e2d40ca764b10bfc3 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -20245,6 +21448,13 @@ __metadata: languageName: node linkType: hard +"utf8-byte-length@npm:^1.0.1": + version: 1.0.5 + resolution: "utf8-byte-length@npm:1.0.5" + checksum: 10c0/e69bda3299608f4cc75976da9fb74ac94801a58b9ca29fdad03a20ec952e7477d7f226c12716b5f36bd4cff8151d1d152d02ee1df3752f017d4b2c725ce3e47a + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -20542,6 +21752,17 @@ __metadata: languageName: node linkType: hard +"which@npm:^1.3.1": + version: 1.3.1 + resolution: "which@npm:1.3.1" + dependencies: + isexe: "npm:^2.0.0" + bin: + which: ./bin/which + checksum: 10c0/e945a8b6bbf6821aaaef7f6e0c309d4b615ef35699576d5489b4261da9539f70393c6b2ce700ee4321c18f914ebe5644bc4631b15466ffbaad37d83151f6af59 + languageName: node + linkType: hard + "which@npm:^2.0.1, which@npm:^2.0.2": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -20564,6 +21785,36 @@ __metadata: languageName: node linkType: hard +"winston-transport@npm:^4.9.0": + version: 4.9.0 + resolution: "winston-transport@npm:4.9.0" + dependencies: + logform: "npm:^2.7.0" + readable-stream: "npm:^3.6.2" + triple-beam: "npm:^1.3.0" + checksum: 10c0/e2990a172e754dbf27e7823772214a22dc8312f7ec9cfba831e5ef30a5d5528792e5ea8f083c7387ccfc5b2af20e3691f64738546c8869086110a26f98671095 + languageName: node + linkType: hard + +"winston@npm:^3.17.0": + version: 3.19.0 + resolution: "winston@npm:3.19.0" + dependencies: + "@colors/colors": "npm:^1.6.0" + "@dabh/diagnostics": "npm:^2.0.8" + async: "npm:^3.2.3" + is-stream: "npm:^2.0.0" + logform: "npm:^2.7.0" + one-time: "npm:^1.0.0" + readable-stream: "npm:^3.4.0" + safe-stable-stringify: "npm:^2.3.1" + stack-trace: "npm:0.0.x" + triple-beam: "npm:^1.3.0" + winston-transport: "npm:^4.9.0" + checksum: 10c0/341a8ccfb726120209d34e2466040e2ca72cadb1a3402c4fc90425facad002b81275675b4ab9b4432a624311bc47ef7c9fb7652c86fca454d2be2f2ee1882226 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" @@ -20683,6 +21934,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^7.0.0": + version: 7.5.13 + resolution: "ws@npm:7.5.13" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/e223726bbda5768ed58ec9252d04eda7018ef380ea122bb94e595a4d7a02b5baeb423933936576fddb8fa51b642375f7ae814bf0f6f5d9a3ce290566578a8c5a + languageName: node + linkType: hard + "ws@npm:^8.12.1, ws@npm:^8.20.1": version: 8.21.0 resolution: "ws@npm:8.21.0" @@ -20807,7 +22073,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^21.1.1": +"yargs-parser@npm:^21.0.0, yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 @@ -20821,6 +22087,18 @@ __metadata: languageName: node linkType: hard +"yargs-unparser@npm:^2.0.0": + version: 2.0.0 + resolution: "yargs-unparser@npm:2.0.0" + dependencies: + camelcase: "npm:^6.0.0" + decamelize: "npm:^4.0.0" + flat: "npm:^5.0.2" + is-plain-obj: "npm:^2.1.0" + checksum: 10c0/a5a7d6dc157efa95122e16780c019f40ed91d4af6d2bac066db8194ed0ec5c330abb115daa5a79ff07a9b80b8ea80c925baacf354c4c12edd878c0529927ff03 + languageName: node + linkType: hard + "yargs@npm:^15.1.0": version: 15.4.1 resolution: "yargs@npm:15.4.1" @@ -20855,6 +22133,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.0.0": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/7a28572f7e785a57886e34fdbddb9b28756dec552e1453d5f6e7cdd00ad8721a4e8c4321d33683f5e61cacb36ad43258adbb48396b71ec4ed14abee0fc0d0c1f + languageName: node + linkType: hard + "yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2"