@chipmobilesdk/rn-camera 0.2.1 → 0.4.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 +122 -0
- package/README.md +64 -9
- package/package.json +10 -2
- package/skills/sdk-rn-camera/SKILL.md +21 -3
- package/src/config/types.ts +8 -0
- package/src/core/captureController.ts +11 -1
- package/src/core/contract.ts +109 -0
- package/src/core/controller.ts +117 -163
- package/src/core/sessionRuntime.ts +242 -0
- package/src/diagnostics/errors.ts +17 -15
- package/src/diagnostics/logger.ts +22 -1
- package/src/index.ts +3 -1
- package/src/native/cameraEngine.ts +35 -10
- package/src/native/visionCameraPeer.ts +30 -0
- package/src/native/visionSurface.ts +43 -18
- package/src/permissions/permissionPort.ts +24 -20
- package/src/react/CameraPreview.tsx +53 -9
- package/src/react/VisionCameraFeed.tsx +70 -10
- package/src/react/hooks.ts +1 -1
- package/src/storage/artifactStore.ts +9 -2
- package/src/testing/conformance.ts +140 -0
- package/src/testing/index.ts +27 -0
- package/src/testing/testCameraAdapter.ts +47 -35
package/src/react/hooks.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type CameraControllerOptions,
|
|
16
16
|
} from '../core/controller';
|
|
17
17
|
|
|
18
|
-
export function useCameraController(options: CameraControllerOptions
|
|
18
|
+
export function useCameraController(options: CameraControllerOptions): CameraController {
|
|
19
19
|
// Options usually arrive as a fresh object literal each render; capturing
|
|
20
20
|
// them in a ref keeps the controller stable for the component's lifetime.
|
|
21
21
|
const optionsRef = useRef(options);
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* the package can refuse to delete anything it did not create (FR-028).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { CameraError } from '../diagnostics/errors';
|
|
11
|
+
|
|
10
12
|
export interface ArtifactStore {
|
|
11
13
|
/** Absolute path of the package-owned temporary directory. */
|
|
12
14
|
temporaryDirectory(): string;
|
|
@@ -112,7 +114,10 @@ export function createArtifactStore(
|
|
|
112
114
|
return uri;
|
|
113
115
|
}
|
|
114
116
|
if (!fs.move) {
|
|
115
|
-
throw new
|
|
117
|
+
throw new CameraError(
|
|
118
|
+
'storageFailure',
|
|
119
|
+
'This FileSystemBridge has no move(), so it cannot adopt a capture',
|
|
120
|
+
);
|
|
116
121
|
}
|
|
117
122
|
await fs.ensureDirectory(root);
|
|
118
123
|
const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
|
|
@@ -132,7 +137,9 @@ export function createArtifactStore(
|
|
|
132
137
|
export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
|
|
133
138
|
const fs = options.fs;
|
|
134
139
|
if (!fs) {
|
|
135
|
-
|
|
140
|
+
// A typed error, like every other failure this package reports.
|
|
141
|
+
throw new CameraError(
|
|
142
|
+
'storageFailure',
|
|
136
143
|
'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
|
|
137
144
|
'createTestCamera() in tests.',
|
|
138
145
|
);
|
|
@@ -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,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
type FileSystemBridge,
|
|
26
28
|
} from '../storage/artifactStore';
|
|
27
29
|
import type { AppStatus } from '../core/controller';
|
|
30
|
+
import { clampQuality } from '../core/captureController';
|
|
28
31
|
|
|
29
32
|
export interface MemoryFileSystem extends FileSystemBridge {
|
|
30
33
|
/** Seeds a file with an explicit creation time, for time-to-live tests. */
|
|
@@ -96,10 +99,19 @@ export interface TestCameraHandles {
|
|
|
96
99
|
setAppState(status: AppStatus): void;
|
|
97
100
|
/** Simulates a Settings-level permission change while running (FR-007). */
|
|
98
101
|
setPermission(state: CameraPermissionState): void;
|
|
99
|
-
/**
|
|
100
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Simulates the OS taking the camera away: an incoming call, another app,
|
|
104
|
+
* a disconnected device. A live session tears down with that code, exactly
|
|
105
|
+
* as the device adapter reports it (CAM-19), and the next start fails until
|
|
106
|
+
* `setStartError(null)` clears it.
|
|
107
|
+
*/
|
|
108
|
+
emitInterruption(code?: CameraErrorCode): void;
|
|
109
|
+
/** Simulates the preview failing to produce frames. */
|
|
110
|
+
emitPermissionRequestFailure(enabled: boolean): void;
|
|
101
111
|
setCaptureError(code: CameraErrorCode | null): void;
|
|
102
112
|
setStartError(code: CameraErrorCode | null): void;
|
|
113
|
+
/** How many times `request` showed the system prompt. */
|
|
114
|
+
prompts(): number;
|
|
103
115
|
/** Every capture the engine produced, in order. */
|
|
104
116
|
captures(): readonly CapturedImage[];
|
|
105
117
|
torchState(): boolean;
|
|
@@ -122,37 +134,42 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
122
134
|
let starts = 0;
|
|
123
135
|
let stops = 0;
|
|
124
136
|
let sequence = 0;
|
|
137
|
+
let promptCount = 0;
|
|
125
138
|
|
|
126
139
|
const produced: CapturedImage[] = [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
140
|
+
// The same listener sets as the real adapters, so a throwing subscriber behaves
|
|
141
|
+
// identically here and on a device.
|
|
142
|
+
const permissionListeners = createListenerSet<[CameraPermissionState]>();
|
|
143
|
+
const appStateListeners = createListenerSet<[AppStatus]>();
|
|
144
|
+
const interruptionListeners = createListenerSet<[CameraErrorCode]>();
|
|
145
|
+
let requestThrows = false;
|
|
130
146
|
|
|
131
147
|
const permissions: PermissionPort = {
|
|
132
148
|
async getStatus() {
|
|
133
149
|
return permission;
|
|
134
150
|
},
|
|
135
151
|
async request() {
|
|
152
|
+
if (requestThrows) {
|
|
153
|
+
// What a native module that cannot be reached does.
|
|
154
|
+
throw new Error('permission request failed');
|
|
155
|
+
}
|
|
136
156
|
// Mirrors the real adapter: never re-prompt after a permanent denial.
|
|
137
157
|
if (permission === 'deniedPermanently') {
|
|
138
158
|
return permission;
|
|
139
159
|
}
|
|
140
|
-
permission
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
listener(permission);
|
|
160
|
+
if (permission === 'undetermined' || permission === 'denied') {
|
|
161
|
+
promptCount += 1;
|
|
162
|
+
permission = 'granted';
|
|
144
163
|
}
|
|
164
|
+
permissionListeners.notify(permission);
|
|
145
165
|
return permission;
|
|
146
166
|
},
|
|
147
|
-
subscribe(listener)
|
|
148
|
-
permissionListeners.add(listener);
|
|
149
|
-
return () => {
|
|
150
|
-
permissionListeners.delete(listener);
|
|
151
|
-
};
|
|
152
|
-
},
|
|
167
|
+
subscribe: listener => permissionListeners.add(listener),
|
|
153
168
|
};
|
|
154
169
|
|
|
155
170
|
const engine: CameraEngine = {
|
|
171
|
+
subscribeToInterruptions: listener => interruptionListeners.add(listener),
|
|
172
|
+
|
|
156
173
|
async start() {
|
|
157
174
|
starts += 1;
|
|
158
175
|
if (startError) {
|
|
@@ -185,7 +202,8 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
185
202
|
timestamp: Date.now(),
|
|
186
203
|
cameraPosition: position,
|
|
187
204
|
ownership: request.destination ? 'appOwned' : 'packageTemporary',
|
|
188
|
-
|
|
205
|
+
// Clamped the way the real path clamps it, not a second copy of 0.9.
|
|
206
|
+
effectiveQuality: clampQuality(request.quality),
|
|
189
207
|
effectiveMaxDimension:
|
|
190
208
|
request.maxDimension === undefined ? unavailable() : known(request.maxDimension),
|
|
191
209
|
fileSize: known(1024),
|
|
@@ -211,33 +229,26 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
211
229
|
store,
|
|
212
230
|
fs,
|
|
213
231
|
|
|
214
|
-
subscribeToAppState(listener)
|
|
215
|
-
appStateListeners.add(listener);
|
|
216
|
-
return () => {
|
|
217
|
-
appStateListeners.delete(listener);
|
|
218
|
-
};
|
|
219
|
-
},
|
|
232
|
+
subscribeToAppState: listener => appStateListeners.add(listener),
|
|
220
233
|
|
|
221
234
|
setAppState(status) {
|
|
222
|
-
|
|
223
|
-
listener(status);
|
|
224
|
-
}
|
|
235
|
+
appStateListeners.notify(status);
|
|
225
236
|
},
|
|
226
237
|
|
|
227
238
|
setPermission(state) {
|
|
228
239
|
permission = state;
|
|
229
|
-
|
|
230
|
-
listener(state);
|
|
231
|
-
}
|
|
240
|
+
permissionListeners.notify(state);
|
|
232
241
|
},
|
|
233
242
|
|
|
234
|
-
emitInterruption() {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
243
|
+
emitInterruption(code: CameraErrorCode = 'interrupted') {
|
|
244
|
+
// The OS took the camera away. A live session hears about it, and the
|
|
245
|
+
// next start attempt fails until the app clears it.
|
|
246
|
+
startError = code;
|
|
247
|
+
interruptionListeners.notify(code);
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
emitPermissionRequestFailure(enabled) {
|
|
251
|
+
requestThrows = enabled;
|
|
241
252
|
},
|
|
242
253
|
|
|
243
254
|
setCaptureError(code) {
|
|
@@ -248,6 +259,7 @@ export function createTestCamera(config: TestCameraConfig = {}): TestCameraHandl
|
|
|
248
259
|
startError = code;
|
|
249
260
|
},
|
|
250
261
|
|
|
262
|
+
prompts: () => promptCount,
|
|
251
263
|
captures: () => produced,
|
|
252
264
|
torchState: () => torch,
|
|
253
265
|
startCount: () => starts,
|