authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-04 00:48:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 00:48:28-07:00
logd60a71ef6e371d8b345ecb1f7575117d675187c7
treef916686bb8d122bd988061d77d29e480d9281760
parent1302ccacbd6d3e957f2f95239556adb6eae79a48
signature Signed by PGP key B5690EEEBB952194

feat(mobile): full-screen simulator viewer with on-demand controls (#15551)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

7 files changed, 224 insertions(+), 80 deletions(-)

apps/mobile/app.config.ts+5
...@@ -306,6 +306,8 @@ const config: ExpoConfig = {...@@ -306,6 +306,8 @@ const config: ExpoConfig = {
306 // JS back handling survives it via react-native's Android 16 shim plus306 // JS back handling survives it via react-native's Android 16 shim plus
307 // withAndroidPredictiveBackCompat on Android 13-15.307 // withAndroidPredictiveBackCompat on Android 13-15.
308 predictiveBackGestureEnabled: true,308 predictiveBackGestureEnabled: true,
309 // expo-sensors declares this for its pedometer, which the app does not use.
310 blockedPermissions: ["android.permission.ACTIVITY_RECOGNITION"],
309 },311 },
310 web: {312 web: {
311 favicon: variant.assets.appIcon,313 favicon: variant.assets.appIcon,
...@@ -440,6 +442,9 @@ const config: ExpoConfig = {...@@ -440,6 +442,9 @@ const config: ExpoConfig = {
440 },442 },
441 ],443 ],
442 "./plugins/withIosCocoaPodsUuidCache.cjs",444 "./plugins/withIosCocoaPodsUuidCache.cjs",
445 // Only the accelerometer is used (device viewer shake). Compile out the
446 // pedometer so iOS needs no motion purpose string.
447 ["expo-sensors", { motionPermission: false }],
443 // Must be listed BEFORE expo-widgets: same-type mods run last-registered-448 // Must be listed BEFORE expo-widgets: same-type mods run last-registered-
444 // first, so registering earlier makes this plugin's mods run AFTER449 // first, so registering earlier makes this plugin's mods run AFTER
445 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which450 // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which
apps/mobile/package.json+1
...@@ -101,6 +101,7 @@...@@ -101,6 +101,7 @@
101 "expo-paste-input": "^0.1.15",101 "expo-paste-input": "^0.1.15",
102 "expo-quick-actions": "^6.0.2",102 "expo-quick-actions": "^6.0.2",
103 "expo-secure-store": "~58.0.2",103 "expo-secure-store": "~58.0.2",
104 "expo-sensors": "~58.0.1",
104 "expo-sharing": "58.0.13",105 "expo-sharing": "58.0.13",
105 "expo-splash-screen": "~58.0.4",106 "expo-splash-screen": "~58.0.4",
106 "expo-sqlite": "~58.0.8",107 "expo-sqlite": "~58.0.8",
apps/mobile/src/Stack.tsx+2
...@@ -701,6 +701,8 @@ const RootStackConfig = createNativeStackNavigator({...@@ -701,6 +701,8 @@ const RootStackConfig = createNativeStackNavigator({
701 presentation: "fullScreenModal",701 presentation: "fullScreenModal",
702 headerShown: false,702 headerShown: false,
703 gestureEnabled: false,703 gestureEnabled: false,
704 autoHideHomeIndicator: true,
705 navigationBarHidden: true,
704 },706 },
705 }),707 }),
706 ThreadReview: createNativeStackScreen({708 ThreadReview: createNativeStackScreen({
apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx+145-80
...@@ -4,16 +4,32 @@ import {...@@ -4,16 +4,32 @@ import {
4 deviceToolUpdatePolicy,4 deviceToolUpdatePolicy,
5} from "@t3tools/client-runtime/state/device";5} from "@t3tools/client-runtime/state/device";
6import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";6import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { createNativeStackNavigator } from "@react-navigation/native-stack";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";8import * as Cause from "effect/Cause";
10import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";9import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
11import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native";10import * as Haptics from "expo-haptics";
11import { Accelerometer } from "expo-sensors";
12import {
13 ActivityIndicator,
14 Alert,
15 AppState,
16 BackHandler,
17 Pressable,
18 StatusBar,
19 View,
20} from "react-native";
21import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
12import { useSafeAreaInsets } from "react-native-safe-area-context";22import { useSafeAreaInsets } from "react-native-safe-area-context";
1323
24import { SymbolView } from "../../components/AppSymbol";
14import { AppText } from "../../components/AppText";25import { AppText } from "../../components/AppText";
15import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader";26import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
16import { NativeHeaderToolbar } from "../../native/StackHeader";27import { GlassSurface } from "../../components/GlassSurface";
28import {
29 androidHeaderMenuActions,
30 findHeaderMenuAction,
31} from "../../components/headerMenu.android";
32import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
17import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";33import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";
18import { useEnvironmentQuery } from "../../state/query";34import { useEnvironmentQuery } from "../../state/query";
19import { useAtomCommand } from "../../state/use-atom-command";35import { useAtomCommand } from "../../state/use-atom-command";
...@@ -24,40 +40,26 @@ import {...@@ -24,40 +40,26 @@ import {
24 threadDevicePreviews,40 threadDevicePreviews,
25 type ThreadDevicePreview,41 type ThreadDevicePreview,
26} from "./threadDevicePreviews";42} from "./threadDevicePreviews";
43import { createShakeDetector } from "./shakeDetector";
2744
28const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>();45const OVERLAY_ENTERING = FadeIn.duration(160).reduceMotion(ReduceMotion.System);
46const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
2947
30type DevicePreviewRouteScreenProps = StaticScreenProps<{48type DevicePreviewRouteScreenProps = StaticScreenProps<{
31 readonly environmentId: string;49 readonly environmentId: string;
32 readonly threadId: string;50 readonly threadId: string;
33}>;51}>;
3452
35/** The nested native stack supplies the navigation bar inside the modal. */53/** Full-screen viewer; controls live in an overlay toggled by the handle or a shake. */
36export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {54export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
37 const navigation = useNavigation();55 const navigation = useNavigation();
38 const onClose = useCallback(() => navigation.goBack(), [navigation]);56 const onClose = useCallback(() => navigation.goBack(), [navigation]);
39 return (57 return (
40 <View collapsable={false} className="flex-1 bg-sheet">58 <DevicePreviewScreen
41 <DevicePreviewStack.Navigator59 environmentId={EnvironmentId.make(route.params.environmentId)}
42 screenOptions={{60 threadId={ThreadId.make(route.params.threadId)}
43 headerShown: Platform.OS === "ios",61 onClose={onClose}
44 headerBackVisible: false,62 />
45 headerShadowVisible: false,
46 headerTransparent: false,
47 headerTitleStyle: { fontSize: 17, fontWeight: "600" },
48 }}
49 >
50 <DevicePreviewStack.Screen name="DevicePreview">
51 {() => (
52 <DevicePreviewScreen
53 environmentId={EnvironmentId.make(route.params.environmentId)}
54 threadId={ThreadId.make(route.params.threadId)}
55 onClose={onClose}
56 />
57 )}
58 </DevicePreviewStack.Screen>
59 </DevicePreviewStack.Navigator>
60 </View>
61 );63 );
62}64}
6365
...@@ -78,6 +80,7 @@ function DevicePreviewScreen({...@@ -78,6 +80,7 @@ function DevicePreviewScreen({
78 const [inputConnected, setInputConnected] = useState(false);80 const [inputConnected, setInputConnected] = useState(false);
79 const [streamAttempt, setStreamAttempt] = useState(0);81 const [streamAttempt, setStreamAttempt] = useState(0);
80 const [shuttingDown, setShuttingDown] = useState(false);82 const [shuttingDown, setShuttingDown] = useState(false);
83 const [controlsVisible, setControlsVisible] = useState(true);
81 const retryHost = useAtomCommand(deviceEnvironment.list);84 const retryHost = useAtomCommand(deviceEnvironment.list);
82 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });85 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
83 const streamRef = useRef<DeviceStreamRef>(null);86 const streamRef = useRef<DeviceStreamRef>(null);
...@@ -87,10 +90,11 @@ function DevicePreviewScreen({...@@ -87,10 +90,11 @@ function DevicePreviewScreen({
87 [state.data, threadId],90 [state.data, threadId],
88 );91 );
89 const preview = selectedThreadDevicePreview(previews, selectedKey);92 const preview = selectedThreadDevicePreview(previews, selectedKey);
90 const onInputConnected = useCallback(93 const onInputConnected = useCallback(async (connected: boolean) => {
91 async (connected: boolean) => setInputConnected(connected),94 setInputConnected(connected);
92 [],95 // Controls greet the user while connecting, then get out of the stream's way.
93 );96 if (connected) setControlsVisible(false);
97 }, []);
94 useEffect(() => {98 useEffect(() => {
95 const subscription = AppState.addEventListener("change", (state) =>99 const subscription = AppState.addEventListener("change", (state) =>
96 setForeground(state !== "background"),100 setForeground(state !== "background"),
...@@ -100,6 +104,26 @@ function DevicePreviewScreen({...@@ -100,6 +104,26 @@ function DevicePreviewScreen({
100 useEffect(() => {104 useEffect(() => {
101 if (focused && state.data !== null && previews.length === 0) onClose();105 if (focused && state.data !== null && previews.length === 0) onClose();
102 }, [focused, state.data, previews.length, onClose]);106 }, [focused, state.data, previews.length, onClose]);
107 useEffect(() => {
108 if (!focused || !foreground) return;
109 const shaken = createShakeDetector();
110 Accelerometer.setUpdateInterval(50);
111 const subscription = Accelerometer.addListener((sample) => {
112 if (!shaken({ ...sample, timestamp: sample.timestamp * 1000 })) return;
113 void Haptics.selectionAsync();
114 setControlsVisible((visible) => !visible);
115 });
116 return () => subscription.remove();
117 }, [focused, foreground]);
118 // Android back reveals the controls first, so leaving takes a deliberate second press.
119 useEffect(() => {
120 if (controlsVisible) return;
121 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
122 setControlsVisible(true);
123 return true;
124 });
125 return () => subscription.remove();
126 }, [controlsVisible]);
103127
104 const shutDownDevice = async () => {128 const shutDownDevice = async () => {
105 if (!preview || shuttingDown) return;129 if (!preview || shuttingDown) return;
...@@ -217,57 +241,29 @@ function DevicePreviewScreen({...@@ -217,57 +241,29 @@ function DevicePreviewScreen({
217 onPress: () => void shutDownDevice(),241 onPress: () => void shutDownDevice(),
218 },242 },
219 ];243 ];
220 return (244 const pressHome = () => streamRef.current?.home();
221 <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}>245 const menuItems: ScreenHeaderMenuItem[] = [
222 <ScreenHeader246 ...(previews.length > 1
223 title={preview?.name ?? "Devices"}247 ? [
224 sidebar={false}
225 onBack={onClose}
226 options={{ headerBackVisible: false }}
227 actions={[
228 {
229 accessibilityLabel: "Home",
230 icon: "house",
231 disabled: !inputConnected,
232 onPress: () => streamRef.current?.home(),
233 },
234 ]}
235 menus={[
236 {248 {
237 title: "Device options",249 id: "devices",
238 icon: "ellipsis",250 title: "Devices",
239 items: [251 inline: true,
240 ...(previews.length > 1252 items: previews.map((device) => ({
241 ? [253 id: device.key,
242 {254 title: device.name,
243 id: "devices",255 subtitle: device.description,
244 title: "Devices",256 selected: device.key === preview?.key,
245 inline: true,257 onPress: () => setSelectedKey(device.key),
246 items: previews.map((device) => ({258 })),
247 id: device.key,
248 title: device.name,
249 subtitle: device.description,
250 selected: device.key === preview?.key,
251 onPress: () => setSelectedKey(device.key),
252 })),
253 },
254 ]
255 : []),
256 ...controls,
257 ],
258 },259 },
259 ]}260 ]
260 />261 : []),
261 {Platform.OS === "ios" ? (262 ...controls,
262 <NativeHeaderToolbar placement="left">263 ];
263 <NativeHeaderToolbar.Button264 return (
264 icon="xmark"265 <View className="flex-1" style={{ backgroundColor: themeVariables["--color-sheet-solid"] }}>
265 accessibilityLabel="Close device preview"266 <StatusBar hidden animated />
266 onPress={onClose}
267 separateBackground
268 />
269 </NativeHeaderToolbar>
270 ) : null}
271 {preview && focused && foreground ? (267 {preview && focused && foreground ? (
272 <OpenDevicePreview268 <OpenDevicePreview
273 key={`${preview.key}:${streamAttempt}`}269 key={`${preview.key}:${streamAttempt}`}
...@@ -296,10 +292,79 @@ function DevicePreviewScreen({...@@ -296,10 +292,79 @@ function DevicePreviewScreen({
296 ) : null}292 ) : null}
297 </View>293 </View>
298 )}294 )}
295 {controlsVisible ? (
296 <Animated.View
297 entering={OVERLAY_ENTERING}
298 exiting={OVERLAY_EXITING}
299 pointerEvents="box-none"
300 className="absolute inset-0"
301 >
302 {inputConnected ? (
303 <Pressable
304 accessibilityLabel="Hide device controls"
305 className="absolute inset-0 bg-black/30"
306 onPress={() => setControlsVisible(false)}
307 />
308 ) : null}
309 <View className="px-3" style={{ paddingTop: Math.max(insets.top, 12) }}>
310 <GlassSurface className="flex-row items-center gap-1 p-1">
311 <ControlPill
312 accessibilityLabel="Close device preview"
313 icon="xmark"
314 onPress={onClose}
315 />
316 <AppText numberOfLines={1} className="flex-1 text-center font-t3-medium text-base">
317 {preview?.name ?? "Devices"}
318 </AppText>
319 <ControlPill
320 accessibilityLabel="Home"
321 icon="house"
322 disabled={!inputConnected}
323 onPress={pressHome}
324 />
325 <DeviceOptionsMenu items={menuItems} />
326 </GlassSurface>
327 </View>
328 </Animated.View>
329 ) : (
330 <Pressable
331 accessibilityRole="button"
332 accessibilityLabel="Show device controls"
333 accessibilityHint="Shaking the phone also shows them"
334 hitSlop={12}
335 className="absolute h-6 w-16 items-center justify-center self-center"
336 style={{ top: Math.max(insets.top - 18, 4) }}
337 onPress={() => setControlsVisible(true)}
338 >
339 <View className="h-1.5 w-10 rounded-full bg-white/40" />
340 </Pressable>
341 )}
299 </View>342 </View>
300 );343 );
301}344}
302345
346function DeviceOptionsMenu({ items }: { readonly items: ReadonlyArray<ScreenHeaderMenuItem> }) {
347 return (
348 <ControlPillMenu
349 actions={androidHeaderMenuActions(items)}
350 isAnchoredToRight
351 title="Device options"
352 onPressAction={({ nativeEvent }) => {
353 const action = findHeaderMenuAction(items, nativeEvent.event);
354 if (action && !action.disabled) action.onPress();
355 }}
356 >
357 <Pressable
358 accessibilityLabel="Device options"
359 accessibilityRole="button"
360 className="size-11 items-center justify-center rounded-full bg-subtle"
361 >
362 <SymbolView name="ellipsis" size={18} tintColorClassName="accent-icon" />
363 </Pressable>
364 </ControlPillMenu>
365 );
366}
367
303function OpenDevicePreview({368function OpenDevicePreview({
304 environmentId,369 environmentId,
305 preview,370 preview,
apps/mobile/src/features/devices/shakeDetector.test.ts created+26
...@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { createShakeDetector } from "./shakeDetector";
4
5const resting = (timestamp: number) => ({ x: 0, y: 0, z: -1, timestamp });
6const jolt = (timestamp: number) => ({ x: 2.2, y: 0.4, z: -1, timestamp });
7
8describe("shake detector", () => {
9 it("ignores gravity and a single bump", () => {
10 const detect = createShakeDetector();
11 expect(detect(resting(0))).toBe(false);
12 expect(detect(jolt(100))).toBe(false);
13 expect(detect(resting(200))).toBe(false);
14 expect(detect(jolt(900))).toBe(false);
15 });
16
17 it("reports two jolts within the window once, then cools down", () => {
18 const detect = createShakeDetector();
19 expect(detect(jolt(0))).toBe(false);
20 expect(detect(jolt(300))).toBe(true);
21 expect(detect(jolt(400))).toBe(false);
22 expect(detect(jolt(600))).toBe(false);
23 expect(detect(jolt(1_400))).toBe(false);
24 expect(detect(jolt(1_500))).toBe(true);
25 });
26});
apps/mobile/src/features/devices/shakeDetector.ts created+30
...@@ -0,0 +1,30 @@
1export interface AccelerationSample {
2 /** Acceleration per axis in g, gravity included. */
3 readonly x: number;
4 readonly y: number;
5 readonly z: number;
6 /** Milliseconds. */
7 readonly timestamp: number;
8}
9
10/**
11 * Reports a shake after two strong jolts within a short window. A single bump,
12 * setting the phone down, or walking stays below the threshold or the count.
13 */
14export function createShakeDetector({
15 threshold = 1.8,
16 window = 600,
17 cooldown = 1_000,
18}: { threshold?: number; window?: number; cooldown?: number } = {}) {
19 let jolts: number[] = [];
20 let lastShake = Number.NEGATIVE_INFINITY;
21 return (sample: AccelerationSample) => {
22 if (sample.timestamp - lastShake < cooldown) return false;
23 if (Math.hypot(sample.x, sample.y, sample.z) < threshold) return false;
24 jolts = [...jolts.filter((time) => sample.timestamp - time <= window), sample.timestamp];
25 if (jolts.length < 2) return false;
26 jolts = [];
27 lastShake = sample.timestamp;
28 return true;
29 };
30}
pnpm-lock.yaml+15
...@@ -408,6 +408,9 @@ importers:...@@ -408,6 +408,9 @@ importers:
408 expo-secure-store:408 expo-secure-store:
409 specifier: ~58.0.2409 specifier: ~58.0.2
410 version: 58.0.2(expo@58.0.2)410 version: 58.0.2(expo@58.0.2)
411 expo-sensors:
412 specifier: ~58.0.1
413 version: 58.0.1(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))
411 expo-sharing:414 expo-sharing:
412 specifier: 58.0.13415 specifier: 58.0.13
413 version: 58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2)416 version: 58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2)
...@@ -7842,6 +7845,12 @@ packages:...@@ -7842,6 +7845,12 @@ packages:
7842 peerDependencies:7845 peerDependencies:
7843 expo: '*'7846 expo: '*'
78447847
7848 expo-sensors@58.0.1:
7849 resolution: {integrity: sha512-hXS10z/wu55sYv++yPstmSjaA7pTGHV1Rc3kAhThf/hZbJZXtbIaFMGsgujIH6RFqi5pcUho0aMWHjvdCewH8g==}
7850 peerDependencies:
7851 expo: '*'
7852 react-native: '*'
7853
7845 expo-server@58.0.2:7854 expo-server@58.0.2:
7846 resolution: {integrity: sha512-QOdIBRZ7hEhlKzDud99/4YPggg+rASCD901PuuDy/oHzSP+eQKs2B4PDwzwfYvtelDA45nOwJsyCKnBvwpuJZA==}7855 resolution: {integrity: sha512-QOdIBRZ7hEhlKzDud99/4YPggg+rASCD901PuuDy/oHzSP+eQKs2B4PDwzwfYvtelDA45nOwJsyCKnBvwpuJZA==}
7847 engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0}7856 engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0}
...@@ -18221,6 +18230,12 @@ snapshots:...@@ -18221,6 +18230,12 @@ snapshots:
18221 dependencies:18230 dependencies:
18222 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025)18231 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025)
1822318232
18233 expo-sensors@58.0.1(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)):
18234 dependencies:
18235 expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025)
18236 invariant: 2.2.4
18237 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
18238
18224 expo-server@58.0.2: {}18239 expo-server@58.0.2: {}
1822518240
18226 expo-sharing@58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2):18241 expo-sharing@58.0.13(patch_hash=fe40d857765bcd6a77aac1fed275e5b8f9415c29b972decdce05fb96de5665e6)(expo@58.0.2)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2):