@sanctum-key/react-native-sdk 1.0.14 → 1.0.16

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,7 +1,6 @@
1
- import { Image as RNImage, Platform } from 'react-native';
1
+ import { Platform } from 'react-native';
2
2
  import * as ImageManipulator from "expo-image-manipulator";
3
- import { truncateFields } from '../modules/api/KYCService';
4
- import { logger } from './logger';
3
+
5
4
 
6
5
  type Point = [number, number];
7
6
 
@@ -19,7 +18,7 @@ export function getObbConfidence(cardObb: any): number | null {
19
18
  const first = cardObb[0];
20
19
 
21
20
  // New format: first is an object with a confidence field
22
- if (first && typeof first === 'object' && 'confidence' in first) {
21
+ if (first && typeof first === 'object' && !Array.isArray(first) && 'confidence' in first) {
23
22
  const score = (first as any).confidence;
24
23
  return typeof score === 'number' ? score : null;
25
24
  }
@@ -30,44 +29,29 @@ export function getObbConfidence(cardObb: any): number | null {
30
29
  return typeof score === 'number' ? score : null;
31
30
  }
32
31
 
33
- /**
34
- * Extracts the card_in_frame flag from card_obb when present.
35
- * Returns:
36
- * - true => card fully in frame
37
- * - false => card not fully in frame
38
- * - null => field not present (backward compatibility)
39
- */
40
32
  /**
41
33
  * Extracts the framing data from card_obb.
42
34
  * STRICT CHECK: Returns true ONLY if card_in_frame is true AND cropped_sides is empty.
43
- * Returns:
44
- * - true => card fully in frame (no sides cropped)
45
- * - false => card not fully in frame OR edges are cropped
46
- * - null => field not present (backward compatibility)
47
35
  */
48
36
  export function getCardInFrame(cardObb: any): boolean | null {
49
37
  if (!cardObb) return null;
50
-
51
- // card_obb can be an array of objects or a single object
38
+
52
39
  const first = Array.isArray(cardObb) ? cardObb[0] : cardObb;
53
40
 
54
41
  if (first && typeof first === 'object' && 'card_in_frame' in first) {
55
42
  const isCardInFrame = first.card_in_frame === true;
56
43
  const hasCroppedSides = Array.isArray(first.cropped_sides) && first.cropped_sides.length > 0;
57
44
 
58
- // If the API explicitly says it's not in frame, OR if it lists cropped edges, reject it.
59
45
  if (!isCardInFrame || hasCroppedSides) {
60
46
  if (hasCroppedSides) {
61
47
  console.warn(`Card is cropped on sides: ${first.cropped_sides.join(', ')}`);
62
48
  }
63
- return false;
49
+ return false;
64
50
  }
65
51
 
66
- // If we made it here, card_in_frame is true AND cropped_sides is empty.
67
- return true;
52
+ return true;
68
53
  }
69
54
 
70
- // Backward compatibility check
71
55
  if (cardObb && typeof cardObb === 'object' && 'card_in_frame' in cardObb) {
72
56
  const value = (cardObb as any).card_in_frame;
73
57
  return typeof value === 'boolean' ? value : null;
@@ -76,6 +60,25 @@ export function getCardInFrame(cardObb: any): boolean | null {
76
60
  return null;
77
61
  }
78
62
 
63
+
64
+ function extractObbPoints(cardObb: any): Point[] | null {
65
+ if (!cardObb) return null;
66
+ const first = Array.isArray(cardObb) ? cardObb[0] : cardObb;
67
+
68
+ // New format: { obb: [...], confidence, ... }
69
+ if (first && typeof first === 'object' && !Array.isArray(first) && 'obb' in first) {
70
+ const pts = (first as any).obb;
71
+ if (Array.isArray(pts) && pts.length === 4) return pts as Point[];
72
+ }
73
+
74
+ // Legacy format: [ [p1,p2,p3,p4], confidence ]
75
+ if (Array.isArray(first) && Array.isArray(first[0]) && first[0].length === 4) {
76
+ return first[0] as Point[];
77
+ }
78
+
79
+ return null;
80
+ }
81
+
79
82
  // Compute axis-aligned bounding box from oriented quadrilateral
80
83
  function computeAabb(points: Point[]) {
81
84
  const xs = points.map(p => p[0]);
@@ -101,14 +104,8 @@ async function cropWeb(uri: string, points: Point[]): Promise<string> {
101
104
  if (!ctx) return reject(new Error('Canvas context not available'));
102
105
  ctx.drawImage(
103
106
  img,
104
- minX, // sx
105
- minY, // sy
106
- width, // sw
107
- height, // sh
108
- 0,
109
- 0,
110
- canvas.width,
111
- canvas.height
107
+ minX, minY, width, height, // Source
108
+ 0, 0, canvas.width, canvas.height // Destination
112
109
  );
113
110
  resolve(canvas.toDataURL('image/jpeg', 0.92));
114
111
  };
@@ -117,16 +114,17 @@ async function cropWeb(uri: string, points: Point[]): Promise<string> {
117
114
  });
118
115
  }
119
116
 
120
- // Fallback: return original for native (no dependency added); caller can still use bbox to draw overlay
121
117
  export async function cropByObb(uri: string, cardObb: any): Promise<{ base64?: string; bbox?: { minX: number; minY: number; width: number; height: number } }> {
122
118
  try {
123
- // card_obb format: [ [ [p1,p2,p3,p4], score ] ] — if confidence too low, don't crop (send full image)
124
119
  const confidence = getObbConfidence(cardObb);
125
120
  if (confidence !== null && confidence < OBB_CONFIDENCE_THRESHOLD) {
126
121
  return {};
127
122
  }
128
- const first = Array.isArray(cardObb) ? cardObb[0] : null;
129
- const points = Array.isArray(first?.[0]) ? (first[0] as Point[]) : null;
123
+
124
+ // FIX: Use extractObbPoints() which handles both legacy and new API formats.
125
+ // The old code used `Array.isArray(first?.[0]) ? first[0] : null`, which
126
+ // returned null for new-format responses like [{obb:[...], confidence}].
127
+ const points = extractObbPoints(cardObb);
130
128
  if (!points || points.length !== 4) return {};
131
129
 
132
130
  const { minX, minY, width, height } = computeAabb(points);
@@ -135,7 +133,7 @@ export async function cropByObb(uri: string, cardObb: any): Promise<{ base64?: s
135
133
  const base64 = await cropWeb(uri, points);
136
134
  return { base64, bbox: { minX, minY, width, height } };
137
135
  }
138
- // Native: return bbox only; keep base64 undefined
136
+
139
137
  return { bbox: { minX, minY, width, height } };
140
138
  } catch (e) {
141
139
  return {};
@@ -143,112 +141,177 @@ export async function cropByObb(uri: string, cardObb: any): Promise<{ base64?: s
143
141
  }
144
142
 
145
143
 
144
+ async function normalizeOrientationAndGetDimensions(
145
+ uri: string
146
+ ): Promise<{ normalizedUri: string; width: number; height: number }> {
147
+ // Web: canvas already handles EXIF; skip normalization
148
+ if (Platform.OS === 'web') {
149
+ return new Promise((resolve, reject) => {
150
+ const img = new Image();
151
+ img.onload = () => resolve({ normalizedUri: uri, width: img.naturalWidth, height: img.naturalHeight });
152
+ img.onerror = reject;
153
+ img.src = uri;
154
+ });
155
+ }
146
156
 
147
-
148
- // exemple d'appel :
149
- export async function cropImageWithBBox(uri: string, bbox: any) {
150
- // 1️⃣ Récupère la taille originale de l'image
151
- await RNImage.getSize(uri, (width, height) => {
152
- console.log("Image originale:", width, height);
153
- });
154
-
155
- // // 2️⃣ Suppose que ton bbox vient d'une image affichée dans `displayedSize`
156
- // const scaleX = originalWidth / displayedSize.width;
157
- // const scaleY = originalHeight / displayedSize.height;
158
-
159
- // 3️⃣ Convertir le bbox à la taille réelle scale 0.10 = 10%
160
- const crop = {
161
- originX: bbox.minX,
162
- originY: bbox.minY,
163
- width: bbox.width,
164
- height: bbox.height,
165
- };
166
-
167
- // 4️⃣ Appliquer le crop
168
- // @ts-ignore - manipulateAsync is deprecated but still functional, new API (useImageManipulator) is a React hook and not suitable for utility functions
157
+ // Native: re-encode to bake EXIF rotation into actual pixels
169
158
  const result = await ImageManipulator.manipulateAsync(
170
159
  uri,
171
- [{ crop }],
172
- { compress: 1, format: ImageManipulator.SaveFormat.PNG }
160
+ [], // No transforms — just re-encode to normalize
161
+ { compress: 1, format: ImageManipulator.SaveFormat.JPEG }
173
162
  );
174
-
175
- console.log("Image recadrée:", result.uri);
176
- return result.uri;
163
+ return { normalizedUri: result.uri, width: result.width, height: result.height };
177
164
  }
178
165
 
179
- // Fonction pour rogner avec une tolérance de 10% autour du bbox
180
- export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tolerance: number = 0.1): Promise<string> {
181
- console.log("cropImageWithBBoxWithTolerance", JSON.stringify(truncateFields({ uri, bbox, tolerance }), null, 2));
182
-
183
- return new Promise<string>((resolve, reject) => {
166
+
167
+ export async function cropImageWithBBoxWithTolerance(uri: string, bbox: any, tolerance: number = 0.02): Promise<string> {
168
+ return new Promise<string>((resolve) => {
184
169
  let isResolved = false;
185
-
186
- // Timeout de sécurité (15 secondes)
170
+
171
+ // Safety Timeout (15 seconds — slightly longer to account for normalization step)
187
172
  const timeout = setTimeout(() => {
188
173
  if (!isResolved) {
189
174
  isResolved = true;
190
- console.warn("Timeout lors du rognage avec tolérance, utilisation de l'image originale");
191
- resolve(uri); // Retourner l'URI original en cas de timeout
175
+ console.warn("Crop timeout, returning original.");
176
+ resolve(uri);
192
177
  }
193
178
  }, 15000);
194
179
 
195
- // 1️⃣ Récupère la taille originale de l'image
196
- RNImage.getSize(uri, (imageWidth, imageHeight) => {
197
- if (isResolved) return;
198
-
199
- // 2️⃣ Calculer la tolérance en pixels (10% de chaque côté)
200
- const toleranceX = bbox.width * tolerance;
201
- const toleranceY = bbox.height * tolerance;
202
-
203
- // 3️⃣ Ajuster le bbox avec la tolérance
204
- // Calculer les nouvelles coordonnées avec tolérance
205
- const newMinX = Math.max(0, bbox.minX - toleranceX);
206
- const newMinY = Math.max(0, bbox.minY - toleranceY);
207
- const newMaxX = Math.min(imageWidth, bbox.minX + bbox.width + toleranceX);
208
- const newMaxY = Math.min(imageHeight, bbox.minY + bbox.height + toleranceY);
209
-
210
- // Calculer la largeur et hauteur finales
211
- const finalWidth = newMaxX - newMinX;
212
- const finalHeight = newMaxY - newMinY;
213
-
214
- const crop = {
215
- originX: newMinX,
216
- originY: newMinY,
217
- width: finalWidth,
218
- height: finalHeight,
219
- };
220
-
221
- logger.log("cropImageWithBBoxWithTolerance - crop params", JSON.stringify(truncateFields({ uri, crop, tolerance, imageWidth, imageHeight }), null, 2));
222
-
223
- // 4️⃣ Appliquer le crop avec tolérance
224
- // @ts-ignore - manipulateAsync is deprecated but still functional, new API (useImageManipulator) is a React hook and not suitable for utility functions
225
- ImageManipulator.manipulateAsync(
226
- uri,
227
- [{ crop }],
228
- { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
229
- )
230
- .then((result) => {
231
- if (isResolved) return;
180
+ (async () => {
181
+ try {
182
+ // STEP 1: Normalize EXIF orientation.
183
+ // This is the core fix — on Android the raw photo pixels may be stored
184
+ // rotated. After this call, normalizedUri has pixels matching the on-screen
185
+ // orientation and actualWidth/actualHeight are the display-space dimensions.
186
+ const { normalizedUri, width: actualWidth, height: actualHeight } =
187
+ await normalizeOrientationAndGetDimensions(uri);
188
+
189
+ if (isResolved) return;
190
+
191
+ // STEP 2: Resolve bbox to pixel coordinates.
192
+ // Detect if bbox is percentage (e.g., 0.90) or absolute pixels (e.g., 800).
193
+ const isPercentageBased = bbox.width <= 1.5 && bbox.height <= 1.5;
194
+
195
+ let pixelMinX: number, pixelMinY: number, pixelWidth: number, pixelHeight: number;
196
+
197
+ if (isPercentageBased) {
198
+ pixelMinX = bbox.minX * actualWidth;
199
+ pixelMinY = bbox.minY * actualHeight;
200
+ pixelWidth = bbox.width * actualWidth;
201
+ pixelHeight = bbox.height * actualHeight;
202
+ } else {
203
+ pixelMinX = bbox.minX;
204
+ pixelMinY = bbox.minY;
205
+ pixelWidth = bbox.width;
206
+ pixelHeight = bbox.height;
207
+ }
208
+
209
+ // STEP 3: Add tolerance and clamp to image bounds.
210
+ const toleranceX = pixelWidth * tolerance;
211
+ const toleranceY = pixelHeight * tolerance;
212
+
213
+ const newMinX = Math.max(0, pixelMinX - toleranceX);
214
+ const newMinY = Math.max(0, pixelMinY - toleranceY);
215
+ const newMaxX = Math.min(actualWidth, pixelMinX + pixelWidth + toleranceX);
216
+ const newMaxY = Math.min(actualHeight, pixelMinY + pixelHeight + toleranceY);
217
+
218
+ const finalCrop = {
219
+ originX: Math.floor(newMinX),
220
+ originY: Math.floor(newMinY),
221
+ width: Math.max(1, Math.floor(newMaxX - newMinX)),
222
+ height: Math.max(1, Math.floor(newMaxY - newMinY)),
223
+ };
224
+
225
+ // STEP 4: Perform final crop on the already-normalized image.
226
+ try {
227
+ const result = await ImageManipulator.manipulateAsync(
228
+ normalizedUri,
229
+ [{ crop: finalCrop }],
230
+ { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
231
+ );
232
+
233
+ if (!isResolved) {
234
+ isResolved = true;
235
+ clearTimeout(timeout);
236
+ resolve(result.uri);
237
+ }
238
+ } catch (err) {
239
+ if (!isResolved) {
240
+ isResolved = true;
241
+ clearTimeout(timeout);
242
+ console.error("ImageManipulator crop failed, returning normalized image", err);
243
+
244
+ resolve(normalizedUri);
245
+ }
246
+ }
247
+ } catch (e) {
248
+ if (!isResolved) {
249
+ isResolved = true;
250
+ clearTimeout(timeout);
251
+ console.error("normalizeOrientationAndGetDimensions failed, returning original", e);
252
+ resolve(uri);
253
+ }
254
+ }
255
+ })();
256
+ });
257
+ }
258
+
259
+ export async function cropToCenterScanArea(
260
+ uri: string,
261
+ scanWidthRatio: number = 0.91, // Match Kotlin's 0.91f
262
+ scanAreaAspectRatio: number = 1.59 // Standard ID Card ratio
263
+ ): Promise<string> {
264
+ return new Promise<string>((resolve) => {
265
+ let isResolved = false;
266
+
267
+ const timeout = setTimeout(() => {
268
+ if (!isResolved) {
269
+ isResolved = true;
270
+ resolve(uri);
271
+ }
272
+ }, 10000);
273
+
274
+ (async () => {
275
+ try {
276
+ // 1. Use Claude's brilliant EXIF normalization first!
277
+ const { normalizedUri, width: actualWidth, height: actualHeight } =
278
+ await normalizeOrientationAndGetDimensions(uri);
279
+
280
+ if (isResolved) return;
281
+
282
+ // 2. Apply the Kotlin mathematical centering
283
+ const scanWidth = actualWidth * scanWidthRatio;
284
+ const scanHeight = scanWidth / scanAreaAspectRatio;
285
+ const left = (actualWidth - scanWidth) / 2;
286
+ const top = (actualHeight - scanHeight) / 2;
287
+
288
+ const finalCrop = {
289
+ originX: Math.max(0, Math.floor(left)),
290
+ originY: Math.max(0, Math.floor(top)),
291
+ width: Math.max(1, Math.floor(scanWidth)),
292
+ height: Math.max(1, Math.floor(scanHeight)),
293
+ };
294
+
295
+ // 3. Perform the final centered crop
296
+ const result = await ImageManipulator.manipulateAsync(
297
+ normalizedUri,
298
+ [{ crop: finalCrop }],
299
+ { compress: 0.95, format: ImageManipulator.SaveFormat.JPEG }
300
+ );
301
+
302
+ if (!isResolved) {
232
303
  isResolved = true;
233
304
  clearTimeout(timeout);
234
- console.log("Image recadrée avec tolérance:", result.uri);
235
305
  resolve(result.uri);
236
- })
237
- .catch((error) => {
238
- if (isResolved) return;
306
+ }
307
+ } catch (err) {
308
+ if (!isResolved) {
239
309
  isResolved = true;
240
310
  clearTimeout(timeout);
241
- console.error("Erreur lors du rognage avec tolérance:", error);
242
- // En cas d'erreur, retourner l'URI original au lieu de rejeter
243
- resolve(uri);
244
- });
245
- }, (error) => {
246
- if (isResolved) return;
247
- isResolved = true;
248
- clearTimeout(timeout);
249
- console.error("Erreur lors de la récupération de la taille de l'image:", error);
250
- // En cas d'erreur, retourner l'URI original au lieu de rejeter
251
- resolve(uri);
252
- });
311
+ console.error("Center crop failed, returning original", err);
312
+ resolve(uri); // Fallback to raw image
313
+ }
314
+ }
315
+ })();
253
316
  });
254
- }
317
+ }