@chipmobilesdk/rn-camera 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +28 -2
- package/package.json +10 -2
- package/skills/sdk-rn-camera/SKILL.md +2 -0
- package/src/core/controller.ts +33 -27
- package/src/diagnostics/errors.ts +17 -15
- package/src/diagnostics/logger.ts +22 -1
- package/src/native/cameraEngine.ts +7 -2
- package/src/native/visionCameraPeer.ts +30 -0
- package/src/permissions/permissionPort.ts +24 -20
- package/src/react/CameraPreview.tsx +13 -2
- package/src/react/VisionCameraFeed.tsx +9 -5
- package/src/testing/conformance.ts +140 -0
- package/src/testing/index.ts +27 -0
- package/src/testing/testCameraAdapter.ts +18 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
1
1
|
# @chipmobilesdk/rn-camera
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- One diagnostic stream: install a sink with `installSdkSink` from
|
|
8
|
+
`@chipmobilesdk/rn-core` and it receives what every package observes (events,
|
|
9
|
+
failures, warnings) as `SdkDiagnostic`s, redacted. Listeners are notified through
|
|
10
|
+
rn-core, so a listener that throws no longer stops the others nor fails the call that
|
|
11
|
+
notified, and its error is reported rather than swallowed. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
12
|
+
|
|
13
|
+
- rn-ads: a banner's auto-refresh is a new cycle, so its `loaded` and `impression`
|
|
14
|
+
events are no longer deduplicated away; the dedupe memory is bounded. A throwing
|
|
15
|
+
status, consent or placement listener is reported as an `internal_error` event.
|
|
16
|
+
- rn-i18n: new `onDiagnostic` config receives every diagnostic (`I18nDiagnostic`,
|
|
17
|
+
with a stable `code`); without it, warnings go to `console.warn` in development
|
|
18
|
+
builds only. The warn-once memory is bounded.
|
|
19
|
+
- rn-chart: now depends on rn-core for its diagnostics.
|
|
20
|
+
- rn-payment: status, entitlement and pending listeners report a throw to the logger
|
|
21
|
+
instead of swallowing it.
|
|
22
|
+
|
|
23
|
+
- The package's errors extend `SdkError` from the new `@chipmobilesdk/rn-core`
|
|
24
|
+
dependency: `isSdkError` recognises them alongside every other package's, and each
|
|
25
|
+
carries `pkg`, `code` and `retryable`. Names and codes are unchanged. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
26
|
+
|
|
27
|
+
- rn-ai and rn-camera: `retryable` is the new name of `recoverable`, which stays as a
|
|
28
|
+
deprecated alias.
|
|
29
|
+
- rn-auth: `retryable` is true for `network-unavailable`. `redact` also removes URLs,
|
|
30
|
+
e-mail addresses and UUIDs.
|
|
31
|
+
- rn-ads: `redact` also removes JWTs and e-mail addresses, and reduces an `AdsError`
|
|
32
|
+
to its code.
|
|
33
|
+
- rn-ai: an error message quoted from the gateway is pattern-redacted.
|
|
34
|
+
- rn-camera: `redact` also removes JWTs, e-mail addresses and UUIDs.
|
|
35
|
+
- rn-theme: `ThemeConfigError` has `code: 'invalid_config'` and `ThemeContrastError`
|
|
36
|
+
`code: 'contrast'` (new `ThemeErrorCode` type).
|
|
37
|
+
- rn-i18n: `I18nConfigError` has `code: 'invalid_config'` and `UnitConversionError`
|
|
38
|
+
`code: 'unit_conversion'` (new `I18nErrorCode` type).
|
|
39
|
+
|
|
40
|
+
- Native peers load through `lazyPeer` from `@chipmobilesdk/rn-core`, on first use,
|
|
41
|
+
and a missing one is each package's own typed error. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
42
|
+
|
|
43
|
+
- rn-auth: creating `auth` no longer loads react-native-app-auth or
|
|
44
|
+
react-native-keychain; each loads on first use. New entrypoints
|
|
45
|
+
`@chipmobilesdk/rn-auth/app-auth` and `/keychain`. The default platform comes from
|
|
46
|
+
rn-core.
|
|
47
|
+
- rn-camera: a preview whose VisionCamera feed cannot load fails the session with
|
|
48
|
+
`cameraUnavailable` (new `controller.reportSurfaceUnavailable()`), instead of
|
|
49
|
+
showing black with no error. A missing VisionCamera module is `cameraUnavailable`.
|
|
50
|
+
- rn-notification: `createMmkvStore` reports a fallback to memory as
|
|
51
|
+
`STATE_STORE_DEGRADED` (to the SDK sinks and a new `onDegraded` option) and
|
|
52
|
+
exposes `durable`.
|
|
53
|
+
- rn-payment: an iOS app validates its catalog against the iOS product ids; the
|
|
54
|
+
default platform used to be Android everywhere. A missing react-native-iap is
|
|
55
|
+
`PAYMENT_UNAVAILABLE`.
|
|
56
|
+
- rn-theme: `buildThemes` runs without React Native (new `hairlineWidth` option) and
|
|
57
|
+
importing the package no longer loads Unistyles; `applyThemeMode` loads it on
|
|
58
|
+
first use.
|
|
59
|
+
- rn-ads, rn-local-db: the same loading, with the same error codes as before.
|
|
60
|
+
|
|
61
|
+
- Port conformance suites: one list of checks that a package's fake and its real adapter
|
|
62
|
+
both pass, so a test written against the fake proves something about the device. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
63
|
+
|
|
64
|
+
- rn-core: `@chipmobilesdk/rn-core/testing` adds `runChecks`, `expectConformance`,
|
|
65
|
+
`ensure` and `createFaultRegistry` (deterministic fault injection by named point).
|
|
66
|
+
- rn-camera: a `./testing` entrypoint with `runPermissionPortConformance`,
|
|
67
|
+
`runCameraEngineConformance` and the test adapter, which stays on the main
|
|
68
|
+
entrypoint too until the next major. `createTestCamera(...).prompts()` counts system
|
|
69
|
+
prompts.
|
|
70
|
+
- rn-payment: `runProviderPortConformance` in `./testing`, passed by the fake store and
|
|
71
|
+
the Play adapter.
|
|
72
|
+
- rn-ads: `runAdsEngineConformance` in `./testing`, passed by the fake engine and the
|
|
73
|
+
AdMob adapter.
|
|
74
|
+
- rn-auth: a `./testing` entrypoint with `createMemorySecureStore`,
|
|
75
|
+
`runSecureTokenStoreConformance` and `runOidcAuthClientConformance`, passed by the
|
|
76
|
+
in-memory doubles and the keychain and app-auth adapters.
|
|
77
|
+
|
|
78
|
+
- One app-lifecycle source, and a startup that needs no extra frame. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
79
|
+
|
|
80
|
+
- rn-core: `appLifecycle()`, a shared, reference-counted `AppState` subscription that
|
|
81
|
+
reports changes only (`active`, `inactive`, `background`), with `onForeground` for a
|
|
82
|
+
return from the background and `createManualLifecycle` in `./testing`.
|
|
83
|
+
- rn-camera, rn-ads and rn-notification hear the lifecycle through it instead of
|
|
84
|
+
subscribing to `AppState` each, so an app holds one native listener. The camera
|
|
85
|
+
controller's core layer no longer imports `react-native`.
|
|
86
|
+
- rn-payment: `syncOnForeground(engine)` and `<PaymentProvider syncOnForeground>` call
|
|
87
|
+
`handleForeground()` on each return from the background. Off by default; calling
|
|
88
|
+
`handleForeground()` yourself works as before.
|
|
89
|
+
- rn-i18n: `i18n.initSync()` resolves the locale before the first render when the
|
|
90
|
+
storage adapter has the new optional `getItemSync`, and returns false otherwise.
|
|
91
|
+
`localeStorageFromKeyValue(port)` builds such an adapter from rn-core's
|
|
92
|
+
`KeyValuePort` (MMKV).
|
|
93
|
+
|
|
94
|
+
### Patch Changes
|
|
95
|
+
|
|
96
|
+
- `createCameraController` accepts an explicit `undefined` for `store` and `logger`, so
|
|
97
|
+
apps compiling with `exactOptionalPropertyTypes` type-check against the package.
|
|
98
|
+
`VisionCameraFeed` leaves `torchMode` unset until the app first touches the torch,
|
|
99
|
+
instead of passing `undefined`. No other change. ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17))
|
|
100
|
+
- Updated dependencies ([#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17), [#17](https://github.com/operationd8979/chip-mobile-sdk/pull/17)):
|
|
101
|
+
- @chipmobilesdk/rn-core@0.1.0
|
|
102
|
+
|
|
3
103
|
## 0.2.1
|
|
4
104
|
|
|
5
105
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -256,6 +256,31 @@ camera.setAppState('background');
|
|
|
256
256
|
camera.emitInterruption();
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
+
### Conformance
|
|
260
|
+
|
|
261
|
+
`@chipmobilesdk/rn-camera/testing` also ships the checks every `PermissionPort` and
|
|
262
|
+
`CameraEngine` must pass. The package runs them against the test adapter and against the
|
|
263
|
+
VisionCamera adapters; run them against your own adapter, or from a demo screen on a
|
|
264
|
+
device, the same way:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
import { expectConformance } from '@chipmobilesdk/rn-core/testing';
|
|
268
|
+
import { runPermissionPortConformance } from '@chipmobilesdk/rn-camera/testing';
|
|
269
|
+
|
|
270
|
+
expectConformance(
|
|
271
|
+
await runPermissionPortConformance({
|
|
272
|
+
create(initial) {
|
|
273
|
+
// a port over a system whose permission starts in `initial`
|
|
274
|
+
return { port, setSystemState, prompts };
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`createTestCamera` and its helpers are exported from `./testing` too; the main
|
|
281
|
+
entrypoint keeps them until the next major release. `camera.prompts()` counts how often
|
|
282
|
+
`request()` showed the system prompt.
|
|
283
|
+
|
|
259
284
|
Add a module mapping so an accidental import of the native module resolves in Jest:
|
|
260
285
|
|
|
261
286
|
```js
|
|
@@ -266,7 +291,8 @@ moduleNameMapper: {
|
|
|
266
291
|
|
|
267
292
|
## Errors
|
|
268
293
|
|
|
269
|
-
Every failure is a `CameraError` with a `code` and a `recoverable`
|
|
294
|
+
Every failure is a `CameraError` with a `code` and a `retryable` flag (`recoverable` is kept as its deprecated alias), so you branch on the code rather than string-matching a message. `CameraError` extends `SdkError` from `@chipmobilesdk/rn-core`, so `isSdkError(error)` recognises it alongside every other package's errors, `error.pkg` names this package, and `error.redactedCause` (when present) is the underlying failure reduced to log-safe text.
|
|
295
|
+
|
|
270
296
|
|
|
271
297
|
`permissionDenied`, `cameraUnavailable`, `cameraBlocked`, `cameraInUse`, `sessionConflict`, `notReady`, `captureInProgress`, `captureFailed`, `storageFailure`, `unsupportedCapability`, `interrupted`.
|
|
272
298
|
|
|
@@ -326,7 +352,7 @@ Still capture and real-time frame processing stay strictly separate.
|
|
|
326
352
|
|
|
327
353
|
## Public API
|
|
328
354
|
|
|
329
|
-
Everything is exported from `@chipmobilesdk/rn-camera`. Modules under `core/`, `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and `testing/` are private — do not deep-import them.
|
|
355
|
+
Everything is exported from `@chipmobilesdk/rn-camera`, test doubles and conformance suites also from `@chipmobilesdk/rn-camera/testing`. Modules under `core/`, `native/`, `permissions/`, `storage/`, `geometry/`, `diagnostics/`, and `testing/` are private — do not deep-import them.
|
|
330
356
|
|
|
331
357
|
- Controller and state: `createCameraController`, `CameraController`,
|
|
332
358
|
`CameraControllerOptions`, `useCameraController`, `useCameraState`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chipmobilesdk/rn-camera",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"types": "./src/index.ts",
|
|
11
11
|
"react-native": "./src/index.ts",
|
|
12
12
|
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./testing": {
|
|
15
|
+
"types": "./src/testing/index.ts",
|
|
16
|
+
"react-native": "./src/testing/index.ts",
|
|
17
|
+
"default": "./src/testing/index.ts"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
@@ -29,9 +34,12 @@
|
|
|
29
34
|
],
|
|
30
35
|
"sideEffects": false,
|
|
31
36
|
"scripts": {
|
|
32
|
-
"typecheck": "tsc -p tsconfig.json
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json",
|
|
33
38
|
"pack:check": "npm pack --dry-run"
|
|
34
39
|
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@chipmobilesdk/rn-core": "^0.1.0"
|
|
42
|
+
},
|
|
35
43
|
"peerDependencies": {
|
|
36
44
|
"react": ">=19",
|
|
37
45
|
"react-native": ">=0.85",
|
|
@@ -53,6 +53,8 @@ Runtime:
|
|
|
53
53
|
- `CameraPreview` (+ `CameraPreviewProps`)
|
|
54
54
|
- `CameraError`, `isCameraError`, `redact` — `redact` strips sensitive paths before logging
|
|
55
55
|
- Testing: `createTestCamera`, `createMemoryFileSystem`, `DEFAULT_TEST_CAPABILITIES`
|
|
56
|
+
- Conformance (`@chipmobilesdk/rn-camera/testing`): `runPermissionPortConformance`,
|
|
57
|
+
`runCameraEngineConformance` — run against any adapter you write or inject
|
|
56
58
|
|
|
57
59
|
Types: `CameraEngine`, `EngineOptions`, `PermissionPort`, `ArtifactStore`,
|
|
58
60
|
`FileSystemBridge`, `StoreOptions`, `CameraErrorCode`, `CameraLogEvent`,
|
package/src/core/controller.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* tested without a camera.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
10
12
|
import {
|
|
11
13
|
type CameraPermissionState,
|
|
12
14
|
type CameraPosition,
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
type StartOptions,
|
|
19
21
|
} from '../config/types';
|
|
20
22
|
import { CameraError, isCameraError } from '../diagnostics/errors';
|
|
21
|
-
import { silentLogger, type CameraLogger } from '../diagnostics/logger';
|
|
23
|
+
import { silentLogger, withSdkSinks, type CameraLogger } from '../diagnostics/logger';
|
|
22
24
|
import type { CameraEngine } from '../native/cameraEngine';
|
|
23
25
|
import type { PermissionPort } from '../permissions/permissionPort';
|
|
24
26
|
import type { ArtifactStore } from '../storage/artifactStore';
|
|
@@ -39,12 +41,13 @@ export type AppStatus = 'active' | 'background';
|
|
|
39
41
|
export interface CameraControllerOptions {
|
|
40
42
|
readonly engine?: CameraEngine;
|
|
41
43
|
readonly permissions?: PermissionPort;
|
|
42
|
-
readonly store?: ArtifactStore;
|
|
43
|
-
readonly logger?: CameraLogger;
|
|
44
|
+
readonly store?: ArtifactStore | undefined;
|
|
45
|
+
readonly logger?: CameraLogger | undefined;
|
|
44
46
|
readonly temporaryTtlMs?: number;
|
|
45
47
|
/**
|
|
46
|
-
* App foreground/background source. Defaults to
|
|
47
|
-
* Injectable so lifecycle rules are
|
|
48
|
+
* App foreground/background source. Defaults to rn-core's shared `appLifecycle()`,
|
|
49
|
+
* where `inactive` counts as background. Injectable so lifecycle rules are
|
|
50
|
+
* testable without an app (FR-015).
|
|
48
51
|
*/
|
|
49
52
|
readonly subscribeToAppState?: (listener: (status: AppStatus) => void) => () => void;
|
|
50
53
|
/**
|
|
@@ -75,6 +78,12 @@ export interface CameraController {
|
|
|
75
78
|
|
|
76
79
|
/** Marks the preview surface mounted, enabling foreground auto-restart. */
|
|
77
80
|
setSurfaceMounted(mounted: boolean): void;
|
|
81
|
+
/**
|
|
82
|
+
* Called by `CameraPreview` when the native preview surface cannot load (the
|
|
83
|
+
* VisionCamera module is missing or unlinked). Fails the session with
|
|
84
|
+
* `cameraUnavailable` rather than leaving a black preview and no error.
|
|
85
|
+
*/
|
|
86
|
+
reportSurfaceUnavailable(): void;
|
|
78
87
|
/**
|
|
79
88
|
* Releases ports, listeners, and the camera. Safe to call twice. Afterwards
|
|
80
89
|
* `start`, `resume`, `capture` and the permission and capability reads reject
|
|
@@ -85,13 +94,9 @@ export interface CameraController {
|
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
function defaultAppStateSubscribe(listener: (status: AppStatus) => void): () => void {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const subscription = AppState.addEventListener('change', (next: string) => {
|
|
92
|
-
listener(next === 'active' ? 'active' : 'background');
|
|
93
|
-
});
|
|
94
|
-
return () => subscription.remove();
|
|
97
|
+
// rn-core's one lifecycle subscription, shared with every other package; it
|
|
98
|
+
// requires react-native only on first subscribe (PERF-07).
|
|
99
|
+
return appLifecycle().subscribe(state => listener(state === 'active' ? 'active' : 'background'));
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
export function createCameraController(options: CameraControllerOptions = {}): CameraController {
|
|
@@ -107,7 +112,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
107
112
|
const engine: CameraEngine = options.engine;
|
|
108
113
|
const permissions: PermissionPort = options.permissions;
|
|
109
114
|
const store = options.store;
|
|
110
|
-
const logger = options.logger ?? silentLogger;
|
|
115
|
+
const logger = withSdkSinks(options.logger ?? silentLogger);
|
|
111
116
|
const ttlMs = options.temporaryTtlMs ?? DEFAULT_TEMPORARY_TTL_MS;
|
|
112
117
|
|
|
113
118
|
let session: SessionSnapshot = initialSession;
|
|
@@ -129,7 +134,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
129
134
|
let detach: (() => void) | null = null;
|
|
130
135
|
|
|
131
136
|
const capture = new SingleFlight();
|
|
132
|
-
const listeners =
|
|
137
|
+
const listeners = createListenerSet<[CameraState]>({
|
|
138
|
+
onListenerError: () => logger.log({ type: 'session', code: 'listener-threw' }),
|
|
139
|
+
});
|
|
133
140
|
|
|
134
141
|
function snapshot(): CameraState {
|
|
135
142
|
return {
|
|
@@ -154,15 +161,9 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
154
161
|
|
|
155
162
|
function emit(): void {
|
|
156
163
|
current = snapshot();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
listener(current);
|
|
162
|
-
} catch {
|
|
163
|
-
logger.log({ type: 'session', code: 'listener-threw' });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
164
|
+
// Isolated: one throwing subscriber neither starves the others nor unwinds
|
|
165
|
+
// into the transition that emitted, which left the controller half-updated.
|
|
166
|
+
listeners.notify(current);
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function dispatch(event: Parameters<typeof sessionReducer>[1]): void {
|
|
@@ -336,10 +337,7 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
336
337
|
return () => undefined;
|
|
337
338
|
}
|
|
338
339
|
attach();
|
|
339
|
-
listeners.add(listener);
|
|
340
|
-
return () => {
|
|
341
|
-
listeners.delete(listener);
|
|
342
|
-
};
|
|
340
|
+
return listeners.add(listener);
|
|
343
341
|
},
|
|
344
342
|
|
|
345
343
|
async getPermission() {
|
|
@@ -492,6 +490,14 @@ export function createCameraController(options: CameraControllerOptions = {}): C
|
|
|
492
490
|
emit();
|
|
493
491
|
},
|
|
494
492
|
|
|
493
|
+
reportSurfaceUnavailable() {
|
|
494
|
+
if (disposed) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
logger.log({ type: 'session', code: 'surface-unavailable', recoverable: false });
|
|
498
|
+
dispatch({ type: 'FAIL', code: 'cameraUnavailable' });
|
|
499
|
+
},
|
|
500
|
+
|
|
495
501
|
setSurfaceMounted(mounted: boolean) {
|
|
496
502
|
if (disposed) {
|
|
497
503
|
return;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* payloads — `redact` is applied before any value reaches a log sink.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { COMMON_PATTERNS, PATH_PATTERN, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
9
11
|
export type CameraErrorCode =
|
|
10
12
|
| 'permissionDenied'
|
|
11
13
|
| 'cameraUnavailable'
|
|
@@ -32,16 +34,16 @@ const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
|
|
|
32
34
|
'interrupted',
|
|
33
35
|
]);
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
readonly recoverable: boolean;
|
|
38
|
-
|
|
37
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-camera'`. */
|
|
38
|
+
export class CameraError extends SdkError<CameraErrorCode> {
|
|
39
39
|
constructor(code: CameraErrorCode, message?: string) {
|
|
40
|
-
super(message
|
|
40
|
+
super('rn-camera', code, { message, retryable: RECOVERABLE.has(code) });
|
|
41
41
|
this.name = 'CameraError';
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @deprecated Use `retryable`, the name every SDK package shares. */
|
|
45
|
+
get recoverable(): boolean {
|
|
46
|
+
return this.retryable;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -49,17 +51,17 @@ export function isCameraError(value: unknown): value is CameraError {
|
|
|
49
51
|
return value instanceof CameraError;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
54
|
+
/**
|
|
55
|
+
* rn-core's shared patterns (URLs, including file URIs; JWTs; e-mail addresses;
|
|
56
|
+
* UUIDs; base64-ish payload runs) plus POSIX and Windows paths.
|
|
57
|
+
*/
|
|
58
|
+
const PATTERNS = [...COMMON_PATTERNS, PATH_PATTERN];
|
|
57
59
|
|
|
58
60
|
const REDACTED = '[redacted]';
|
|
59
61
|
const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
|
|
60
62
|
|
|
61
63
|
function redactString(value: string): string {
|
|
62
|
-
return value
|
|
64
|
+
return redactPatterns(value, PATTERNS);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -76,7 +78,7 @@ export function redact(value: unknown): unknown {
|
|
|
76
78
|
return value.map(redact);
|
|
77
79
|
}
|
|
78
80
|
if (value instanceof CameraError) {
|
|
79
|
-
return { code: value.code, recoverable: value.
|
|
81
|
+
return { code: value.code, recoverable: value.retryable };
|
|
80
82
|
}
|
|
81
83
|
if (value instanceof Error) {
|
|
82
84
|
return { name: value.name, message: redactString(value.message) };
|
|
@@ -6,18 +6,39 @@
|
|
|
6
6
|
* image data — so an opted-in logger still cannot leak an image path.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
10
|
+
|
|
9
11
|
export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
|
|
10
12
|
|
|
11
13
|
export interface CameraLogEvent {
|
|
12
14
|
readonly type: CameraLogEventType;
|
|
13
15
|
readonly code: string;
|
|
14
|
-
readonly recoverable?: boolean;
|
|
16
|
+
readonly recoverable?: boolean | undefined;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface CameraLogger {
|
|
18
20
|
log(event: CameraLogEvent): void;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
const channel = createDiagnosticChannel('rn-camera');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every event also reaches the app's SDK sinks (`installSdkSink`, CORE-08), as
|
|
27
|
+
* `<type>.<code>`: the same code-and-state body, so nothing new can leak.
|
|
28
|
+
*/
|
|
29
|
+
export function withSdkSinks(logger: CameraLogger): CameraLogger {
|
|
30
|
+
return {
|
|
31
|
+
log(event) {
|
|
32
|
+
channel.emit(
|
|
33
|
+
`${event.type}.${event.code}`,
|
|
34
|
+
event.code === 'listener-threw' ? 'error' : event.recoverable === false ? 'warn' : 'info',
|
|
35
|
+
event.recoverable === undefined ? undefined : { retryable: event.recoverable },
|
|
36
|
+
);
|
|
37
|
+
logger.log(event);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/** No-op logger used when the consumer supplies none. */
|
|
22
43
|
export const silentLogger: CameraLogger = {
|
|
23
44
|
log() {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* mounted preview surface (see `visionSurface`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { loadVisionCamera } from './visionCameraPeer';
|
|
17
18
|
import type {
|
|
18
19
|
CapabilityProfile,
|
|
19
20
|
CapturedImage,
|
|
@@ -49,8 +50,12 @@ interface DiscoveredDevice {
|
|
|
49
50
|
* owns the rendered surface and the photo output that capture runs through.
|
|
50
51
|
*/
|
|
51
52
|
export function createVisionCameraEngine(_options: EngineOptions = {}): CameraEngine {
|
|
52
|
-
//
|
|
53
|
-
const { VisionCamera } =
|
|
53
|
+
// Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
|
|
54
|
+
const { VisionCamera } = loadVisionCamera<{
|
|
55
|
+
VisionCamera: {
|
|
56
|
+
createDeviceFactory(): Promise<{ readonly cameraDevices: DiscoveredDevice[] }>;
|
|
57
|
+
};
|
|
58
|
+
}>();
|
|
54
59
|
|
|
55
60
|
// The factory is created once and reused: it keeps its own device list up to
|
|
56
61
|
// date, so re-creating it per call would only add latency.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-native-vision-camera, loaded on first use (rn-core's lazy peer). A missing
|
|
3
|
+
* or unlinked module is a `cameraUnavailable` CameraError, the code the controller
|
|
4
|
+
* already reports when the camera cannot be reached.
|
|
5
|
+
*/
|
|
6
|
+
import { lazyPeer } from '@chipmobilesdk/rn-core';
|
|
7
|
+
|
|
8
|
+
import { CameraError } from '../diagnostics/errors';
|
|
9
|
+
|
|
10
|
+
// The engine and the permission port each describe the slice they use.
|
|
11
|
+
type VisionCameraModule = { VisionCamera: Record<string, (...args: never[]) => unknown> };
|
|
12
|
+
|
|
13
|
+
const visionCamera = lazyPeer('react-native-vision-camera', () => {
|
|
14
|
+
// Inside `try`, so Metro bundles an app that leaves this optional peer out.
|
|
15
|
+
try {
|
|
16
|
+
return require('react-native-vision-camera') as VisionCameraModule;
|
|
17
|
+
} catch (cause) {
|
|
18
|
+
throw cause;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function loadVisionCamera<T>(): T {
|
|
23
|
+
return visionCamera.require(
|
|
24
|
+
peer =>
|
|
25
|
+
new CameraError(
|
|
26
|
+
'cameraUnavailable',
|
|
27
|
+
`${peer} is not installed or its native module is not linked`,
|
|
28
|
+
),
|
|
29
|
+
) as unknown as T;
|
|
30
|
+
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* statics since v5).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { loadVisionCamera } from '../native/visionCameraPeer';
|
|
16
|
+
import { appLifecycle, createListenerSet } from '@chipmobilesdk/rn-core';
|
|
17
|
+
|
|
15
18
|
import type { CameraPermissionState } from '../config/types';
|
|
16
19
|
|
|
17
20
|
export interface PermissionPort {
|
|
@@ -46,12 +49,16 @@ function mapStatus(raw: string): CameraPermissionState {
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function createVisionCameraPermissions(): PermissionPort {
|
|
49
|
-
//
|
|
50
|
-
const { VisionCamera } =
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// Loaded when the default adapter is created; a missing peer is `cameraUnavailable`.
|
|
53
|
+
const { VisionCamera } = loadVisionCamera<{
|
|
54
|
+
VisionCamera: {
|
|
55
|
+
readonly cameraPermissionStatus: string;
|
|
56
|
+
requestCameraPermission(): Promise<unknown>;
|
|
57
|
+
};
|
|
58
|
+
}>();
|
|
59
|
+
const listeners = createListenerSet<[CameraPermissionState]>();
|
|
53
60
|
let last: CameraPermissionState | null = null;
|
|
54
|
-
let
|
|
61
|
+
let stopWatchingLifecycle: (() => void) | null = null;
|
|
55
62
|
|
|
56
63
|
function read(): CameraPermissionState {
|
|
57
64
|
try {
|
|
@@ -66,9 +73,7 @@ export function createVisionCameraPermissions(): PermissionPort {
|
|
|
66
73
|
function emit(state: CameraPermissionState): CameraPermissionState {
|
|
67
74
|
if (state !== last) {
|
|
68
75
|
last = state;
|
|
69
|
-
|
|
70
|
-
listener(state);
|
|
71
|
-
}
|
|
76
|
+
listeners.notify(state);
|
|
72
77
|
}
|
|
73
78
|
return state;
|
|
74
79
|
}
|
|
@@ -92,22 +97,21 @@ export function createVisionCameraPermissions(): PermissionPort {
|
|
|
92
97
|
},
|
|
93
98
|
|
|
94
99
|
subscribe(listener) {
|
|
95
|
-
listeners.add(listener);
|
|
100
|
+
const remove = listeners.add(listener);
|
|
96
101
|
// Grants and revocations made in system Settings send the app through
|
|
97
102
|
// background/foreground, so re-reading on foreground is what surfaces
|
|
98
103
|
// them (FR-007). Attached only while somebody is listening.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
104
|
+
// rn-core's shared lifecycle port, so this adds no native subscription of its own.
|
|
105
|
+
stopWatchingLifecycle ??= appLifecycle().subscribe(state => {
|
|
106
|
+
if (state === 'active') {
|
|
107
|
+
emit(read());
|
|
108
|
+
}
|
|
109
|
+
});
|
|
106
110
|
return () => {
|
|
107
|
-
|
|
108
|
-
if (listeners.size === 0 &&
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
remove();
|
|
112
|
+
if (listeners.size === 0 && stopWatchingLifecycle) {
|
|
113
|
+
stopWatchingLifecycle();
|
|
114
|
+
stopWatchingLifecycle = null;
|
|
111
115
|
}
|
|
112
116
|
};
|
|
113
117
|
},
|
|
@@ -33,6 +33,9 @@ export interface CameraPreviewProps {
|
|
|
33
33
|
readonly children?: React.ReactNode;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The feed module failed to load; distinct from `null`, which means not requested. */
|
|
37
|
+
const FEED_UNAVAILABLE = 'unavailable' as const;
|
|
38
|
+
|
|
36
39
|
/** Nominal capture dimensions until the engine reports the real format. */
|
|
37
40
|
const DEFAULT_IMAGE_WIDTH = 1920;
|
|
38
41
|
const DEFAULT_IMAGE_HEIGHT = 1080;
|
|
@@ -101,10 +104,18 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
101
104
|
return require('./VisionCameraFeed')
|
|
102
105
|
.VisionCameraFeed as React.ComponentType<VisionCameraFeedProps>;
|
|
103
106
|
} catch {
|
|
104
|
-
|
|
107
|
+
// Reported, not swallowed (CAM-21): the session fails with
|
|
108
|
+
// `cameraUnavailable` instead of showing a black preview.
|
|
109
|
+
return FEED_UNAVAILABLE;
|
|
105
110
|
}
|
|
106
111
|
}, [feedRequested]);
|
|
107
112
|
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (Feed === FEED_UNAVAILABLE) {
|
|
115
|
+
controller.reportSurfaceUnavailable();
|
|
116
|
+
}
|
|
117
|
+
}, [Feed, controller]);
|
|
118
|
+
|
|
108
119
|
return (
|
|
109
120
|
<View
|
|
110
121
|
style={style}
|
|
@@ -113,7 +124,7 @@ export const CameraPreview: React.FC<CameraPreviewProps> = ({
|
|
|
113
124
|
accessibilityRole={accessibilityRole}
|
|
114
125
|
testID="rn-camera-preview"
|
|
115
126
|
>
|
|
116
|
-
{Feed ? (
|
|
127
|
+
{Feed && Feed !== FEED_UNAVAILABLE ? (
|
|
117
128
|
<Feed
|
|
118
129
|
cameraPosition={state.cameraPosition}
|
|
119
130
|
resizeMode={resizeMode}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type CameraPosition,
|
|
27
27
|
type CapturedImage,
|
|
28
28
|
type CaptureRequest,
|
|
29
|
+
type Maybe,
|
|
29
30
|
type ResizeMode,
|
|
30
31
|
} from '../config/types';
|
|
31
32
|
import { DEFAULT_QUALITY } from '../core/captureController';
|
|
@@ -57,6 +58,11 @@ const ORIENTATION_DEGREES: Record<string, number> = {
|
|
|
57
58
|
left: 270,
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
function orientationOf(orientation: string): Maybe<number> {
|
|
62
|
+
const degrees = ORIENTATION_DEGREES[orientation];
|
|
63
|
+
return degrees === undefined ? unavailable() : known(degrees);
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
function toFileUri(path: string): string {
|
|
61
67
|
return path.startsWith('file://') ? path : `file://${path}`;
|
|
62
68
|
}
|
|
@@ -122,10 +128,7 @@ async function capturePhoto(
|
|
|
122
128
|
// The native layer reports neither back after a save, and guessing them
|
|
123
129
|
// would be worse than saying so (FR-022).
|
|
124
130
|
fileSize: unavailable(),
|
|
125
|
-
orientation:
|
|
126
|
-
photo.orientation in ORIENTATION_DEGREES
|
|
127
|
-
? known(ORIENTATION_DEGREES[photo.orientation])
|
|
128
|
-
: unavailable(),
|
|
131
|
+
orientation: orientationOf(photo.orientation),
|
|
129
132
|
};
|
|
130
133
|
} finally {
|
|
131
134
|
// Large native buffers must not wait for the JS GC.
|
|
@@ -171,7 +174,8 @@ export const VisionCameraFeed: React.FC<VisionCameraFeedProps> = ({
|
|
|
171
174
|
outputs={[photoOutput]}
|
|
172
175
|
isActive={true}
|
|
173
176
|
resizeMode={resizeMode}
|
|
174
|
-
|
|
177
|
+
// Left unset until the app first touches the torch, so the device's own default stands.
|
|
178
|
+
{...(torchMode === undefined ? {} : { torchMode })}
|
|
175
179
|
/>
|
|
176
180
|
);
|
|
177
181
|
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What any `PermissionPort` and `CameraEngine` must do (IOS-12, CAM-13).
|
|
3
|
+
*
|
|
4
|
+
* The same checks run against the in-memory test adapter and against the real
|
|
5
|
+
* VisionCamera adapters (native module mocked in Jest, or for real on a device),
|
|
6
|
+
* so the fake the app's tests rely on cannot drift from the adapter it stands in
|
|
7
|
+
* for. Each check builds its own port from a harness, under the conditions it
|
|
8
|
+
* needs.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
ensure,
|
|
12
|
+
runChecks,
|
|
13
|
+
type Check,
|
|
14
|
+
type ConformanceReport,
|
|
15
|
+
} from '@chipmobilesdk/rn-core/testing';
|
|
16
|
+
|
|
17
|
+
import type { CameraPermissionState } from '../config/types';
|
|
18
|
+
import type { CameraEngine } from '../native/cameraEngine';
|
|
19
|
+
import type { PermissionPort } from '../permissions/permissionPort';
|
|
20
|
+
|
|
21
|
+
export type SystemPermission = 'undetermined' | 'granted' | 'deniedPermanently';
|
|
22
|
+
|
|
23
|
+
export interface PermissionHarness {
|
|
24
|
+
/** A port over a system whose permission starts in `initial`. */
|
|
25
|
+
create(initial: SystemPermission): {
|
|
26
|
+
readonly port: PermissionPort;
|
|
27
|
+
/** Changes the system permission, as Settings would. */
|
|
28
|
+
setSystemState(state: SystemPermission): void;
|
|
29
|
+
/** How many times the system prompt was shown. */
|
|
30
|
+
prompts(): number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CameraDeviceSpec {
|
|
35
|
+
readonly position: 'front' | 'back';
|
|
36
|
+
readonly hasFlash?: boolean;
|
|
37
|
+
readonly hasTorch?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EngineHarness {
|
|
41
|
+
/** An engine on a device with these cameras. */
|
|
42
|
+
create(devices: readonly CameraDeviceSpec[]): CameraEngine;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const permissionPortChecks: readonly Check<PermissionHarness>[] = [
|
|
46
|
+
{
|
|
47
|
+
name: 'reads the status without prompting',
|
|
48
|
+
async run(harness) {
|
|
49
|
+
const system = harness.create('undetermined');
|
|
50
|
+
ensure((await system.port.getStatus()) === 'undetermined', 'expected undetermined');
|
|
51
|
+
ensure(system.prompts() === 0, 'getStatus prompted');
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'reports a grant after prompting once',
|
|
56
|
+
async run(harness) {
|
|
57
|
+
const system = harness.create('undetermined');
|
|
58
|
+
const state = await system.port.request();
|
|
59
|
+
ensure(state === 'granted', `expected granted, got ${state}`);
|
|
60
|
+
ensure(system.prompts() === 1, `expected one prompt, got ${system.prompts()}`);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'never prompts again after a permanent denial',
|
|
65
|
+
async run(harness) {
|
|
66
|
+
const system = harness.create('deniedPermanently');
|
|
67
|
+
const state = await system.port.request();
|
|
68
|
+
ensure(state === 'deniedPermanently', `expected deniedPermanently, got ${state}`);
|
|
69
|
+
ensure(system.prompts() === 0, 'prompted after a permanent denial');
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'tells subscribers about a change, and nothing after unsubscribing',
|
|
74
|
+
async run(harness) {
|
|
75
|
+
const system = harness.create('undetermined');
|
|
76
|
+
const seen: CameraPermissionState[] = [];
|
|
77
|
+
const unsubscribe = system.port.subscribe(state => seen.push(state));
|
|
78
|
+
await system.port.request();
|
|
79
|
+
ensure(seen.includes('granted'), `subscriber saw ${JSON.stringify(seen)}`);
|
|
80
|
+
unsubscribe();
|
|
81
|
+
unsubscribe();
|
|
82
|
+
const before = seen.length;
|
|
83
|
+
system.setSystemState('deniedPermanently');
|
|
84
|
+
await system.port.getStatus();
|
|
85
|
+
ensure(seen.length === before, 'notified after unsubscribing');
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export const cameraEngineChecks: readonly Check<EngineHarness>[] = [
|
|
91
|
+
{
|
|
92
|
+
name: 'refuses to start without a camera, as cameraUnavailable',
|
|
93
|
+
async run(harness) {
|
|
94
|
+
const engine = harness.create([]);
|
|
95
|
+
let code: unknown;
|
|
96
|
+
try {
|
|
97
|
+
await engine.start({});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
code = (error as { code?: unknown }).code;
|
|
100
|
+
}
|
|
101
|
+
ensure(code === 'cameraUnavailable', `expected cameraUnavailable, got ${String(code)}`);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'starts when a camera exists',
|
|
106
|
+
async run(harness) {
|
|
107
|
+
await harness.create([{ position: 'back' }]).start({});
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'reports capabilities from the cameras present',
|
|
112
|
+
async run(harness) {
|
|
113
|
+
const capabilities = await harness
|
|
114
|
+
.create([{ position: 'back', hasTorch: true, hasFlash: true }, { position: 'front' }])
|
|
115
|
+
.getCapabilities();
|
|
116
|
+
ensure(capabilities.hasCameraHardware, 'no camera hardware reported');
|
|
117
|
+
ensure(capabilities.hasTorch && capabilities.hasFlash, 'torch or flash not reported');
|
|
118
|
+
const positions = [...capabilities.availablePositions].sort();
|
|
119
|
+
ensure(positions.join(',') === 'back,front', `positions ${positions.join(',')}`);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'stops safely, even without a start and twice',
|
|
124
|
+
async run(harness) {
|
|
125
|
+
const engine = harness.create([{ position: 'back' }]);
|
|
126
|
+
await engine.stop();
|
|
127
|
+
await engine.stop();
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export function runPermissionPortConformance(
|
|
133
|
+
harness: PermissionHarness,
|
|
134
|
+
): Promise<ConformanceReport> {
|
|
135
|
+
return runChecks(() => harness, permissionPortChecks);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function runCameraEngineConformance(harness: EngineHarness): Promise<ConformanceReport> {
|
|
139
|
+
return runChecks(() => harness, cameraEngineChecks);
|
|
140
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@chipmobilesdk/rn-camera/testing`: test doubles and the port conformance suites.
|
|
3
|
+
*
|
|
4
|
+
* A separate entrypoint so the suites cannot reach a production bundle. The test
|
|
5
|
+
* adapter is also exported from the main entrypoint for compatibility; it leaves
|
|
6
|
+
* there only in a major release.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
createTestCamera,
|
|
11
|
+
createMemoryFileSystem,
|
|
12
|
+
DEFAULT_TEST_CAPABILITIES,
|
|
13
|
+
} from './testCameraAdapter';
|
|
14
|
+
export type { MemoryFileSystem, TestCameraConfig, TestCameraHandles } from './testCameraAdapter';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
cameraEngineChecks,
|
|
18
|
+
permissionPortChecks,
|
|
19
|
+
runCameraEngineConformance,
|
|
20
|
+
runPermissionPortConformance,
|
|
21
|
+
} from './conformance';
|
|
22
|
+
export type {
|
|
23
|
+
CameraDeviceSpec,
|
|
24
|
+
EngineHarness,
|
|
25
|
+
PermissionHarness,
|
|
26
|
+
SystemPermission,
|
|
27
|
+
} from './conformance';
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* filesystem, or a permission API.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { createListenerSet } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
10
12
|
import {
|
|
11
13
|
known,
|
|
12
14
|
unavailable,
|
|
@@ -100,6 +102,8 @@ export interface TestCameraHandles {
|
|
|
100
102
|
emitInterruption(): void;
|
|
101
103
|
setCaptureError(code: CameraErrorCode | null): void;
|
|
102
104
|
setStartError(code: CameraErrorCode | null): void;
|
|
105
|
+
/** How many times `request` showed the system prompt. */
|
|
106
|
+
prompts(): number;
|
|
103
107
|
/** Every capture the engine produced, in order. */
|
|
104
108
|
captures(): readonly CapturedImage[];
|
|
105
109
|
torchState(): boolean;
|
|
@@ -122,11 +126,13 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
122
126
|
let starts = 0;
|
|
123
127
|
let stops = 0;
|
|
124
128
|
let sequence = 0;
|
|
129
|
+
let promptCount = 0;
|
|
125
130
|
|
|
126
131
|
const produced: CapturedImage[] = [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
132
|
+
// The same listener sets as the real adapters, so a throwing subscriber behaves
|
|
133
|
+
// identically here and on a device.
|
|
134
|
+
const permissionListeners = createListenerSet<[CameraPermissionState]>();
|
|
135
|
+
const appStateListeners = createListenerSet<[AppStatus]>();
|
|
130
136
|
|
|
131
137
|
const permissions: PermissionPort = {
|
|
132
138
|
async getStatus() {
|
|
@@ -137,19 +143,14 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
137
143
|
if (permission === 'deniedPermanently') {
|
|
138
144
|
return permission;
|
|
139
145
|
}
|
|
140
|
-
permission
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
listener(permission);
|
|
146
|
+
if (permission === 'undetermined' || permission === 'denied') {
|
|
147
|
+
promptCount += 1;
|
|
148
|
+
permission = 'granted';
|
|
144
149
|
}
|
|
150
|
+
permissionListeners.notify(permission);
|
|
145
151
|
return permission;
|
|
146
152
|
},
|
|
147
|
-
subscribe(listener)
|
|
148
|
-
permissionListeners.add(listener);
|
|
149
|
-
return () => {
|
|
150
|
-
permissionListeners.delete(listener);
|
|
151
|
-
};
|
|
152
|
-
},
|
|
153
|
+
subscribe: listener => permissionListeners.add(listener),
|
|
153
154
|
};
|
|
154
155
|
|
|
155
156
|
const engine: CameraEngine = {
|
|
@@ -211,30 +212,18 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
211
212
|
store,
|
|
212
213
|
fs,
|
|
213
214
|
|
|
214
|
-
subscribeToAppState(listener)
|
|
215
|
-
appStateListeners.add(listener);
|
|
216
|
-
return () => {
|
|
217
|
-
appStateListeners.delete(listener);
|
|
218
|
-
};
|
|
219
|
-
},
|
|
215
|
+
subscribeToAppState: listener => appStateListeners.add(listener),
|
|
220
216
|
|
|
221
217
|
setAppState(status) {
|
|
222
|
-
|
|
223
|
-
listener(status);
|
|
224
|
-
}
|
|
218
|
+
appStateListeners.notify(status);
|
|
225
219
|
},
|
|
226
220
|
|
|
227
221
|
setPermission(state) {
|
|
228
222
|
permission = state;
|
|
229
|
-
|
|
230
|
-
listener(state);
|
|
231
|
-
}
|
|
223
|
+
permissionListeners.notify(state);
|
|
232
224
|
},
|
|
233
225
|
|
|
234
226
|
emitInterruption() {
|
|
235
|
-
for (const listener of interruptionListeners) {
|
|
236
|
-
listener();
|
|
237
|
-
}
|
|
238
227
|
// An interruption behaves like the OS taking the camera away: the next
|
|
239
228
|
// start attempt fails until the app clears it.
|
|
240
229
|
startError = 'interrupted';
|
|
@@ -248,6 +237,7 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
248
237
|
startError = code;
|
|
249
238
|
},
|
|
250
239
|
|
|
240
|
+
prompts: () => promptCount,
|
|
251
241
|
captures: () => produced,
|
|
252
242
|
torchState: () => torch,
|
|
253
243
|
startCount: () => starts,
|