@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.
@@ -1,111 +1,104 @@
1
- /**
2
- * Session lifecycle state machine (FR-014..FR-019).
3
- *
4
- * Pure and synchronous: it decides what a transition means, the controller
5
- * performs the I/O. That split is what makes every lifecycle rule in
6
- * `data-model.md` section 2 testable without a camera.
7
- *
8
- * Backgrounding STOPS rather than pauses. Both operating systems can reclaim
9
- * the camera anyway, and holding it risks leaving the OS camera indicator lit
10
- * after the app is no longer in front of the user (FR-015).
11
- */
12
-
13
- import type { SessionStatus } from '../config/types';
14
- import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
15
-
16
- export type SessionEvent =
17
- | { readonly type: 'START' }
18
- | { readonly type: 'STARTED' }
19
- | { readonly type: 'PAUSE' }
20
- | { readonly type: 'RESUME' }
21
- | { readonly type: 'STOP' }
22
- | { readonly type: 'STOPPED' }
23
- | { readonly type: 'BACKGROUND' }
24
- | { readonly type: 'FOREGROUND'; readonly surfaceMounted: boolean }
25
- | { readonly type: 'FAIL'; readonly code: CameraErrorCode }
26
- | { readonly type: 'START_TIMEOUT' };
27
-
28
- export interface SessionSnapshot {
29
- readonly status: SessionStatus;
30
- readonly error: CameraError | null;
31
- }
32
-
33
- export const initialSession: SessionSnapshot = { status: 'idle', error: null };
34
-
35
- /** Statuses from which a fresh start is allowed (FR-019). */
36
- const STARTABLE: ReadonlySet<SessionStatus> = new Set<SessionStatus>([
37
- 'idle',
38
- 'stopped',
39
- 'error',
40
- ]);
41
-
42
- export function canStart(status: SessionStatus): boolean {
43
- return STARTABLE.has(status);
44
- }
45
-
46
- /** Capture is only ever legal from a fully active session (FR-020). */
47
- export function canCapture(status: SessionStatus): boolean {
48
- return status === 'active';
49
- }
50
-
51
- function fail(code: CameraErrorCode): SessionSnapshot {
52
- return { status: 'error', error: new CameraError(code) };
53
- }
54
-
55
- export function sessionReducer(
56
- snapshot: SessionSnapshot,
57
- event: SessionEvent,
58
- ): SessionSnapshot {
59
- const { status } = snapshot;
60
-
61
- switch (event.type) {
62
- case 'START':
63
- if (!canStart(status)) {
64
- // Refuse rather than silently stealing the camera from the surface
65
- // that already holds it (FR-019).
66
- return { status, error: new CameraError('sessionConflict') };
67
- }
68
- return { status: 'starting', error: null };
69
-
70
- case 'STARTED':
71
- return status === 'starting' ? { status: 'active', error: null } : snapshot;
72
-
73
- case 'START_TIMEOUT':
74
- // Never sit in `starting` forever when frames stop arriving.
75
- return status === 'starting' ? fail('captureFailed') : snapshot;
76
-
77
- case 'PAUSE':
78
- return status === 'active' ? { status: 'paused', error: null } : snapshot;
79
-
80
- case 'RESUME':
81
- return status === 'paused' ? { status: 'starting', error: null } : snapshot;
82
-
83
- case 'STOP':
84
- if (status === 'stopped' || status === 'idle') {
85
- return snapshot;
86
- }
87
- return { status: 'stopping', error: snapshot.error };
88
-
89
- case 'STOPPED':
90
- return { status: 'stopped', error: null };
91
-
92
- case 'BACKGROUND':
93
- // Release, do not hold (FR-015).
94
- if (status === 'active' || status === 'paused' || status === 'starting') {
95
- return { status: 'stopping', error: null };
96
- }
97
- return snapshot;
98
-
99
- case 'FOREGROUND':
100
- if (event.surfaceMounted && (status === 'stopped' || status === 'idle')) {
101
- return { status: 'starting', error: null };
102
- }
103
- return snapshot;
104
-
105
- case 'FAIL':
106
- return fail(event.code);
107
-
108
- default:
109
- return snapshot;
110
- }
111
- }
1
+ /**
2
+ * Session lifecycle state machine (FR-014..FR-019).
3
+ *
4
+ * Pure and synchronous: it decides what a transition means, the controller
5
+ * performs the I/O. That split is what makes every lifecycle rule in
6
+ * `data-model.md` section 2 testable without a camera.
7
+ *
8
+ * Backgrounding STOPS rather than pauses. Both operating systems can reclaim
9
+ * the camera anyway, and holding it risks leaving the OS camera indicator lit
10
+ * after the app is no longer in front of the user (FR-015).
11
+ */
12
+
13
+ import type { SessionStatus } from '../config/types';
14
+ import { CameraError, type CameraErrorCode } from '../diagnostics/errors';
15
+
16
+ export type SessionEvent =
17
+ | { readonly type: 'START' }
18
+ | { readonly type: 'STARTED' }
19
+ | { readonly type: 'PAUSE' }
20
+ | { readonly type: 'RESUME' }
21
+ | { readonly type: 'STOP' }
22
+ | { readonly type: 'STOPPED' }
23
+ | { readonly type: 'BACKGROUND' }
24
+ | { readonly type: 'FOREGROUND'; readonly surfaceMounted: boolean }
25
+ | { readonly type: 'FAIL'; readonly code: CameraErrorCode }
26
+ | { readonly type: 'START_TIMEOUT' };
27
+
28
+ export interface SessionSnapshot {
29
+ readonly status: SessionStatus;
30
+ readonly error: CameraError | null;
31
+ }
32
+
33
+ export const initialSession: SessionSnapshot = { status: 'idle', error: null };
34
+
35
+ /** Statuses from which a fresh start is allowed (FR-019). */
36
+ const STARTABLE: ReadonlySet<SessionStatus> = new Set<SessionStatus>(['idle', 'stopped', 'error']);
37
+
38
+ export function canStart(status: SessionStatus): boolean {
39
+ return STARTABLE.has(status);
40
+ }
41
+
42
+ /** Capture is only ever legal from a fully active session (FR-020). */
43
+ export function canCapture(status: SessionStatus): boolean {
44
+ return status === 'active';
45
+ }
46
+
47
+ function fail(code: CameraErrorCode): SessionSnapshot {
48
+ return { status: 'error', error: new CameraError(code) };
49
+ }
50
+
51
+ export function sessionReducer(snapshot: SessionSnapshot, event: SessionEvent): SessionSnapshot {
52
+ const { status } = snapshot;
53
+
54
+ switch (event.type) {
55
+ case 'START':
56
+ if (!canStart(status)) {
57
+ // Refuse rather than silently stealing the camera from the surface
58
+ // that already holds it (FR-019).
59
+ return { status, error: new CameraError('sessionConflict') };
60
+ }
61
+ return { status: 'starting', error: null };
62
+
63
+ case 'STARTED':
64
+ return status === 'starting' ? { status: 'active', error: null } : snapshot;
65
+
66
+ case 'START_TIMEOUT':
67
+ // Never sit in `starting` forever when frames stop arriving.
68
+ return status === 'starting' ? fail('captureFailed') : snapshot;
69
+
70
+ case 'PAUSE':
71
+ return status === 'active' ? { status: 'paused', error: null } : snapshot;
72
+
73
+ case 'RESUME':
74
+ return status === 'paused' ? { status: 'starting', error: null } : snapshot;
75
+
76
+ case 'STOP':
77
+ if (status === 'stopped' || status === 'idle') {
78
+ return snapshot;
79
+ }
80
+ return { status: 'stopping', error: snapshot.error };
81
+
82
+ case 'STOPPED':
83
+ return { status: 'stopped', error: null };
84
+
85
+ case 'BACKGROUND':
86
+ // Release, do not hold (FR-015).
87
+ if (status === 'active' || status === 'paused' || status === 'starting') {
88
+ return { status: 'stopping', error: null };
89
+ }
90
+ return snapshot;
91
+
92
+ case 'FOREGROUND':
93
+ if (event.surfaceMounted && (status === 'stopped' || status === 'idle')) {
94
+ return { status: 'starting', error: null };
95
+ }
96
+ return snapshot;
97
+
98
+ case 'FAIL':
99
+ return fail(event.code);
100
+
101
+ default:
102
+ return snapshot;
103
+ }
104
+ }
@@ -1,92 +1,94 @@
1
- /**
2
- * Typed error taxonomy (FR-044) and redaction helper (FR-038).
3
- *
4
- * The taxonomy exists so consuming apps can branch on `code` and `recoverable`
5
- * instead of string-matching messages. Messages never carry file paths or image
6
- * payloads — `redact` is applied before any value reaches a log sink.
7
- */
8
-
9
- export type CameraErrorCode =
10
- | 'permissionDenied'
11
- | 'cameraUnavailable'
12
- | 'cameraBlocked'
13
- | 'cameraInUse'
14
- | 'sessionConflict'
15
- | 'notReady'
16
- | 'captureInProgress'
17
- | 'captureFailed'
18
- | 'storageFailure'
19
- | 'unsupportedCapability'
20
- | 'interrupted';
21
-
22
- /** Codes the app may retry; everything else ends the session. */
23
- const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
24
- 'permissionDenied',
25
- 'cameraInUse',
26
- 'sessionConflict',
27
- 'notReady',
28
- 'captureInProgress',
29
- 'captureFailed',
30
- 'storageFailure',
31
- 'unsupportedCapability',
32
- 'interrupted',
33
- ]);
34
-
35
- export class CameraError extends Error {
36
- readonly code: CameraErrorCode;
37
- readonly recoverable: boolean;
38
-
39
- constructor(code: CameraErrorCode, message?: string) {
40
- super(message ?? code);
41
- this.name = 'CameraError';
42
- this.code = code;
43
- this.recoverable = RECOVERABLE.has(code);
44
- Object.setPrototypeOf(this, CameraError.prototype);
45
- }
46
- }
47
-
48
- export function isCameraError(value: unknown): value is CameraError {
49
- return value instanceof CameraError;
50
- }
51
-
52
- /** Matches anything path-like: file URIs, POSIX paths, and Windows paths. */
53
- const PATH_PATTERN =
54
- /(?:[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)|(?:[a-zA-Z]:[\\/][^\s"']+)|(?:\/[^\s"']*\/[^\s"']*)/g;
55
- /** Matches base64-ish runs long enough to be an encoded image payload. */
56
- const PAYLOAD_PATTERN = /[A-Za-z0-9+/=]{64,}/g;
57
-
58
- const REDACTED = '[redacted]';
59
- const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
60
-
61
- function redactString(value: string): string {
62
- return value.replace(PATH_PATTERN, REDACTED).replace(PAYLOAD_PATTERN, REDACTED);
63
- }
64
-
65
- /**
66
- * Strips paths and payload-shaped strings from a value before logging (FR-038).
67
- * Recurses through plain objects and arrays; known-sensitive keys are dropped
68
- * wholesale rather than pattern-matched, since a bare filename has no separator
69
- * for the path pattern to catch.
70
- */
71
- export function redact(value: unknown): unknown {
72
- if (typeof value === 'string') {
73
- return redactString(value);
74
- }
75
- if (Array.isArray(value)) {
76
- return value.map(redact);
77
- }
78
- if (value instanceof CameraError) {
79
- return { code: value.code, recoverable: value.recoverable };
80
- }
81
- if (value instanceof Error) {
82
- return { name: value.name, message: redactString(value.message) };
83
- }
84
- if (value && typeof value === 'object') {
85
- const out: Record<string, unknown> = {};
86
- for (const [key, item] of Object.entries(value)) {
87
- out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : redact(item);
88
- }
89
- return out;
90
- }
91
- return value;
92
- }
1
+ /**
2
+ * Typed error taxonomy (FR-044) and redaction helper (FR-038).
3
+ *
4
+ * The taxonomy exists so consuming apps can branch on `code` and `recoverable`
5
+ * instead of string-matching messages. Messages never carry file paths or image
6
+ * payloads — `redact` is applied before any value reaches a log sink.
7
+ */
8
+
9
+ import { COMMON_PATTERNS, PATH_PATTERN, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
10
+
11
+ export type CameraErrorCode =
12
+ | 'permissionDenied'
13
+ | 'cameraUnavailable'
14
+ | 'cameraBlocked'
15
+ | 'cameraInUse'
16
+ | 'sessionConflict'
17
+ | 'notReady'
18
+ | 'captureInProgress'
19
+ | 'captureFailed'
20
+ | 'storageFailure'
21
+ | 'unsupportedCapability'
22
+ | 'interrupted';
23
+
24
+ /** Codes the app may retry; everything else ends the session. */
25
+ const RECOVERABLE: ReadonlySet<CameraErrorCode> = new Set<CameraErrorCode>([
26
+ 'permissionDenied',
27
+ 'cameraInUse',
28
+ 'sessionConflict',
29
+ 'notReady',
30
+ 'captureInProgress',
31
+ 'captureFailed',
32
+ 'storageFailure',
33
+ 'unsupportedCapability',
34
+ 'interrupted',
35
+ ]);
36
+
37
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-camera'`. */
38
+ export class CameraError extends SdkError<CameraErrorCode> {
39
+ constructor(code: CameraErrorCode, message?: string) {
40
+ super('rn-camera', code, { message, retryable: RECOVERABLE.has(code) });
41
+ this.name = 'CameraError';
42
+ }
43
+
44
+ /** @deprecated Use `retryable`, the name every SDK package shares. */
45
+ get recoverable(): boolean {
46
+ return this.retryable;
47
+ }
48
+ }
49
+
50
+ export function isCameraError(value: unknown): value is CameraError {
51
+ return value instanceof CameraError;
52
+ }
53
+
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];
59
+
60
+ const REDACTED = '[redacted]';
61
+ const SENSITIVE_KEYS = new Set(['uri', 'path', 'destination', 'file', 'data', 'base64']);
62
+
63
+ function redactString(value: string): string {
64
+ return redactPatterns(value, PATTERNS);
65
+ }
66
+
67
+ /**
68
+ * Strips paths and payload-shaped strings from a value before logging (FR-038).
69
+ * Recurses through plain objects and arrays; known-sensitive keys are dropped
70
+ * wholesale rather than pattern-matched, since a bare filename has no separator
71
+ * for the path pattern to catch.
72
+ */
73
+ export function redact(value: unknown): unknown {
74
+ if (typeof value === 'string') {
75
+ return redactString(value);
76
+ }
77
+ if (Array.isArray(value)) {
78
+ return value.map(redact);
79
+ }
80
+ if (value instanceof CameraError) {
81
+ return { code: value.code, recoverable: value.retryable };
82
+ }
83
+ if (value instanceof Error) {
84
+ return { name: value.name, message: redactString(value.message) };
85
+ }
86
+ if (value && typeof value === 'object') {
87
+ const out: Record<string, unknown> = {};
88
+ for (const [key, item] of Object.entries(value)) {
89
+ out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : redact(item);
90
+ }
91
+ return out;
92
+ }
93
+ return value;
94
+ }
@@ -1,31 +1,47 @@
1
- /**
2
- * Optional consumer-supplied logger (research R4).
3
- *
4
- * The default is no logging at all, so FR-038 holds whether or not a consumer
5
- * opts in. Event bodies carry codes and states only — never file paths, never
6
- * image data — so an opted-in logger still cannot leak an image path.
7
- */
8
-
9
- export type CameraLogEventType =
10
- | 'session'
11
- | 'permission'
12
- | 'capture'
13
- | 'capability'
14
- | 'cleanup';
15
-
16
- export interface CameraLogEvent {
17
- readonly type: CameraLogEventType;
18
- readonly code: string;
19
- readonly recoverable?: boolean;
20
- }
21
-
22
- export interface CameraLogger {
23
- log(event: CameraLogEvent): void;
24
- }
25
-
26
- /** No-op logger used when the consumer supplies none. */
27
- export const silentLogger: CameraLogger = {
28
- log() {
29
- // Intentionally empty: logging is opt-in.
30
- },
31
- };
1
+ /**
2
+ * Optional consumer-supplied logger (research R4).
3
+ *
4
+ * The default is no logging at all, so FR-038 holds whether or not a consumer
5
+ * opts in. Event bodies carry codes and states only — never file paths, never
6
+ * image data — so an opted-in logger still cannot leak an image path.
7
+ */
8
+
9
+ import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
10
+
11
+ export type CameraLogEventType = 'session' | 'permission' | 'capture' | 'capability' | 'cleanup';
12
+
13
+ export interface CameraLogEvent {
14
+ readonly type: CameraLogEventType;
15
+ readonly code: string;
16
+ readonly recoverable?: boolean | undefined;
17
+ }
18
+
19
+ export interface CameraLogger {
20
+ log(event: CameraLogEvent): void;
21
+ }
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
+
42
+ /** No-op logger used when the consumer supplies none. */
43
+ export const silentLogger: CameraLogger = {
44
+ log() {
45
+ // Intentionally empty: logging is opt-in.
46
+ },
47
+ };