Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion packages/@expo/hub-client/src/__tests__/android-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,57 @@ import {
androidStreamSourceErrorMessage,
parseAndroidStreamSource,
} from '../android-stream-source';
import { androidWsUrlFor, parseServeEmuStreamSettings } from '../useAndroidDevice';
import {
androidWsUrlFor,
parseServeEmuStreamSettings,
parseServeEmuViewerStreamSettings,
} from '../useAndroidDevice';

describe('serve-emu stream contract', () => {
const webRtcSettings = {
transport: 'webrtc',
codec: 'h264',
iceServers: [{ urls: ['turn:relay.test'], username: 'hub', credential: 'secret' }],
iceTransportPolicy: 'relay',
} satisfies ReturnType<typeof parseServeEmuViewerStreamSettings>;

test('offers the advertised WebRTC profile when the host defaults to WebSocket', () => {
expect(
parseServeEmuViewerStreamSettings({
stream: { transport: 'websocket' },
viewerTransports: {
default: 'websocket',
available: ['websocket', 'webrtc'],
webrtc: webRtcSettings,
},
}),
).toEqual(webRtcSettings);
});

test('keeps legacy hosts compatible when viewer capabilities are absent', () => {
expect(parseServeEmuViewerStreamSettings({ stream: webRtcSettings })).toEqual(webRtcSettings);
expect(parseServeEmuViewerStreamSettings({ stream: { transport: 'websocket' } })).toEqual({
transport: 'websocket',
});
});

test('does not enable WebRTC from launch settings when the catalog excludes it or is invalid', () => {
for (const viewerTransports of [
{ available: ['websocket'], webrtc: null },
{ available: ['websocket'], webrtc: webRtcSettings },
{ available: ['websocket', 'webrtc'], webrtc: null },
{ available: ['webrtc'], webrtc: { ...webRtcSettings, codec: 'vp8' } },
{ available: ['webrtc'], webrtc: { ...webRtcSettings, iceTransportPolicy: 'invalid' } },
{ available: 'webrtc', webrtc: webRtcSettings },
null,
[],
]) {
expect(parseServeEmuViewerStreamSettings({ stream: webRtcSettings, viewerTransports })).toEqual({
transport: 'websocket',
});
}
});

test('uses a metadata video socket for H.264 and an input-only socket for WebRTC', () => {
expect(androidWsUrlFor('http://localhost:3400/vendor/serve-emu', 'emulator-5554', true)).toBe(
'ws://localhost:3400/vendor/serve-emu/ws?frame-meta=1&device=emulator-5554',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Peer {
closeCount = 0;
ontrack?: (event: { streams: object[]; track: object }) => void;

constructor() {
constructor(readonly configuration?: RTCConfiguration) {
Peer.instances.push(this);
}
addTransceiver() {
Expand Down Expand Up @@ -198,7 +198,7 @@ class Video extends EventTarget {
}
}

async function androidHarness({ delaySource = false } = {}) {
async function androidHarness({ delaySource = false, websocketDefault = false } = {}) {
stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const timers = new Map<number, { callback: () => void; delay: number }>();
let timerId = 0;
Expand Down Expand Up @@ -256,21 +256,41 @@ async function androidHarness({ delaySource = false } = {}) {
}
if (path === '/api/stream-mode') return delaySource ? sourceRead : Response.json(source);
if (path === '/api') {
const webrtc = {
transport: 'webrtc',
codec: 'h264',
iceServers: [{ urls: ['stun:host.test'] }],
iceTransportPolicy: 'all',
};
return Response.json({
stream: { transport: 'webrtc', codec: 'h264', iceServers: [], iceTransportPolicy: 'all' },
stream: websocketDefault ? { transport: 'websocket' } : webrtc,
...(websocketDefault
? { viewerTransports: { default: 'websocket', available: ['websocket', 'webrtc'], webrtc } }
: {}),
});
}
if (path === '/webrtc/offer') return Response.json({ type: 'answer', sdp: 'answer' });
return Response.json({}, { status: 404 });
});
let client!: ReturnType<typeof useAndroidDeviceClient>;
function Harness({ device = 'emulator-test' }: { device?: string }) {
client = useAndroidDeviceClient({ baseUrl: 'https://hub.test', device, streamMode: 'webrtc' });
function Harness({
device = 'emulator-test',
streamMode = 'webrtc',
}: {
device?: string;
streamMode?: 'h264' | 'webrtc';
}) {
client = useAndroidDeviceClient({ baseUrl: 'https://hub.test', device, streamMode });
return null;
}
await act(async () => {
renderer = create(<Harness />);
renderer = create(<Harness streamMode={websocketDefault ? 'h264' : 'webrtc'} />);
});
if (websocketDefault) {
expect(client.streamCapabilities?.modeAvailability.webrtc).toBe(true);
expect(Peer.instances).toHaveLength(0);
await act(async () => renderer!.update(<Harness streamMode="webrtc" />));
}
const attach = async (video: Video) => {
await act(async () => client.attachVideo(video as unknown as HTMLVideoElement));
};
Expand Down Expand Up @@ -317,6 +337,16 @@ async function androidHarness({ delaySource = false } = {}) {
};
}

test('Android streams over WebRTC with host ICE settings when the CLI defaults to WebSocket', async () => {
const hub = await androidHarness({ websocketDefault: true });
expect(hub.client.streamCapabilities?.modeAvailability.webrtc).toBe(true);
expect(hub.client.videoKind).toBe('video');
expect(Peer.instances).toHaveLength(1);
expect(Peer.instances[0]!.configuration?.iceServers).toEqual([{ urls: ['stun:host.test'] }]);
expect(ControlSocket.instances).toHaveLength(1);
expect(new URL(ControlSocket.instances[0]!.url).searchParams.get('video')).toBe('0');
});

test('attaching and remounting the Android video keeps one control socket and uses the latest node', async () => {
const hub = await androidHarness();
expect(ControlSocket.instances).toHaveLength(1);
Expand Down
28 changes: 24 additions & 4 deletions packages/@expo/hub-client/src/useAndroidDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ type ServeEmuStreamSettings =
type ServeEmuApiInfo = {
size?: { width?: unknown; height?: unknown };
stream?: unknown;
viewerTransports?: unknown;
};

function isIceServer(value: unknown): value is WebRtcIceServer {
Expand Down Expand Up @@ -190,6 +191,26 @@ export function parseServeEmuStreamSettings(value: unknown): ServeEmuStreamSetti
};
}

/** Prefer advertised viewer capabilities; older hosts only expose their launch settings. */
export function parseServeEmuViewerStreamSettings(info: ServeEmuApiInfo): ServeEmuStreamSettings {
if (info.viewerTransports === undefined) {
return parseServeEmuStreamSettings(info.stream) ?? { transport: 'websocket' };
}
const catalog = info.viewerTransports;
if (catalog && typeof catalog === 'object' && !Array.isArray(catalog)) {
const { available, webrtc } = catalog as Record<string, unknown>;
const settings = parseServeEmuStreamSettings(webrtc);
if (
Array.isArray(available) &&
available.includes('webrtc') &&
settings?.transport === 'webrtc'
) {
return settings;
}
}
return { transport: 'websocket' };
}

export function useAndroidDeviceClient(options: DeviceConnectionOptions): DeviceClient {
const { baseUrl, enabled = true, device: targetDevice = null, streamMode } = options;
const active = enabled && !!baseUrl;
Expand Down Expand Up @@ -735,9 +756,8 @@ export function useAndroidDeviceClient(options: DeviceConnectionOptions): Device
}, [refreshStreamSettings, refreshStreamSource, streamSettingsUrl, streamSourceUrl]);

// ── Stream metadata ──
// serve-emu locks its host transport at launch. Poll the device-scoped API so
// the viewer only offers WebRTC when that transport is actually configured,
// and so the peer uses the host's ICE servers/policy rather than client input.
// Poll the device-scoped capabilities independently of the host's default
// transport, and use the advertised ICE servers/policy for WebRTC peers.
useEffect(() => {
setServerStreamSettings(null);
if (!active || !baseUrl) return;
Expand All @@ -756,7 +776,7 @@ export function useAndroidDeviceClient(options: DeviceConnectionOptions): Device
if (!response.ok) return;
const info = (await response.json()) as ServeEmuApiInfo;
if (cancelled) return;
const next = parseServeEmuStreamSettings(info.stream) ?? { transport: 'websocket' };
const next = parseServeEmuViewerStreamSettings(info);
setServerStreamSettings((current) =>
JSON.stringify(current) === JSON.stringify(next) ? current : next,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export function StreamOptionsSection({
</SectionNote>
)}
{hostWebRtcDisabled && (
<SectionNote>Start the standalone server with --transport webrtc to enable WebRTC.</SectionNote>
<SectionNote>WebRTC is unavailable for the current device stream.</SectionNote>
)}
{settingsCapabilities && (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -983,7 +983,7 @@ test('hides WebRTC statistics when another transport is active', () => {
expect(html).not.toContain('role="img"');
});

test('explains when the Android host was not launched with WebRTC', () => {
test('explains when WebRTC is unavailable for the Android stream', () => {
const client = {
...inspectorClient('android'),
streamCapabilities: {
Expand All @@ -1005,7 +1005,8 @@ test('explains when the Android host was not launched with WebRTC', () => {
);

expect(selectValue(html, 'Stream transport')).toBe('WebSocket');
expect(html).toContain('Start the standalone server with --transport webrtc');
expect(html).toContain('WebRTC is unavailable for the current device stream.');
expect(html).not.toContain('--transport webrtc');
});

test('renders every iOS device option as a select pill sized by its options', () => {
Expand Down