From 903663022fca4e09284c51f166a77fca13913300 Mon Sep 17 00:00:00 2001 From: gxz Date: Thu, 15 Oct 2026 21:48:25 +0800 Subject: [PATCH 1/2] feat: demonstrate Android ultra-wide and Pixel zoom capture --- .../AgoraServicePackage.kt | 2 +- .../CameraCapabilitiesModule.kt | 47 ++++ .../CameraCapturerConfiguration/CSD-80336.md | 41 ++++ .../CameraCapturerConfiguration.tsx | 215 +++++++++++++++++- .../ultraWideCamera.ts | 85 +++++++ src/__tests__/UltraWideCameraDemo.test.ts | 164 +++++++++++++ src/__tests__/UltraWideCameraScreen.test.ts | 155 +++++++++++++ 7 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/CameraCapabilitiesModule.kt create mode 100644 examples/expo/app/examples/advanced/CameraCapturerConfiguration/CSD-80336.md create mode 100644 examples/expo/app/examples/advanced/CameraCapturerConfiguration/ultraWideCamera.ts create mode 100644 src/__tests__/UltraWideCameraDemo.test.ts create mode 100644 src/__tests__/UltraWideCameraScreen.test.ts diff --git a/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/AgoraServicePackage.kt b/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/AgoraServicePackage.kt index 9a5dcaddc..ace4b45a6 100644 --- a/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/AgoraServicePackage.kt +++ b/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/AgoraServicePackage.kt @@ -11,6 +11,6 @@ class AgoraServicePackage : ReactPackage { } override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(AgoraServiceManager(reactContext)) + return listOf(AgoraServiceManager(reactContext), CameraCapabilitiesModule(reactContext)) } } diff --git a/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/CameraCapabilitiesModule.kt b/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/CameraCapabilitiesModule.kt new file mode 100644 index 000000000..899881f03 --- /dev/null +++ b/examples/expo/android/app/src/main/java/com/reactnativeagoraexampleexpo/CameraCapabilitiesModule.kt @@ -0,0 +1,47 @@ +package com.reactnativeagoraexampleexpo + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.os.Build +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +/** Demo-only Camera2 metadata bridge. Does not open or take ownership of a camera. */ +class CameraCapabilitiesModule(private val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { + override fun getName() = "CameraCapabilities" + + @ReactMethod + fun getRearCameraZoomRanges(promise: Promise) { + if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + promise.reject("CAMERA_PERMISSION", "Grant camera permission before querying cameras") + return + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + promise.reject("ZOOM_RANGE_UNAVAILABLE", "Camera2 zoom ratio range requires Android 11 or later") + return + } + try { + val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + val cameras = Arguments.createArray() + for (cameraId in manager.cameraIdList) { + val characteristics = manager.getCameraCharacteristics(cameraId) + if (characteristics.get(CameraCharacteristics.LENS_FACING) != CameraCharacteristics.LENS_FACING_BACK) continue + val range = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE) ?: continue + val camera = Arguments.createMap() + camera.putString("cameraId", cameraId) + camera.putDouble("minZoom", range.lower.toDouble()) + camera.putDouble("maxZoom", range.upper.toDouble()) + cameras.pushMap(camera) + } + promise.resolve(cameras) + } catch (error: Exception) { + promise.reject("CAMERA_QUERY_FAILED", "Could not read Camera2 zoom ranges", error) + } + } +} diff --git a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CSD-80336.md b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CSD-80336.md new file mode 100644 index 000000000..bda4eb967 --- /dev/null +++ b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CSD-80336.md @@ -0,0 +1,41 @@ +# Android ultra-wide / Pixel zoom Demo + +Build the Expo Android app from this branch with a valid Agora App ID in +`examples/expo/src/config/agora.config.ts`. Open **Advanced → CameraCapturerConfiguration**. +Grant camera and microphone permission. A channel/token is not needed for local preview. + +Tap **Query capability and start ultra-wide preview**. The Demo always tests the rear camera: + +1. Call `queryCameraFocalLengthCapability`. If the returned entries advertise rear + `CameraFocalLengthUltraWide`, configure it using `cameraDirection`, then start preview. + The status displays `CameraFocalLengthApplied (5)` when confirmed by the SDK. + `CameraFocalLengthFallbackToDefault (6)` is displayed as a failure, not success. + If neither event arrives, the status remains waiting; do not treat it as passed. +2. If the SDK does not advertise rear ultra-wide, the Demo-only Android bridge reads + Camera2 `CONTROL_ZOOM_RATIO_RANGE` for the exposed rear logical cameras (Android 11+). + A camera must actually report a minimum below 1x. Configure that exact `cameraId` + without also specifying `cameraDirection`, start preview, then call + `setCameraZoomFactor` once capture is ready. Use the native minimum, bounded by + the SDK's 0.5x minimum. This path does **not** wait for `onLocalVideoEvent`. + Use **Compare: 1x** and **Apply native minimum** to check the wider field of view. + +The status and event log show the selected path, camera ID, native ranges and SDK +return code. Return 0 means the request was accepted; verify the actual picture. +Devices without a reported sub-1x range, Android versions below 11, missing +permission and API errors display an unsupported/error result instead of guessing 0.5x. + +Tap **Stop ultra-wide demo** before another attempt or before using the existing +manual camera configuration and channel join controls. Stop/release invalidates any +outstanding native query so leaving the screen cannot restart capture. + +## Required device verification + +- A non-Pixel Android device advertising rear ultra-wide: expect event 5, no event 6, + and a visibly wider preview. Record device/OS, capability and callback logs. +- Pixel: if SDK reports no rear ultra-wide but Camera2 reports a sub-1x range, + verify the native-query path, `setCameraZoomFactor` return code and 1x/sub-1x + pictures. No focal-length callback is required. +- A device without sub-1x support: expect an explicit unsupported result, no + unsupported zoom request. Also test permission denial and Stop during query. +- Run both React Native architectures. The bridge is part of the Expo Demo only; + it does not change the public RN SDK API or require an Iris change. diff --git a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx index 157a0724e..eb7c97b41 100644 --- a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx +++ b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx @@ -1,4 +1,5 @@ import React, { ReactElement } from 'react'; +import { NativeModules, Platform } from 'react-native'; import { CameraDirection, CameraFocalLengthType, @@ -7,6 +8,8 @@ import { ErrorCodeType, IRtcEngineEventHandler, LocalVideoEventType, + LocalVideoStreamReason, + LocalVideoStreamState, RtcConnection, RtcStats, UserOfflineReasonType, @@ -23,12 +26,20 @@ import { AgoraDivider, AgoraDropdown, AgoraSwitch, + AgoraText, AgoraTextInput, } from '../../../../src/components/ui'; import Config from '../../../../src/config/agora.config'; import { enumToItems } from '../../../../src/utils'; import { askMediaAccess } from '../../../../src/utils/permissions'; +import { + NativeCameraZoomRange, + UltraWidePlan, + queryUltraWidePlan, + startUltraWidePreview, +} from './ultraWideCamera'; + interface State extends BaseVideoComponentState { cameraDirection: CameraDirection; cameraFocalLengthType: CameraFocalLengthType; @@ -36,12 +47,19 @@ interface State extends BaseVideoComponentState { formatWidth: number; formatHeight: number; formatFps: number; + demoReady: boolean; + demoBusy: boolean; + demoStatus: string; } export default class CameraCapturerConfiguration extends BaseComponent<{}, State> implements IRtcEngineEventHandler { + private demoGeneration = 0; + private demoStarting = false; + private demoPlan?: UltraWidePlan; + protected createState(): State { return { appId: Config.appId, @@ -58,6 +76,10 @@ export default class CameraCapturerConfiguration formatWidth: 960, formatHeight: 540, formatFps: 15, + demoReady: false, + demoBusy: false, + demoStatus: + 'Start the Android ultra-wide demo without joining a channel.', }; } @@ -65,21 +87,140 @@ export default class CameraCapturerConfiguration const { appId } = this.state; if (!appId) { this.error('appId is invalid'); + this.setState({ demoStatus: 'Configure a valid Agora App ID first.' }); + return; } this.engine = createAgoraRtcEngine(); - this.engine.initialize({ + const initialized = this.engine.initialize({ appId, logConfig: { filePath: Config.logFilePath }, channelProfile: ChannelProfileType.ChannelProfileLiveBroadcasting, }); + if (initialized !== 0) { + this.setState({ demoStatus: `initialize failed: ${initialized}` }); + return; + } this.engine.registerEventHandler(this); - await askMediaAccess([ - 'android.permission.RECORD_AUDIO', - 'android.permission.CAMERA', - ]); - this.engine.enableVideo(); + const generation = this.demoGeneration; + try { + await askMediaAccess([ + 'android.permission.RECORD_AUDIO', + 'android.permission.CAMERA', + ]); + if (generation !== this.demoGeneration) return; + const enabled = this.engine.enableVideo(); + this.setState({ + demoReady: enabled === 0, + demoStatus: + enabled === 0 + ? 'Ready. Use the rear camera to test ultra-wide capture.' + : `enableVideo failed: ${enabled}`, + }); + } catch (error) { + if (generation === this.demoGeneration) { + this.setState({ demoStatus: String(error) }); + } + } + } + + protected async startUltraWideDemo() { + if ( + Platform.OS !== 'android' || + !this.engine || + !this.state.demoReady || + this.demoStarting || + this.state.startPreview || + this.state.joinChannelSuccess + ) + return; + this.demoStarting = true; + const generation = ++this.demoGeneration; + this.setState({ + demoBusy: true, + demoStatus: 'Querying SDK rear-camera capability…', + }); + try { + const plan = await queryUltraWidePlan(this.engine, async () => { + const native = NativeModules.CameraCapabilities; + if (!native?.getRearCameraZoomRanges) { + throw new Error( + 'Camera metadata bridge is unavailable. Rebuild the Android Demo.' + ); + } + const cameras: NativeCameraZoomRange[] = + await native.getRearCameraZoomRanges(); + this.info('Camera2 rear camera zoom ranges', cameras); + return cameras; + }); + if (generation !== this.demoGeneration) return; + this.demoPlan = plan; + this.info('Ultra-wide plan', plan); + this.setState({ + demoStatus: + plan.mode === 'focal' + ? 'SDK supports rear ultra-wide. Waiting for CameraFocalLengthApplied; FallbackToDefault is a failure.' + : `SDK reports no rear ultra-wide. Native camera ${plan.configuration.cameraId} supports ${plan.zoom}x. Starting preview; no focal-length callback is required.`, + }); + startUltraWidePreview(this.engine, plan); + this.setState({ startPreview: true }); + } catch (error) { + if (generation !== this.demoGeneration) return; + this.demoPlan = undefined; + this.engine?.stopPreview(); + this.setState({ startPreview: false, demoStatus: String(error) }); + this.error('Ultra-wide demo', error); + } finally { + if (generation === this.demoGeneration) { + this.demoStarting = false; + this.setState({ demoBusy: false }); + } + } + } + + protected stopUltraWideDemo() { + ++this.demoGeneration; + this.demoStarting = false; + this.demoPlan = undefined; + this.engine?.stopPreview(); + this.setState({ + startPreview: false, + demoBusy: false, + demoStatus: 'Stopped. Ready to query again.', + }); + } + + protected applyDemoZoom(factor: number) { + if (this.demoPlan?.mode !== 'zoom') return; + const result = this.engine?.setCameraZoomFactor(factor); + this.info('setCameraZoomFactor', factor, 'result', result); + this.setState({ + demoStatus: + result === 0 + ? `Zoom ${factor}x accepted (return 0). Compare the preview against 1x; no focal-length callback is required.` + : `setCameraZoomFactor(${factor}) failed: ${result}`, + }); + } + + onLocalVideoStateChanged( + source: VideoSourceType, + state: LocalVideoStreamState, + reason: LocalVideoStreamReason + ) { + this.info('onLocalVideoStateChanged', source, state, reason); + if (source !== VideoSourceType.VideoSourceCamera || !this.demoPlan) return; + if (state === LocalVideoStreamState.LocalVideoStreamStateFailed) { + this.setState({ + demoStatus: `Camera capture failed: ${LocalVideoStreamReason[reason]} (${reason})`, + }); + } else if ( + state === LocalVideoStreamState.LocalVideoStreamStateCapturing && + this.demoPlan.mode === 'zoom' + ) { + // Apply once capture is ready; never wait for a focal-length callback on this path. + this.applyDemoZoom(this.demoPlan.zoom); + } } protected joinChannel() { @@ -136,6 +277,9 @@ export default class CameraCapturerConfiguration } protected releaseRtcEngine() { + ++this.demoGeneration; + this.demoPlan = undefined; + this.engine?.stopPreview(); this.engine?.unregisterEventHandler(this); this.engine?.release(); } @@ -176,6 +320,28 @@ export default class CameraCapturerConfiguration 'eventName', LocalVideoEventType[event] ); + if ( + source === VideoSourceType.VideoSourceCamera && + this.demoPlan?.mode === 'focal' + ) { + if ( + event === + LocalVideoEventType.LocalVideoEventTypeCameraFocalLengthApplied + ) { + this.setState({ + demoStatus: + 'CameraFocalLengthApplied (5): SDK confirmed rear ultra-wide was applied. Verify the preview.', + }); + } else if ( + event === + LocalVideoEventType.LocalVideoEventTypeCameraFocalLengthFallbackToDefault + ) { + this.setState({ + demoStatus: + 'CameraFocalLengthFallbackToDefault (6): ultra-wide failed; SDK fell back to the default camera.', + }); + } + } } protected renderChannel(): ReactElement | undefined { @@ -202,6 +368,7 @@ export default class CameraCapturerConfiguration value={uid.toString()} /> { joinChannelSuccess ? this.leaveChannel() : this.joinChannel(); @@ -222,6 +389,42 @@ export default class CameraCapturerConfiguration const editable = !startPreview && !joinChannelSuccess; return ( <> + {Platform.OS === 'android' && ( + <> + + {'Android ultra-wide / Pixel zoom demo (rear camera)'} + + {this.state.demoStatus} + this.startUltraWideDemo()} + /> + this.stopUltraWideDemo()} + /> + {startPreview && this.demoPlan?.mode === 'zoom' && ( + <> + this.applyDemoZoom(1)} + /> + { + if (this.demoPlan?.mode === 'zoom') + this.applyDemoZoom(this.demoPlan.zoom); + }} + /> + + )} + + + )} , + getRearCameraZoomRanges: () => Promise +): Promise { + const capability = engine.queryCameraFocalLengthCapability(); + if ( + !Array.isArray(capability?.focalLengthInfos) || + !Number.isInteger(capability.size) || + capability.size < 0 || + capability.size > capability.focalLengthInfos.length + ) { + throw new Error('SDK focal-length capability query failed'); + } + const supportsUltraWide = capability.focalLengthInfos + .slice(0, capability.size) + .some( + (info) => + info.cameraDirection === CameraDirection.CameraRear && + info.focalLengthType === + CameraFocalLengthType.CameraFocalLengthUltraWide + ); + if (supportsUltraWide) { + return { + mode: 'focal', + configuration: { + cameraDirection: CameraDirection.CameraRear, + cameraFocalLengthType: CameraFocalLengthType.CameraFocalLengthUltraWide, + }, + }; + } + + const cameras = await getRearCameraZoomRanges(); + const camera = cameras + .filter( + ({ cameraId, minZoom, maxZoom }) => + cameraId.length > 0 && + Number.isFinite(minZoom) && + Number.isFinite(maxZoom) && + minZoom > 0 && + minZoom < 1 && + maxZoom >= 1 + ) + .sort((a, b) => a.minZoom - b.minZoom)[0]; + if (!camera) { + throw new Error('No rear camera exposes a native zoom range below 1x'); + } + return { + mode: 'zoom', + // SDK supports a minimum of 0.5x. Never go below the native camera minimum. + zoom: Math.max(0.5, camera.minZoom), + // Match the queried logical camera. cameraId and cameraDirection are exclusive. + configuration: { cameraId: camera.cameraId }, + }; +} + +export function startUltraWidePreview( + engine: Pick, + plan: UltraWidePlan +) { + const configured = engine.setCameraCapturerConfiguration(plan.configuration); + if (configured !== 0) { + throw new Error(`setCameraCapturerConfiguration failed: ${configured}`); + } + const started = engine.startPreview(); + if (started !== 0) { + throw new Error(`startPreview failed: ${started}`); + } +} diff --git a/src/__tests__/UltraWideCameraDemo.test.ts b/src/__tests__/UltraWideCameraDemo.test.ts new file mode 100644 index 000000000..b5254321d --- /dev/null +++ b/src/__tests__/UltraWideCameraDemo.test.ts @@ -0,0 +1,164 @@ +import { + queryUltraWidePlan, + startUltraWidePreview, +} from '../../examples/expo/app/examples/advanced/CameraCapturerConfiguration/ultraWideCamera'; +import { CameraFocalLengthType } from '../AgoraBase'; +import { CameraDirection } from '../IAgoraRtcEngine'; + +jest.mock( + 'react-native-agora', + () => ({ + ...jest.requireActual('../AgoraBase'), + ...jest.requireActual('../IAgoraRtcEngine'), + }), + { virtual: true } +); + +describe('CSD-80336 ultra-wide camera selection', () => { + const unsupported = { + queryCameraFocalLengthCapability: () => ({ focalLengthInfos: [], size: 0 }), + }; + + it('uses advertised rear ultra-wide without querying native zoom', async () => { + const native = jest.fn(); + const plan = await queryUltraWidePlan( + { + queryCameraFocalLengthCapability: () => ({ + focalLengthInfos: [ + { + cameraDirection: CameraDirection.CameraRear, + focalLengthType: CameraFocalLengthType.CameraFocalLengthUltraWide, + }, + ], + size: 1, + }), + }, + native + ); + expect(plan.mode).toBe('focal'); + expect(plan.configuration).toEqual({ + cameraDirection: CameraDirection.CameraRear, + cameraFocalLengthType: CameraFocalLengthType.CameraFocalLengthUltraWide, + }); + expect(native).not.toHaveBeenCalled(); + }); + + it('does not mistake a front ultra-wide camera for rear support', async () => { + const native = jest + .fn() + .mockResolvedValue([ + { cameraId: 'rear-logical', minZoom: 0.6, maxZoom: 8 }, + ]); + const plan = await queryUltraWidePlan( + { + queryCameraFocalLengthCapability: () => ({ + focalLengthInfos: [ + { + cameraDirection: CameraDirection.CameraFront, + focalLengthType: CameraFocalLengthType.CameraFocalLengthUltraWide, + }, + ], + size: 1, + }), + }, + native + ); + expect(native).toHaveBeenCalledTimes(1); + expect(plan).toEqual({ + mode: 'zoom', + zoom: 0.6, + configuration: { cameraId: 'rear-logical' }, + }); + expect(plan.configuration.cameraDirection).toBeUndefined(); + }); + + it('honors returned size and clamps native zoom to the SDK minimum', async () => { + const plan = await queryUltraWidePlan( + { + queryCameraFocalLengthCapability: () => ({ + focalLengthInfos: [ + { + cameraDirection: CameraDirection.CameraRear, + focalLengthType: CameraFocalLengthType.CameraFocalLengthUltraWide, + }, + ], + size: 0, + }), + }, + async () => [ + { cameraId: 'tele', minZoom: 1, maxZoom: 4 }, + { cameraId: 'invalid', minZoom: 0, maxZoom: 4 }, + { cameraId: 'logical', minZoom: 0.4, maxZoom: 8 }, + ] + ); + expect(plan).toEqual({ + mode: 'zoom', + zoom: 0.5, + configuration: { cameraId: 'logical' }, + }); + }); + + it('reports unsupported native zoom instead of inventing 0.5x support', async () => { + await expect( + queryUltraWidePlan(unsupported, async () => [ + { cameraId: '0', minZoom: 1, maxZoom: 10 }, + ]) + ).rejects.toThrow('No rear camera'); + }); + + it('does not treat an SDK query failure as lack of capability', async () => { + const native = jest.fn(); + await expect( + queryUltraWidePlan( + { + queryCameraFocalLengthCapability: () => ({ + focalLengthInfos: [], + size: -1, + }), + }, + native + ) + ).rejects.toThrow('query failed'); + expect(native).not.toHaveBeenCalled(); + }); + + it('preserves permission/native query errors', async () => { + await expect( + queryUltraWidePlan(unsupported, async () => { + throw new Error('CAMERA_PERMISSION'); + }) + ).rejects.toThrow('CAMERA_PERMISSION'); + }); + + it('configures before preview and stops on SDK errors', () => { + const calls: string[] = []; + const engine = { + setCameraCapturerConfiguration: jest.fn(() => { + calls.push('configure'); + return 0; + }), + startPreview: jest.fn(() => { + calls.push('preview'); + return 0; + }), + }; + const plan = { + mode: 'zoom' as const, + zoom: 0.5, + configuration: { cameraId: '0' }, + }; + startUltraWidePreview(engine, plan); + expect(calls).toEqual(['configure', 'preview']); + engine.startPreview.mockClear(); + engine.setCameraCapturerConfiguration.mockReturnValue(-2); + expect(() => startUltraWidePreview(engine, plan)).toThrow( + 'Configuration failed: -2' + ); + expect(engine.startPreview).not.toHaveBeenCalled(); + engine.setCameraCapturerConfiguration.mockReturnValue(0); + engine.startPreview.mockReturnValue(-4); + expect(() => startUltraWidePreview(engine, plan)).toThrow( + 'startPreview failed: -4' + ); + }); +}); diff --git a/src/__tests__/UltraWideCameraScreen.test.ts b/src/__tests__/UltraWideCameraScreen.test.ts new file mode 100644 index 000000000..bd1748559 --- /dev/null +++ b/src/__tests__/UltraWideCameraScreen.test.ts @@ -0,0 +1,155 @@ +import CameraScreen from '../../examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration'; +import { + LocalVideoEventType, + LocalVideoStreamReason, + LocalVideoStreamState, +} from '../AgoraBase'; +import { VideoSourceType } from '../AgoraMediaBase'; +import { CameraDirection, IRtcEngine } from '../IAgoraRtcEngine'; + +// The Expo workspace uses a different RN version from the root Jest preset. +jest.mock( + require.resolve('react-native', { + paths: [require('path').resolve(__dirname, '../../examples/expo')], + }), + () => ({ + NativeModules: {}, + Platform: { OS: 'android' }, + }) +); + +jest.mock( + 'react-native-agora', + () => ({ + ...jest.requireActual('../AgoraBase'), + ...jest.requireActual('../AgoraMediaBase'), + ...jest.requireActual('../IAgoraRtcEngine'), + }), + { virtual: true } +); +jest.mock('../../examples/expo/src/components/BaseComponent', () => ({ + BaseComponent: class extends require('react').Component { + constructor(props: unknown) { + super(props); + this.state = this.createState(); + } + info() {} + error() {} + }, +})); +jest.mock('../../examples/expo/src/components/ui', () => ({})); +jest.mock('../../examples/expo/src/config/agora.config', () => ({ + appId: 'test', +})); +jest.mock('../../examples/expo/src/utils', () => ({})); +jest.mock('../../examples/expo/src/utils/permissions', () => ({})); + +const { NativeModules } = jest.requireMock( + require.resolve('react-native', { + paths: [require('path').resolve(__dirname, '../../examples/expo')], + }) +); + +class CameraDemo extends CameraScreen { + prepare(engine: IRtcEngine) { + this.engine = engine; + Object.assign(this.state, { demoReady: true }); + this.setState = (update) => { + if (update && typeof update !== 'function') + Object.assign(this.state, update); + }; + } + start() { + return this.startUltraWideDemo(); + } + stop() { + this.stopUltraWideDemo(); + } +} + +describe('Ultra-wide screen workflow', () => { + function setup() { + const engine = { + queryCameraFocalLengthCapability: jest + .fn() + .mockReturnValue({ focalLengthInfos: [], size: 0 }), + setCameraCapturerConfiguration: jest.fn().mockReturnValue(0), + startPreview: jest.fn().mockReturnValue(0), + stopPreview: jest.fn().mockReturnValue(0), + setCameraZoomFactor: jest.fn().mockReturnValue(0), + }; + const screen = new CameraDemo({}); + screen.prepare(engine as unknown as IRtcEngine); + NativeModules.CameraCapabilities = { + getRearCameraZoomRanges: jest + .fn() + .mockResolvedValue([{ cameraId: '0', minZoom: 0.6, maxZoom: 8 }]), + }; + return { screen, engine }; + } + + it('only confirms the focal path when event 5 arrives, and exposes event 6 as failure', async () => { + const { screen, engine } = setup(); + engine.queryCameraFocalLengthCapability.mockReturnValue({ + focalLengthInfos: [ + { cameraDirection: CameraDirection.CameraRear, focalLengthType: 2 }, + ], + size: 1, + }); + await screen.start(); + expect(screen.state.demoStatus).toContain('Waiting'); + screen.onLocalVideoEvent( + VideoSourceType.VideoSourceCamera, + LocalVideoEventType.LocalVideoEventTypeCameraFocalLengthApplied + ); + expect(screen.state.demoStatus).toContain('SDK confirmed'); + screen.onLocalVideoEvent( + VideoSourceType.VideoSourceCamera, + LocalVideoEventType.LocalVideoEventTypeCameraFocalLengthFallbackToDefault + ); + expect(screen.state.demoStatus).toContain('ultra-wide failed'); + expect( + NativeModules.CameraCapabilities.getRearCameraZoomRanges + ).not.toHaveBeenCalled(); + }); + + it('applies native zoom on capture readiness without a focal-length callback', async () => { + const { screen, engine } = setup(); + await screen.start(); + expect(engine.setCameraCapturerConfiguration).toHaveBeenCalledWith({ + cameraId: '0', + }); + expect(engine.setCameraZoomFactor).not.toHaveBeenCalled(); + screen.onLocalVideoStateChanged( + VideoSourceType.VideoSourceCamera, + LocalVideoStreamState.LocalVideoStreamStateCapturing, + LocalVideoStreamReason.LocalVideoStreamReasonOk + ); + expect(engine.setCameraZoomFactor).toHaveBeenCalledWith(0.6); + expect(screen.state.demoStatus).toContain('accepted'); + screen.onLocalVideoEvent( + VideoSourceType.VideoSourceCamera, + LocalVideoEventType.LocalVideoEventTypeCameraFocalLengthFallbackToDefault + ); + expect(screen.state.demoStatus).toContain('accepted'); + }); + + it('does not restart a stopped Demo when the native query completes late', async () => { + const { screen, engine } = setup(); + let resolveQuery!: ( + value: { cameraId: string; minZoom: number; maxZoom: number }[] + ) => void; + NativeModules.CameraCapabilities.getRearCameraZoomRanges.mockReturnValue( + new Promise((resolve) => { + resolveQuery = resolve; + }) + ); + const starting = screen.start(); + screen.stop(); + resolveQuery([{ cameraId: '0', minZoom: 0.5, maxZoom: 8 }]); + await starting; + expect(engine.startPreview).not.toHaveBeenCalled(); + expect(screen.state.startPreview).toBe(false); + expect(screen.state.demoStatus).toContain('Stopped'); + }); +}); From a3991e33f24165018d58b2e06ab0abd5f5f4a19f Mon Sep 17 00:00:00 2001 From: gxz Date: Thu, 15 Oct 2026 21:50:26 +0800 Subject: [PATCH 2/2] fix: keep camera helpers outside Expo routes --- .../CameraCapturerConfiguration/CameraCapturerConfiguration.tsx | 2 +- .../utils}/ultraWideCamera.ts | 0 src/__tests__/UltraWideCameraDemo.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename examples/expo/{app/examples/advanced/CameraCapturerConfiguration => src/utils}/ultraWideCamera.ts (100%) diff --git a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx index eb7c97b41..d9a9660c1 100644 --- a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx +++ b/examples/expo/app/examples/advanced/CameraCapturerConfiguration/CameraCapturerConfiguration.tsx @@ -38,7 +38,7 @@ import { UltraWidePlan, queryUltraWidePlan, startUltraWidePreview, -} from './ultraWideCamera'; +} from '../../../../src/utils/ultraWideCamera'; interface State extends BaseVideoComponentState { cameraDirection: CameraDirection; diff --git a/examples/expo/app/examples/advanced/CameraCapturerConfiguration/ultraWideCamera.ts b/examples/expo/src/utils/ultraWideCamera.ts similarity index 100% rename from examples/expo/app/examples/advanced/CameraCapturerConfiguration/ultraWideCamera.ts rename to examples/expo/src/utils/ultraWideCamera.ts diff --git a/src/__tests__/UltraWideCameraDemo.test.ts b/src/__tests__/UltraWideCameraDemo.test.ts index b5254321d..68e8995a7 100644 --- a/src/__tests__/UltraWideCameraDemo.test.ts +++ b/src/__tests__/UltraWideCameraDemo.test.ts @@ -1,7 +1,7 @@ import { queryUltraWidePlan, startUltraWidePreview, -} from '../../examples/expo/app/examples/advanced/CameraCapturerConfiguration/ultraWideCamera'; +} from '../../examples/expo/src/utils/ultraWideCamera'; import { CameraFocalLengthType } from '../AgoraBase'; import { CameraDirection } from '../IAgoraRtcEngine';