diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx index 9366902a1c8..f8a4b44f577 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx @@ -92,7 +92,7 @@ describe('the hybrid shell screen', () => { reason: 'document-load-failed', retriedOnce: true }) - expect(textOf(tree)).toContain('The downloaded workspace could not be opened.') + expect(textOf(tree)).toContain('The downloaded app could not be opened.') const retry = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry') expect(retry).toHaveLength(1) await act(async () => { @@ -123,20 +123,38 @@ describe('the hybrid shell screen', () => { it('names what is missing when the host is unreachable and nothing is cached', async () => { expect(textOf(await renderScreen({ kind: 'offline' }))).toContain( - 'Connect to this host to download the workspace' + 'Connect to this host to download the latest app' ) }) - it('counts assets and bytes while downloading', async () => { + it('shows the download as a progress bar with megabytes from the paired host', async () => { const tree = await renderScreen({ kind: 'fetching', - completedAssets: 2, - totalAssets: 4, - receivedBytes: 2048, - totalBytes: 4096 + completedAssets: 0, + totalAssets: 128, + receivedBytes: 2_020_592, + totalBytes: 8_082_368 }) - expect(textOf(tree)).toContain('2/4 files') - expect(textOf(tree)).toContain('2048/4096 bytes') + expect(textOf(tree)).toContain('Downloading the latest app') + expect(textOf(tree)).toContain('from Host One · 2.0 of 8.1 MB') + const bar = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-progress-bar') + expect(bar).toHaveLength(1) + expect(bar[0].props.accessibilityRole).toBe('progressbar') + expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 25 }) + expect(byName(tree, 'ActivityIndicator')).toEqual([]) + }) + + it('reads an empty download as no progress rather than dividing by zero', async () => { + const tree = await renderScreen({ + kind: 'fetching', + completedAssets: 0, + totalAssets: 0, + receivedBytes: 0, + totalBytes: 0 + }) + const bar = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-progress-bar') + expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 0 }) + expect(textOf(tree)).toContain('0.0 of 0.0 MB') }) it('hands the shell view the generation path and the session id', async () => { @@ -360,7 +378,7 @@ describe('the hybrid shell screen', () => { textOf( await renderScreen({ kind: 'failed', reason: 'document-load-failed', retriedOnce: true }) ) - ).toContain('The downloaded workspace could not be opened.') + ).toContain('The downloaded app could not be opened.') }) it('reports a URL nothing on this phone could open, which is the dead tap that survives', async () => { @@ -584,7 +602,7 @@ describe('a refused update is said beside the page, not in front of it', () => { const tree = await renderScreen(readyState('session-one')) expect(byName(tree, 'ShellViewProbe')).toHaveLength(1) const text = textOf(tree) - expect(text).toContain("Couldn't update the workspace from this host") + expect(text).toContain("Couldn't download the latest app from this host") expect(text).toContain('Showing the last version that worked') expect(text).not.toContain('Try again') }) diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx index 97a29e52c99..34c150921ba 100644 --- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx +++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from 'react' -import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' import { useNavigation, useRouter } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { @@ -36,6 +36,7 @@ import { isHostRouteOf } from './page-route-policy' import { useResponsiveLayout } from '../layout/responsive-layout' import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs' import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame' +import { ShellDownloadProgress } from './ShellDownloadProgress' import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets' function failureMessage(reason: MobileWebShellFailureCause): string { @@ -43,14 +44,14 @@ function failureMessage(reason: MobileWebShellFailureCause): string { case 'isolation-unavailable': return "This device's WebView is too old to open the workspace safely." case 'download-failed': - return 'The workspace could not be downloaded from this host.' + return 'The latest app could not be downloaded from this host.' case 'status-unreadable': return "Could not read this host's status. Go back and reopen it." case 'render-process-gone': return 'The workspace stopped responding.' case 'generation-unreadable': case 'document-load-failed': - return 'The downloaded workspace could not be opened.' + return 'The downloaded app could not be opened.' } } @@ -59,7 +60,7 @@ function failureMessage(reason: MobileWebShellFailureCause): string { function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string { switch (notice) { case 'update-failed': - return "Couldn't update the workspace from this host. Showing the last version that worked." + return "Couldn't download the latest app from this host. Showing the last version that worked." } } @@ -75,18 +76,6 @@ function Waiting({ label }: { label: string }) { ) } -function Fetching({ state }: { state: Extract }) { - return ( - - - Downloading workspace - - {`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`} - - - ) -} - function Failed({ state, onRetry @@ -366,13 +355,17 @@ export function MobileWebShellScreen({ return ( - Connect to this host to download the workspace + Connect to this host to download the latest app ) } if (state.kind === 'fetching') { - return + return ( + + + + ) } if (state.kind !== 'ready') { return @@ -461,11 +454,6 @@ const styles = StyleSheet.create({ marginTop: spacing.md, textAlign: 'center' }, - progress: { - fontSize: typography.metaSize, - color: colors.textMuted, - marginTop: spacing.sm - }, failedMessage: { fontSize: typography.bodySize, color: colors.textPrimary, diff --git a/mobile/src/mobile-web-shell/ShellDownloadProgress.tsx b/mobile/src/mobile-web-shell/ShellDownloadProgress.tsx new file mode 100644 index 00000000000..34f2e39e49b --- /dev/null +++ b/mobile/src/mobile-web-shell/ShellDownloadProgress.tsx @@ -0,0 +1,60 @@ +import { Animated, StyleSheet, View } from 'react-native' +import { colors } from '../theme/mobile-theme' +import { useHostDisplay, type HostDisplaySource } from '../transport/use-host-display' +import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract' +import { ShellWaitingFrame } from './ShellWaitingFrame' +import { useEasedDownloadProgress } from './use-eased-download-progress' + +const DOWNLOADING_LABEL = 'Downloading the latest app' + +function megabytes(bytes: number): string { + return (bytes / 1_000_000).toFixed(1) +} + +/** The page bundle coming down from the host, as a bar over the bytes received. */ +export function ShellDownloadProgress({ + state, + host +}: { + state: Extract + /** Null until the stored profile is read, which leaves the caption without a name. */ + host: HostDisplaySource | null +}) { + const fraction = state.totalBytes > 0 ? Math.min(1, state.receivedBytes / state.totalBytes) : 0 + const shown = useEasedDownloadProgress(fraction) + const hostTitle = useHostDisplay(host).title + const source = host === null ? '' : `from ${hostTitle} · ` + return ( + + + + } + caption={`${source}${megabytes(state.receivedBytes)} of ${megabytes(state.totalBytes)} MB`} + /> + ) +} + +const styles = StyleSheet.create({ + track: { + width: 180, + height: 4, + borderRadius: 2, + overflow: 'hidden', + backgroundColor: colors.bgRaised + }, + fill: { + width: '100%', + height: '100%', + transformOrigin: 'left', + backgroundColor: colors.textSecondary + } +}) diff --git a/mobile/src/mobile-web-shell/ShellWaitingFrame.test.tsx b/mobile/src/mobile-web-shell/ShellWaitingFrame.test.tsx index ec6d2ae1680..7ca15770d43 100644 --- a/mobile/src/mobile-web-shell/ShellWaitingFrame.test.tsx +++ b/mobile/src/mobile-web-shell/ShellWaitingFrame.test.tsx @@ -26,7 +26,8 @@ vi.mock('react-native', () => ({ create: (styles: unknown) => styles, absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 } }, - Text: 'Text' + Text: 'Text', + View: 'View' })) const { ShellPageCover } = await import('./ShellWaitingFrame') diff --git a/mobile/src/mobile-web-shell/ShellWaitingFrame.tsx b/mobile/src/mobile-web-shell/ShellWaitingFrame.tsx index ea7c5f06603..bb61cbfbc8b 100644 --- a/mobile/src/mobile-web-shell/ShellWaitingFrame.tsx +++ b/mobile/src/mobile-web-shell/ShellWaitingFrame.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { ActivityIndicator, Animated, Easing, StyleSheet, Text } from 'react-native' +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { ActivityIndicator, Animated, Easing, StyleSheet, Text, View } from 'react-native' import { colors, spacing, typography } from '../theme/mobile-theme' /** What the shell calls the wait from the bytes being on disk to the page having a frame. */ @@ -12,12 +12,29 @@ export const SHELL_OPENING_LABEL = 'Opening workspace' */ export const SHELL_PAGE_COVER_FADE_MS = 220 -/** The shell's neutral frame: one spinner and what it is waiting on. */ -export function ShellWaitingFrame({ label }: { label: string }) { +/** + * The shell's neutral frame: an indicator, a spinner unless the caller has a better one, and what it + * is waiting on. The indicator slot and the out-of-flow caption keep the label at one height across + * every waiting screen, whatever each one shows. + */ +export function ShellWaitingFrame({ + label, + indicator, + caption +}: { + label: string + indicator?: ReactNode + caption?: string +}) { return ( <> - - {label} + + {indicator ?? } + + + {label} + {caption !== undefined && {caption}} + ) } @@ -78,10 +95,29 @@ const styles = StyleSheet.create({ backgroundColor: colors.bgBase, paddingHorizontal: spacing.lg }, + indicatorSlot: { + height: 20, + justifyContent: 'center' + }, + labelRow: { + alignSelf: 'stretch' + }, label: { fontSize: typography.bodySize, color: colors.textSecondary, marginTop: spacing.md, textAlign: 'center' + }, + // Out of flow, so a frame with a caption centres at the same height as one without. + caption: { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + marginTop: spacing.sm, + textAlign: 'center', + fontSize: typography.metaSize, + color: colors.textMuted, + fontVariant: ['tabular-nums'] } }) diff --git a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx index ababfd2fc3e..91d3add4383 100644 --- a/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx +++ b/mobile/src/mobile-web-shell/catch-all-page-route-states.test.tsx @@ -261,7 +261,7 @@ describe('the screen the catch-all paints for each shell state', () => { it('says the host is unreachable rather than that the screen is unavailable', async () => { const tree = await renderRoute({ kind: 'offline' }) const text = textOf(tree) - expect(text).toContain('Connect to this host to download the workspace') + expect(text).toContain('Connect to this host to download the latest app') expect(text).not.toContain('This workspace screen is not available on this host.') }) diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx index 2f62e2e01ef..0d557e353f0 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx +++ b/mobile/src/mobile-web-shell/mobile-web-shell-screen-test-mocks.tsx @@ -8,8 +8,12 @@ import { SCREEN_SNAPSHOT, type ScreenDependencies } from './mobile-web-shell-scr export function screenModuleMocks(dependencies: ScreenDependencies) { return { 'react-native': () => ({ + AccessibilityInfo: { + isReduceMotionEnabled: () => Promise.resolve(false), + addEventListener: () => ({ remove: () => {} }) + }, ActivityIndicator: 'ActivityIndicator', - Easing: { in: (fn: unknown) => fn, quad: 'quad' }, + Easing: { in: (fn: unknown) => fn, quad: 'quad', linear: 'linear' }, // Enough of it for the cover to mount, fade and unmount. What the fade looks like is not this // test's business; that the cover is up until the page paints is, and that is the `visible` prop. Animated: { diff --git a/mobile/src/mobile-web-shell/use-eased-download-progress.test.tsx b/mobile/src/mobile-web-shell/use-eased-download-progress.test.tsx new file mode 100644 index 00000000000..cc57b5b4fcf --- /dev/null +++ b/mobile/src/mobile-web-shell/use-eased-download-progress.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type Glide = { toValue: number; duration: number } + +const motion: { reduced: boolean; glides: Glide[] } = vi.hoisted(() => ({ + reduced: false, + glides: [] +})) + +vi.mock('react-native', () => ({ + AccessibilityInfo: { + isReduceMotionEnabled: () => Promise.resolve(motion.reduced), + addEventListener: () => ({ remove: () => {} }) + }, + Easing: { linear: 'linear' }, + Animated: { + Value: class {}, + timing: (_value: unknown, { toValue, duration }: Glide) => ({ + start: () => { + motion.glides.push({ toValue, duration }) + } + }) + } +})) + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { useEasedDownloadProgress } from './use-eased-download-progress' + +function Probe({ fraction }: { fraction: number }) { + useEasedDownloadProgress(fraction) + return null +} + +let tree: ReactTestRenderer | null = null + +async function render(fraction: number): Promise { + await act(async () => { + if (tree === null) { + tree = create(createElement(Probe, { fraction })) + } else { + tree.update(createElement(Probe, { fraction })) + } + }) +} + +async function renderAt(ms: number, fraction: number): Promise { + vi.setSystemTime(ms) + await render(fraction) +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + motion.reduced = false + motion.glides = [] +}) + +afterEach(() => { + act(() => tree?.unmount()) + tree = null + vi.useRealTimers() +}) + +describe('useEasedDownloadProgress', () => { + it('glides to each update over the averaged gap, the first measured from the mount', async () => { + await render(0) + await renderAt(800, 0.25) + await renderAt(1200, 0.5) + expect(motion.glides).toEqual([ + { toValue: 0.25, duration: 800 }, + { toValue: 0.5, duration: 600 } + ]) + }) + + it('keeps the glide for a burst of replies instead of letting their tiny gaps shorten it', async () => { + const burst = async (startMs: number, fractions: number[]): Promise => { + for (const [index, fraction] of fractions.entries()) { + await renderAt(startMs + index * 5, fraction) + } + } + await render(0) + await burst(1600, [0.1, 0.15, 0.2, 0.25]) + await burst(3200, [0.35, 0.4, 0.45, 0.5]) + expect(motion.glides).toEqual( + [0.1, 0.15, 0.2, 0.25, 0.35, 0.4, 0.45, 0.5].map((toValue) => ({ toValue, duration: 1600 })) + ) + }) + + it('clamps the glide between 200 ms and 2 s', async () => { + await render(0) + await renderAt(50, 0.1) + await renderAt(5050, 0.2) + expect(motion.glides).toEqual([ + { toValue: 0.1, duration: 200 }, + { toValue: 0.2, duration: 2000 } + ]) + }) + + it('does not count the reduce-motion setting arriving after mount as an update', async () => { + // The setting resolves inside this render's act, re-running the effect with the same fraction. + await render(0.1) + await renderAt(1000, 0.2) + expect(motion.glides).toEqual([{ toValue: 0.2, duration: 1000 }]) + }) + + it('jumps straight to each update when reduce motion is on', async () => { + motion.reduced = true + await render(0) + await renderAt(500, 0.4) + expect(motion.glides).toEqual([{ toValue: 0.4, duration: 0 }]) + }) +}) diff --git a/mobile/src/mobile-web-shell/use-eased-download-progress.ts b/mobile/src/mobile-web-shell/use-eased-download-progress.ts new file mode 100644 index 00000000000..7c84efd99c2 --- /dev/null +++ b/mobile/src/mobile-web-shell/use-eased-download-progress.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from 'react' +import { Animated, Easing } from 'react-native' +import { useReducedMotionEnabled } from '../onboarding/use-reduced-motion' + +/** Each window reply of a concurrent batch is its own update, landing a few ms after the first. */ +const BURST_WINDOW_MS = 200 +const MIN_GLIDE_MS = 200 +const MAX_GLIDE_MS = 2000 + +/** + * The received fraction as the bar shows it: each update glides from what is on screen to the new + * value over the average gap between bursts of updates, so the fill keeps moving until the next + * burst lands instead of jumping and waiting. Mount is the download's start. + */ +export function useEasedDownloadProgress(fraction: number): Animated.Value { + const shown = useRef(new Animated.Value(fraction)).current + const [mountedAt] = useState(Date.now) + const burstAt = useRef(mountedAt) + const averageGapMs = useRef(null) + const target = useRef(fraction) + const reduceMotion = useReducedMotionEnabled() + + useEffect(() => { + if (fraction === target.current) { + return + } + const now = Date.now() + const sinceBurst = now - burstAt.current + // A reply inside the current burst says nothing about when the next burst comes. + if (averageGapMs.current === null || sinceBurst >= BURST_WINDOW_MS) { + averageGapMs.current = + averageGapMs.current === null ? sinceBurst : (averageGapMs.current + sinceBurst) / 2 + burstAt.current = now + } + target.current = fraction + Animated.timing(shown, { + toValue: fraction, + duration: + reduceMotion === true + ? 0 + : Math.min(MAX_GLIDE_MS, Math.max(MIN_GLIDE_MS, averageGapMs.current)), + easing: Easing.linear, + useNativeDriver: true + }).start() + }, [fraction, reduceMotion, shown]) + + return shown +} diff --git a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts index 4cc4afbb468..28564a7062e 100644 --- a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts +++ b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts @@ -35,8 +35,8 @@ describe('nativeMountingSubstitutes', () => { it('throws on a member nobody substituted rather than recording an undefined native API', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read itself is the assertion; the proxy has no declared shape. - expect(() => (substitute('expo-crypto') as { digest?: unknown }).digest).toThrow( - 'Unsubstituted native member: expo-crypto.digest' + expect(() => (substitute('expo-crypto') as { randomUUID?: unknown }).randomUUID).toThrow( + 'Unsubstituted native member: expo-crypto.randomUUID' ) }) }) diff --git a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts index 1f0428b8584..1739afdf5ab 100644 --- a/mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts +++ b/mobile/src/test-support/rpc-recording/native-mounting-substitutes.ts @@ -24,8 +24,9 @@ import { reactNativeScreenMembers, screenNativeSubstitutes } from './screen-nati * React additionally has to be the one instance the test renderer drives, which is also why * `react/jsx-runtime` is the real module: the automatic runtime a screen compiles to must build * elements for that instance. `@noble/hashes` is the same pure-JS digest the product would run on a - * device, and `expo-crypto` is routed through the Web Crypto the recording scheduler already pins, - * which is both deterministic and what the library itself does off-device. + * device, and also stands in for `expo-crypto`'s native SHA-256. `expo-crypto`'s random bytes are + * routed through the Web Crypto the recording scheduler already pins, which is both deterministic + * and what the library itself does off-device. * * Every substitute keeps one of the two trap shapes in `native-module-traps.ts`, which is where the * rule about unlisted members and `__esModule` lives. The view packages a mounted screen needs are @@ -77,7 +78,10 @@ export function nativeMountingSubstitutes(): Map { 'expo-crypto', partialNativeModule('expo-crypto', { getRandomBytes: (length: number) => - globalThis.crypto.getRandomValues(new Uint8Array(length)) + globalThis.crypto.getRandomValues(new Uint8Array(length)), + // Hashed before it resolves, as the native module's synchronous digest is. + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: async (_algorithm: string, data: Uint8Array) => sha256(data).buffer }) ], // The RN polyfill mobile bundles is this same pure implementation of the same encoding. diff --git a/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts b/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts index 953a37838f8..4a4b14aacf3 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch-pipeline.test.ts @@ -7,6 +7,15 @@ import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' import type { RpcClient } from './rpc-client' import type { RpcResponse } from './types' +// Hashed before it resolves, as the native module's synchronous digest is. +vi.mock('expo-crypto', async () => { + const { sha256: hash } = await import('@noble/hashes/sha256') + return { + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer + } +}) + const CHUNK_BYTES = 4 function toHex(bytes: Uint8Array): string { diff --git a/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts b/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts index 37cdf736df9..02431cdaf2d 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch-range.test.ts @@ -7,6 +7,15 @@ import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal' import type { RpcClient } from './rpc-client' import type { RpcResponse } from './types' +// Hashed before it resolves, as the native module's synchronous digest is. +vi.mock('expo-crypto', async () => { + const { sha256: hash } = await import('@noble/hashes/sha256') + return { + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer + } +}) + /** The grids this fake host names on its manifest reply. The phone pages whatever grid the host * names, so a few KiB exercises the same shapes as the real 384 KiB range and 48 KiB chunk; the * desktop suite pins that the real host names `MOBILE_WEB_BUNDLE_RANGE_BYTES`. */ diff --git a/mobile/src/transport/mobile-web-bundle-fetch.test.ts b/mobile/src/transport/mobile-web-bundle-fetch.test.ts index f914da89c07..33a52341b68 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch.test.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch.test.ts @@ -7,6 +7,15 @@ import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations' import type { RpcClient } from './rpc-client' import type { RpcResponse } from './types' +// Hashed before it resolves, as the native module's synchronous digest is. +vi.mock('expo-crypto', async () => { + const { sha256: hash } = await import('@noble/hashes/sha256') + return { + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer + } +}) + function toHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') } diff --git a/mobile/src/transport/mobile-web-bundle-fetch.ts b/mobile/src/transport/mobile-web-bundle-fetch.ts index fbc4db922bc..11f3bc5997e 100644 --- a/mobile/src/transport/mobile-web-bundle-fetch.ts +++ b/mobile/src/transport/mobile-web-bundle-fetch.ts @@ -1,4 +1,4 @@ -import { sha256 } from '@noble/hashes/sha256' +import { CryptoDigestAlgorithm, digest } from 'expo-crypto' import { mobileWebBundleManifestRead } from './mobile-web-bundle-operations' import type { MobileWebBundleAssetRead, @@ -36,7 +36,7 @@ export type MobileWebBundleFetchResult = { type AssetReassembly = { readonly entry: MobileWebBundleAssetRead - readonly whole: Uint8Array + readonly whole: Uint8Array outstandingWindows: number } @@ -86,7 +86,12 @@ export async function fetchMobileWebBundle(args: { asset.whole.set(bytes, offset) asset.outstandingWindows -= 1 if (asset.outstandingWindows === 0) { - assets.set(asset.entry.path, verifyReassembledAsset(asset)) + const verified = await verifyReassembledAsset(asset) + // A sibling failed while this hashed; the fetch already rejected with that failure. + if (stopped.signal.aborted) { + return + } + assets.set(asset.entry.path, verified) } // Per window, not per asset: the largest asset goes first, so asset completions bunch at the end. receivedBytes += bytes.byteLength @@ -191,12 +196,13 @@ function assertWindowFillsItsSlot( } /** Every slot was accepted exactly once, so only the hash is left to say the bytes are right. */ -function verifyReassembledAsset(asset: AssetReassembly): Uint8Array { - const digest = toHex(sha256(asset.whole)) - if (digest !== asset.entry.sha256) { +async function verifyReassembledAsset(asset: AssetReassembly): Promise { + // Native: hashing in JS held the JS thread for seconds, and React's progress renders waited on it. + const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, asset.whole))) + if (hashed !== asset.entry.sha256) { throw new MobileWebBundleFetchError( 'asset-checksum-mismatch', - `bundle asset ${asset.entry.path} hashed ${digest}, not ${asset.entry.sha256}` + `bundle asset ${asset.entry.path} hashed ${hashed}, not ${asset.entry.sha256}` ) } return asset.whole