@chipmobilesdk/rn-camera 0.2.0 → 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 +207 -93
- package/README.md +390 -364
- package/package.json +52 -47
- package/skills/sdk-rn-camera/SKILL.md +76 -75
- package/src/config/types.ts +143 -149
- package/src/core/capabilities.ts +41 -44
- package/src/core/captureController.ts +120 -120
- package/src/core/controller.ts +522 -518
- package/src/core/sessionMachine.ts +104 -111
- package/src/diagnostics/errors.ts +94 -92
- package/src/diagnostics/logger.ts +47 -31
- package/src/geometry/coordinateMapping.ts +129 -129
- package/src/index.ts +78 -78
- 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 +137 -125
- package/src/react/VisionCameraFeed.tsx +181 -178
- package/src/react/hooks.ts +56 -58
- package/src/storage/artifactStore.ts +141 -141
- package/src/testing/conformance.ts +140 -0
- package/src/testing/index.ts +27 -0
- package/src/testing/testCameraAdapter.ts +246 -256
package/src/react/hooks.ts
CHANGED
|
@@ -1,58 +1,56 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* React bindings for the camera controller.
|
|
3
|
-
*
|
|
4
|
-
* `useCameraController` owns the controller lifetime and disposes it on
|
|
5
|
-
* unmount, which is what guarantees the camera is released when a screen goes
|
|
6
|
-
* away (FR-016).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
10
|
-
|
|
11
|
-
import type { CameraState } from '../config/types';
|
|
12
|
-
import {
|
|
13
|
-
createCameraController,
|
|
14
|
-
type CameraController,
|
|
15
|
-
type CameraControllerOptions,
|
|
16
|
-
} from '../core/controller';
|
|
17
|
-
|
|
18
|
-
export function useCameraController(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
controller.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
58
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for the camera controller.
|
|
3
|
+
*
|
|
4
|
+
* `useCameraController` owns the controller lifetime and disposes it on
|
|
5
|
+
* unmount, which is what guarantees the camera is released when a screen goes
|
|
6
|
+
* away (FR-016).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
10
|
+
|
|
11
|
+
import type { CameraState } from '../config/types';
|
|
12
|
+
import {
|
|
13
|
+
createCameraController,
|
|
14
|
+
type CameraController,
|
|
15
|
+
type CameraControllerOptions,
|
|
16
|
+
} from '../core/controller';
|
|
17
|
+
|
|
18
|
+
export function useCameraController(options: CameraControllerOptions = {}): CameraController {
|
|
19
|
+
// Options usually arrive as a fresh object literal each render; capturing
|
|
20
|
+
// them in a ref keeps the controller stable for the component's lifetime.
|
|
21
|
+
const optionsRef = useRef(options);
|
|
22
|
+
// Construction has no side effects (the controller subscribes on first use),
|
|
23
|
+
// so a controller React builds and discards here costs nothing.
|
|
24
|
+
const [controller, setController] = useState(() => createCameraController(optionsRef.current));
|
|
25
|
+
const disposedRef = useRef(new WeakSet<CameraController>());
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// StrictMode, and React's planned offscreen reuse, run the cleanup and then
|
|
29
|
+
// this effect again on the same component. Disposal is final, so the old
|
|
30
|
+
// controller cannot be revived; replace it. Before this, the screen kept a
|
|
31
|
+
// dead controller after the remount and the camera never started.
|
|
32
|
+
const disposed = disposedRef.current;
|
|
33
|
+
if (disposed.has(controller)) {
|
|
34
|
+
setController(createCameraController(optionsRef.current));
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
disposed.add(controller);
|
|
39
|
+
controller.dispose().catch(() => undefined);
|
|
40
|
+
};
|
|
41
|
+
}, [controller]);
|
|
42
|
+
|
|
43
|
+
return controller;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useCameraState(controller: CameraController): CameraState {
|
|
47
|
+
// `subscribe` is a property of the controller object, so it is stable for the
|
|
48
|
+
// controller's lifetime, which is what useSyncExternalStore requires. The
|
|
49
|
+
// store read is tear-free, unlike the previous useState copy, which could
|
|
50
|
+
// miss a change made between render and effect.
|
|
51
|
+
return useSyncExternalStore(
|
|
52
|
+
controller.subscribe,
|
|
53
|
+
() => controller.state,
|
|
54
|
+
() => controller.state,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ArtifactStore port + default cache-directory adapter (FR-026..FR-029).
|
|
3
|
-
*
|
|
4
|
-
* Package-owned artifacts live in a dedicated `rn-camera` subdirectory of the
|
|
5
|
-
* platform cache directory (research R6). That dedicated prefix is what makes
|
|
6
|
-
* ownership checkable rather than advisory: `owns()` is a path-prefix test, so
|
|
7
|
-
* the package can refuse to delete anything it did not create (FR-028).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export interface ArtifactStore {
|
|
11
|
-
/** Absolute path of the package-owned temporary directory. */
|
|
12
|
-
temporaryDirectory(): string;
|
|
13
|
-
/** True only for paths the package created (FR-028). */
|
|
14
|
-
owns(uri: string): boolean;
|
|
15
|
-
/** Deletes an artifact. Resolves even if already gone (FR-027). */
|
|
16
|
-
remove(uri: string): Promise<void>;
|
|
17
|
-
/** Deletes owned artifacts older than `olderThanMs`; returns the count. */
|
|
18
|
-
sweep(olderThanMs: number): Promise<number>;
|
|
19
|
-
/**
|
|
20
|
-
* Brings a fresh capture under the store and returns its managed URI: a file
|
|
21
|
-
* already inside the store is recorded as created by this process, a file
|
|
22
|
-
* anywhere else is moved in. Without this, a capture the engine wrote to its
|
|
23
|
-
* own temporary directory is never owned, so `cleanup()` refuses it and the
|
|
24
|
-
* sweep never sees it. Optional so app-written stores keep compiling.
|
|
25
|
-
*/
|
|
26
|
-
adopt?(uri: string): Promise<string>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Minimal filesystem surface the adapter needs. Injectable for tests. */
|
|
30
|
-
export interface FileSystemBridge {
|
|
31
|
-
cacheDirectory(): string;
|
|
32
|
-
exists(path: string): Promise<boolean>;
|
|
33
|
-
unlink(path: string): Promise<void>;
|
|
34
|
-
list(directory: string): Promise<readonly string[]>;
|
|
35
|
-
createdAt(path: string): Promise<number>;
|
|
36
|
-
ensureDirectory(directory: string): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
* Moves a file. Needed by `adopt()` to pull a capture out of the engine's own
|
|
39
|
-
* temporary directory; a bridge without it can only adopt files already
|
|
40
|
-
* inside the store.
|
|
41
|
-
*/
|
|
42
|
-
move?(from: string, to: string): Promise<void>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface StoreOptions {
|
|
46
|
-
readonly fs?: FileSystemBridge;
|
|
47
|
-
/** Directory name under the cache directory. Defaults to `rn-camera`. */
|
|
48
|
-
readonly directoryName?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Normalizes separators so prefix comparison works on both platforms. */
|
|
52
|
-
function normalize(path: string): string {
|
|
53
|
-
return path.replace(/\\/g, '/').replace(/^file:\/\//, '');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function createArtifactStore(
|
|
57
|
-
fs: FileSystemBridge,
|
|
58
|
-
directoryName = 'rn-camera',
|
|
59
|
-
): ArtifactStore {
|
|
60
|
-
const root = `${fs.cacheDirectory().replace(/\/$/, '')}/${directoryName}`;
|
|
61
|
-
const normalizedRoot = normalize(root);
|
|
62
|
-
// Artifacts created by this process are never swept, no matter their age.
|
|
63
|
-
const createdThisProcess = new Set<string>();
|
|
64
|
-
|
|
65
|
-
function owns(uri: string): boolean {
|
|
66
|
-
return normalize(uri).startsWith(`${normalizedRoot}/`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
temporaryDirectory() {
|
|
71
|
-
return root;
|
|
72
|
-
},
|
|
73
|
-
|
|
74
|
-
owns,
|
|
75
|
-
|
|
76
|
-
async remove(uri) {
|
|
77
|
-
if (!owns(uri)) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
const path = normalize(uri);
|
|
81
|
-
// Idempotent: a missing artifact is a successful cleanup (FR-027).
|
|
82
|
-
if (await fs.exists(path)) {
|
|
83
|
-
await fs.unlink(path);
|
|
84
|
-
}
|
|
85
|
-
createdThisProcess.delete(path);
|
|
86
|
-
},
|
|
87
|
-
|
|
88
|
-
async sweep(olderThanMs) {
|
|
89
|
-
await fs.ensureDirectory(root);
|
|
90
|
-
const entries = await fs.list(root);
|
|
91
|
-
const cutoff = Date.now() - olderThanMs;
|
|
92
|
-
let removed = 0;
|
|
93
|
-
|
|
94
|
-
for (const entry of entries) {
|
|
95
|
-
const path = normalize(entry);
|
|
96
|
-
if (createdThisProcess.has(path)) {
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const created = await fs.createdAt(path);
|
|
100
|
-
if (created < cutoff) {
|
|
101
|
-
await fs.unlink(path);
|
|
102
|
-
removed += 1;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return removed;
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
async adopt(uri) {
|
|
109
|
-
const path = normalize(uri);
|
|
110
|
-
if (owns(uri)) {
|
|
111
|
-
createdThisProcess.add(path);
|
|
112
|
-
return uri;
|
|
113
|
-
}
|
|
114
|
-
if (!fs.move) {
|
|
115
|
-
throw new Error('This FileSystemBridge has no move(), so it cannot adopt a capture');
|
|
116
|
-
}
|
|
117
|
-
await fs.ensureDirectory(root);
|
|
118
|
-
const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
|
|
119
|
-
const target = `${root}/${name}`;
|
|
120
|
-
await fs.move(path, target);
|
|
121
|
-
createdThisProcess.add(normalize(target));
|
|
122
|
-
return uri.startsWith('file://') ? `file://${normalize(target)}` : target;
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Default adapter. The concrete filesystem bridge is supplied by the native
|
|
129
|
-
* layer at runtime; it is a parameter rather than a hard import so the package
|
|
130
|
-
* takes no filesystem dependency of its own.
|
|
131
|
-
*/
|
|
132
|
-
export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
|
|
133
|
-
const fs = options.fs;
|
|
134
|
-
if (!fs) {
|
|
135
|
-
throw new Error(
|
|
136
|
-
'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
|
|
137
|
-
'createTestCamera() in tests.',
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return createArtifactStore(fs, options.directoryName);
|
|
141
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ArtifactStore port + default cache-directory adapter (FR-026..FR-029).
|
|
3
|
+
*
|
|
4
|
+
* Package-owned artifacts live in a dedicated `rn-camera` subdirectory of the
|
|
5
|
+
* platform cache directory (research R6). That dedicated prefix is what makes
|
|
6
|
+
* ownership checkable rather than advisory: `owns()` is a path-prefix test, so
|
|
7
|
+
* the package can refuse to delete anything it did not create (FR-028).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ArtifactStore {
|
|
11
|
+
/** Absolute path of the package-owned temporary directory. */
|
|
12
|
+
temporaryDirectory(): string;
|
|
13
|
+
/** True only for paths the package created (FR-028). */
|
|
14
|
+
owns(uri: string): boolean;
|
|
15
|
+
/** Deletes an artifact. Resolves even if already gone (FR-027). */
|
|
16
|
+
remove(uri: string): Promise<void>;
|
|
17
|
+
/** Deletes owned artifacts older than `olderThanMs`; returns the count. */
|
|
18
|
+
sweep(olderThanMs: number): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Brings a fresh capture under the store and returns its managed URI: a file
|
|
21
|
+
* already inside the store is recorded as created by this process, a file
|
|
22
|
+
* anywhere else is moved in. Without this, a capture the engine wrote to its
|
|
23
|
+
* own temporary directory is never owned, so `cleanup()` refuses it and the
|
|
24
|
+
* sweep never sees it. Optional so app-written stores keep compiling.
|
|
25
|
+
*/
|
|
26
|
+
adopt?(uri: string): Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal filesystem surface the adapter needs. Injectable for tests. */
|
|
30
|
+
export interface FileSystemBridge {
|
|
31
|
+
cacheDirectory(): string;
|
|
32
|
+
exists(path: string): Promise<boolean>;
|
|
33
|
+
unlink(path: string): Promise<void>;
|
|
34
|
+
list(directory: string): Promise<readonly string[]>;
|
|
35
|
+
createdAt(path: string): Promise<number>;
|
|
36
|
+
ensureDirectory(directory: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Moves a file. Needed by `adopt()` to pull a capture out of the engine's own
|
|
39
|
+
* temporary directory; a bridge without it can only adopt files already
|
|
40
|
+
* inside the store.
|
|
41
|
+
*/
|
|
42
|
+
move?(from: string, to: string): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StoreOptions {
|
|
46
|
+
readonly fs?: FileSystemBridge;
|
|
47
|
+
/** Directory name under the cache directory. Defaults to `rn-camera`. */
|
|
48
|
+
readonly directoryName?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalizes separators so prefix comparison works on both platforms. */
|
|
52
|
+
function normalize(path: string): string {
|
|
53
|
+
return path.replace(/\\/g, '/').replace(/^file:\/\//, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createArtifactStore(
|
|
57
|
+
fs: FileSystemBridge,
|
|
58
|
+
directoryName = 'rn-camera',
|
|
59
|
+
): ArtifactStore {
|
|
60
|
+
const root = `${fs.cacheDirectory().replace(/\/$/, '')}/${directoryName}`;
|
|
61
|
+
const normalizedRoot = normalize(root);
|
|
62
|
+
// Artifacts created by this process are never swept, no matter their age.
|
|
63
|
+
const createdThisProcess = new Set<string>();
|
|
64
|
+
|
|
65
|
+
function owns(uri: string): boolean {
|
|
66
|
+
return normalize(uri).startsWith(`${normalizedRoot}/`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
temporaryDirectory() {
|
|
71
|
+
return root;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
owns,
|
|
75
|
+
|
|
76
|
+
async remove(uri) {
|
|
77
|
+
if (!owns(uri)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const path = normalize(uri);
|
|
81
|
+
// Idempotent: a missing artifact is a successful cleanup (FR-027).
|
|
82
|
+
if (await fs.exists(path)) {
|
|
83
|
+
await fs.unlink(path);
|
|
84
|
+
}
|
|
85
|
+
createdThisProcess.delete(path);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async sweep(olderThanMs) {
|
|
89
|
+
await fs.ensureDirectory(root);
|
|
90
|
+
const entries = await fs.list(root);
|
|
91
|
+
const cutoff = Date.now() - olderThanMs;
|
|
92
|
+
let removed = 0;
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const path = normalize(entry);
|
|
96
|
+
if (createdThisProcess.has(path)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const created = await fs.createdAt(path);
|
|
100
|
+
if (created < cutoff) {
|
|
101
|
+
await fs.unlink(path);
|
|
102
|
+
removed += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return removed;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async adopt(uri) {
|
|
109
|
+
const path = normalize(uri);
|
|
110
|
+
if (owns(uri)) {
|
|
111
|
+
createdThisProcess.add(path);
|
|
112
|
+
return uri;
|
|
113
|
+
}
|
|
114
|
+
if (!fs.move) {
|
|
115
|
+
throw new Error('This FileSystemBridge has no move(), so it cannot adopt a capture');
|
|
116
|
+
}
|
|
117
|
+
await fs.ensureDirectory(root);
|
|
118
|
+
const name = path.slice(path.lastIndexOf('/') + 1) || `capture-${Date.now()}.jpg`;
|
|
119
|
+
const target = `${root}/${name}`;
|
|
120
|
+
await fs.move(path, target);
|
|
121
|
+
createdThisProcess.add(normalize(target));
|
|
122
|
+
return uri.startsWith('file://') ? `file://${normalize(target)}` : target;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Default adapter. The concrete filesystem bridge is supplied by the native
|
|
129
|
+
* layer at runtime; it is a parameter rather than a hard import so the package
|
|
130
|
+
* takes no filesystem dependency of its own.
|
|
131
|
+
*/
|
|
132
|
+
export function createCacheArtifactStore(options: StoreOptions = {}): ArtifactStore {
|
|
133
|
+
const fs = options.fs;
|
|
134
|
+
if (!fs) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'createCacheArtifactStore requires a FileSystemBridge. Pass `fs`, or use ' +
|
|
137
|
+
'createTestCamera() in tests.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return createArtifactStore(fs, options.directoryName);
|
|
141
|
+
}
|
|
@@ -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';
|