@repliqo/sdk-react-native 0.1.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.
Files changed (58) hide show
  1. package/INTEGRATION_GUIDE.md +1312 -0
  2. package/android/build.gradle +24 -0
  3. package/android/src/main/AndroidManifest.xml +4 -0
  4. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +230 -0
  5. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -0
  6. package/android/src/main/java/com/repliqo/screencapture/ScreenCapturePackage.java +38 -0
  7. package/dist/components/ScreenCapturePermission.d.ts +55 -0
  8. package/dist/components/ScreenCapturePermission.js +112 -0
  9. package/dist/core/client.d.ts +41 -0
  10. package/dist/core/client.js +309 -0
  11. package/dist/core/config.d.ts +4 -0
  12. package/dist/core/config.js +26 -0
  13. package/dist/core/logger.d.ts +8 -0
  14. package/dist/core/logger.js +25 -0
  15. package/dist/index.d.ts +15 -0
  16. package/dist/index.js +28 -0
  17. package/dist/snapshot/NativeScreenCapture.d.ts +17 -0
  18. package/dist/snapshot/NativeScreenCapture.js +38 -0
  19. package/dist/snapshot/capture.d.ts +36 -0
  20. package/dist/snapshot/capture.js +109 -0
  21. package/dist/snapshot/captureScreenshot.d.ts +26 -0
  22. package/dist/snapshot/captureScreenshot.js +89 -0
  23. package/dist/trackers/error.tracker.d.ts +22 -0
  24. package/dist/trackers/error.tracker.js +123 -0
  25. package/dist/trackers/navigation.tracker.d.ts +6 -0
  26. package/dist/trackers/navigation.tracker.js +78 -0
  27. package/dist/trackers/screen.tracker.d.ts +2 -0
  28. package/dist/trackers/screen.tracker.js +23 -0
  29. package/dist/trackers/touch.tracker.d.ts +8 -0
  30. package/dist/trackers/touch.tracker.js +30 -0
  31. package/dist/transport/api.client.d.ts +29 -0
  32. package/dist/transport/api.client.js +156 -0
  33. package/dist/transport/batch-queue.d.ts +18 -0
  34. package/dist/transport/batch-queue.js +80 -0
  35. package/dist/types/crash.d.ts +10 -0
  36. package/dist/types/crash.js +2 -0
  37. package/dist/types/events.d.ts +53 -0
  38. package/dist/types/events.js +2 -0
  39. package/dist/types/snapshot.d.ts +21 -0
  40. package/dist/types/snapshot.js +9 -0
  41. package/package.json +64 -0
  42. package/src/components/ScreenCapturePermission.tsx +160 -0
  43. package/src/core/client.ts +425 -0
  44. package/src/core/config.ts +27 -0
  45. package/src/core/logger.ts +27 -0
  46. package/src/index.ts +47 -0
  47. package/src/snapshot/NativeScreenCapture.ts +54 -0
  48. package/src/snapshot/capture.ts +142 -0
  49. package/src/snapshot/captureScreenshot.ts +109 -0
  50. package/src/trackers/error.tracker.ts +136 -0
  51. package/src/trackers/navigation.tracker.ts +98 -0
  52. package/src/trackers/screen.tracker.ts +19 -0
  53. package/src/trackers/touch.tracker.tsx +44 -0
  54. package/src/transport/api.client.ts +225 -0
  55. package/src/transport/batch-queue.ts +95 -0
  56. package/src/types/crash.ts +10 -0
  57. package/src/types/events.ts +59 -0
  58. package/src/types/snapshot.ts +23 -0
@@ -0,0 +1,425 @@
1
+ import { AppState, AppStateStatus } from 'react-native';
2
+ import {
3
+ AnalyticsEvent,
4
+ DeviceInfo,
5
+ ScreenVisit,
6
+ SDKConfig,
7
+ } from '../types/events';
8
+ import { SnapshotPayload } from '../types/snapshot';
9
+ import { SnapshotCapture } from '../snapshot/capture';
10
+ import { ResolvedConfig, resolveConfig } from './config';
11
+ import { Logger } from './logger';
12
+ import { ApiClient } from '../transport/api.client';
13
+ import { BatchQueue } from '../transport/batch-queue';
14
+ import { ErrorTracker } from '../trackers/error.tracker';
15
+ // Native multi-window capture is used automatically via captureScreenshot()
16
+ // when the ScreenCaptureModule is linked. No permission or service needed.
17
+
18
+ export class AppAnalytics {
19
+ private static instance: AppAnalytics | null = null;
20
+
21
+ private config: ResolvedConfig;
22
+ private apiClient: ApiClient;
23
+ private eventQueue: BatchQueue<AnalyticsEvent>;
24
+ private screenVisitQueue: BatchQueue<ScreenVisit>;
25
+ private snapshotQueue: BatchQueue<SnapshotPayload>;
26
+ private snapshotCapture: SnapshotCapture | null = null;
27
+ private errorTracker: ErrorTracker | null = null;
28
+ private sessionId: string | null = null;
29
+ private currentScreen: string | null = null;
30
+ private currentScreenEnteredAt: string | null = null;
31
+ private logger: Logger;
32
+ private appStateSubscription: { remove: () => void } | null = null;
33
+
34
+ private constructor(config: SDKConfig) {
35
+ this.config = resolveConfig(config);
36
+ this.logger = new Logger(this.config.debug);
37
+ this.apiClient = new ApiClient(
38
+ this.config.baseUrl,
39
+ this.config.apiKey,
40
+ this.logger,
41
+ );
42
+
43
+ this.eventQueue = new BatchQueue<AnalyticsEvent>(
44
+ this.config.batchSize,
45
+ this.config.flushInterval,
46
+ async (events: AnalyticsEvent[]) => {
47
+ if (!this.sessionId) {
48
+ this.logger.warn(
49
+ 'Cannot flush events: no active session',
50
+ );
51
+ throw new Error('No active session');
52
+ }
53
+ await this.apiClient.sendEventsBatch(this.sessionId, events);
54
+ },
55
+ );
56
+
57
+ this.screenVisitQueue = new BatchQueue<ScreenVisit>(
58
+ this.config.batchSize,
59
+ this.config.flushInterval,
60
+ async (visits: ScreenVisit[]) => {
61
+ await this.apiClient.sendScreenVisitsBatch(visits);
62
+ },
63
+ );
64
+
65
+ // Snapshots are heavier (~15-30 KB JPEG each) so they use a smaller
66
+ // batch size, shorter flush interval, and a tighter memory cap than
67
+ // the event queue. All configurable via SDKConfig.
68
+ this.snapshotQueue = new BatchQueue<SnapshotPayload>(
69
+ this.config.snapshotBatchSize,
70
+ this.config.snapshotFlushInterval,
71
+ async (snapshots: SnapshotPayload[]) => {
72
+ await this.apiClient.sendSnapshotsBatch(snapshots);
73
+ },
74
+ this.config.snapshotMaxBufferSize,
75
+ );
76
+
77
+ this.eventQueue.startAutoFlush();
78
+ this.screenVisitQueue.startAutoFlush();
79
+ this.snapshotQueue.startAutoFlush();
80
+
81
+ if (this.config.enableSnapshots) {
82
+ this.snapshotCapture = new SnapshotCapture(
83
+ {
84
+ captureInterval: this.config.snapshotInterval,
85
+ maxSnapshotsPerSession: this.config.maxSnapshotsPerSession,
86
+ },
87
+ (snapshot: SnapshotPayload) => {
88
+ this.snapshotQueue.add(snapshot);
89
+ },
90
+ undefined, // default captureScreenshot provider
91
+ (...args: any[]) => this.logger.log(...args),
92
+ (...args: any[]) => this.logger.warn(...args),
93
+ );
94
+ }
95
+
96
+ if (this.config.enableCrashTracking) {
97
+ this.errorTracker = new ErrorTracker((crash) => {
98
+ // Crash reports are sent immediately (fire-and-forget), not batched
99
+ this.apiClient.sendCrashReport(crash).catch(() => {});
100
+ });
101
+ this.errorTracker.start();
102
+ }
103
+
104
+ this.setupAppStateListener();
105
+
106
+ this.logger.log('SDK initialized with config:', {
107
+ baseUrl: this.config.baseUrl,
108
+ appId: this.config.appId,
109
+ batchSize: this.config.batchSize,
110
+ flushInterval: this.config.flushInterval,
111
+ enableSnapshots: this.config.enableSnapshots,
112
+ snapshotInterval: this.config.snapshotInterval,
113
+ enableCrashTracking: this.config.enableCrashTracking,
114
+ });
115
+ }
116
+
117
+ static init(config: SDKConfig): AppAnalytics {
118
+ if (AppAnalytics.instance) {
119
+ AppAnalytics.instance.logger.warn(
120
+ 'AppAnalytics already initialized. Returning existing instance.',
121
+ );
122
+ return AppAnalytics.instance;
123
+ }
124
+
125
+ AppAnalytics.instance = new AppAnalytics(config);
126
+ return AppAnalytics.instance;
127
+ }
128
+
129
+ static getInstance(): AppAnalytics {
130
+ if (!AppAnalytics.instance) {
131
+ throw new Error(
132
+ 'AppAnalytics not initialized. Call AppAnalytics.init(config) first.',
133
+ );
134
+ }
135
+ return AppAnalytics.instance;
136
+ }
137
+
138
+ static destroy(): void {
139
+ if (AppAnalytics.instance) {
140
+ const instance = AppAnalytics.instance;
141
+ instance.logger.log('Destroying AppAnalytics instance');
142
+
143
+ if (instance.errorTracker) {
144
+ instance.errorTracker.stop();
145
+ instance.errorTracker = null;
146
+ }
147
+
148
+ if (instance.snapshotCapture) {
149
+ instance.snapshotCapture.stop();
150
+ instance.snapshotCapture = null;
151
+ }
152
+
153
+
154
+ instance.eventQueue.stopAutoFlush();
155
+ instance.screenVisitQueue.stopAutoFlush();
156
+ instance.snapshotQueue.stopAutoFlush();
157
+
158
+ if (instance.appStateSubscription) {
159
+ instance.appStateSubscription.remove();
160
+ instance.appStateSubscription = null;
161
+ }
162
+
163
+ // Attempt a final flush (fire-and-forget)
164
+ instance.flush().catch(() => {});
165
+
166
+ AppAnalytics.instance = null;
167
+ }
168
+ }
169
+
170
+ async startSession(
171
+ deviceId: string,
172
+ deviceInfo?: DeviceInfo,
173
+ ): Promise<string> {
174
+ try {
175
+ const response = await this.apiClient.startSession(deviceId, deviceInfo);
176
+ this.sessionId = response.id;
177
+ this.logger.log('Session started with ID:', this.sessionId);
178
+
179
+ this.errorTracker?.setSessionId(this.sessionId);
180
+
181
+ if (this.snapshotCapture) {
182
+ this.snapshotCapture.setSessionId(this.sessionId);
183
+ this.snapshotCapture.reset();
184
+
185
+ // Native capture (MediaProjection) is NOT started automatically.
186
+ // It requires a system dialog that is confusing if shown on every
187
+ // launch. The host app can call enableNativeCapture() explicitly
188
+ // when the user opts in. Until then, view-shot is used.
189
+ this.startSnapshotCapture();
190
+ }
191
+
192
+ return this.sessionId;
193
+ } catch (error) {
194
+ this.logger.error('Failed to start session:', error);
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ async endSession(): Promise<void> {
200
+ if (!this.sessionId) {
201
+ this.logger.warn('No active session to end');
202
+ return;
203
+ }
204
+
205
+ // Stop snapshot capture
206
+ this.stopSnapshotCapture();
207
+
208
+ // Flush any pending screen exit
209
+ if (this.currentScreen && this.currentScreenEnteredAt) {
210
+ this.onScreenExit(this.currentScreen);
211
+ }
212
+
213
+ // Flush remaining data
214
+ await this.flush();
215
+
216
+ try {
217
+ await this.apiClient.endSession(this.sessionId);
218
+ this.logger.log('Session ended:', this.sessionId);
219
+ } catch (error) {
220
+ this.logger.error('Failed to end session:', error);
221
+ }
222
+
223
+ this.sessionId = null;
224
+ this.currentScreen = null;
225
+ this.currentScreenEnteredAt = null;
226
+
227
+ this.errorTracker?.setSessionId(null);
228
+
229
+ if (this.snapshotCapture) {
230
+ this.snapshotCapture.setSessionId(null);
231
+ }
232
+ }
233
+
234
+ trackTouch(
235
+ x: number,
236
+ y: number,
237
+ screenName?: string,
238
+ extra?: Record<string, any>,
239
+ ): void {
240
+ if (!this.sessionId) {
241
+ this.logger.warn('Cannot track touch: no active session');
242
+ return;
243
+ }
244
+
245
+ if (!this.config.enableTouchTracking) {
246
+ return;
247
+ }
248
+
249
+ const event: AnalyticsEvent = {
250
+ type: 'touch',
251
+ screenName: screenName || this.currentScreen || undefined,
252
+ data: { x, y, ...extra },
253
+ timestamp: new Date().toISOString(),
254
+ };
255
+
256
+ this.eventQueue.add(event);
257
+ this.logger.log('Touch tracked:', event);
258
+ }
259
+
260
+ trackNavigation(fromScreen: string, toScreen: string): void {
261
+ if (!this.sessionId) {
262
+ this.logger.warn('Cannot track navigation: no active session');
263
+ return;
264
+ }
265
+
266
+ if (!this.config.enableNavigationTracking) {
267
+ return;
268
+ }
269
+
270
+ const event: AnalyticsEvent = {
271
+ type: 'navigation',
272
+ screenName: toScreen,
273
+ data: { fromScreen, toScreen },
274
+ timestamp: new Date().toISOString(),
275
+ };
276
+
277
+ this.eventQueue.add(event);
278
+ this.logger.log('Navigation tracked:', fromScreen, '->', toScreen);
279
+ }
280
+
281
+ trackCustomEvent(eventName: string, data?: Record<string, any>): void {
282
+ if (!this.sessionId) {
283
+ this.logger.warn('Cannot track custom event: no active session');
284
+ return;
285
+ }
286
+
287
+ const event: AnalyticsEvent = {
288
+ type: 'custom',
289
+ screenName: this.currentScreen || undefined,
290
+ data: { eventName, ...data },
291
+ timestamp: new Date().toISOString(),
292
+ };
293
+
294
+ this.eventQueue.add(event);
295
+ this.logger.log('Custom event tracked:', eventName);
296
+ }
297
+
298
+ reportError(error: Error, metadata?: Record<string, any>): void {
299
+ if (!this.errorTracker) {
300
+ this.logger.warn(
301
+ 'Cannot report error: crash tracking is not enabled',
302
+ );
303
+ return;
304
+ }
305
+
306
+ this.errorTracker.reportError(error, metadata);
307
+ }
308
+
309
+ onScreenEnter(screenName: string): void {
310
+ this.currentScreen = screenName;
311
+ this.currentScreenEnteredAt = new Date().toISOString();
312
+ this.errorTracker?.setCurrentScreen(screenName);
313
+ this.snapshotCapture?.setCurrentScreen(screenName);
314
+ this.logger.log('Screen entered:', screenName);
315
+ }
316
+
317
+ onScreenExit(screenName: string): void {
318
+ if (
319
+ this.sessionId &&
320
+ this.currentScreen === screenName &&
321
+ this.currentScreenEnteredAt
322
+ ) {
323
+ const enteredAt = this.currentScreenEnteredAt;
324
+ const exitedAt = new Date().toISOString();
325
+ const duration =
326
+ new Date(exitedAt).getTime() - new Date(enteredAt).getTime();
327
+
328
+ const visit: ScreenVisit = {
329
+ sessionId: this.sessionId,
330
+ screenName,
331
+ enteredAt,
332
+ exitedAt,
333
+ duration,
334
+ };
335
+
336
+ this.screenVisitQueue.add(visit);
337
+ this.logger.log('Screen exited:', screenName, 'duration:', duration);
338
+ }
339
+
340
+ if (this.currentScreen === screenName) {
341
+ this.currentScreen = null;
342
+ this.currentScreenEnteredAt = null;
343
+ this.errorTracker?.setCurrentScreen(null);
344
+ }
345
+ }
346
+
347
+ async flush(): Promise<void> {
348
+ this.logger.log('Flushing queues...');
349
+ await Promise.all([
350
+ this.eventQueue.flush(),
351
+ this.screenVisitQueue.flush(),
352
+ this.snapshotQueue.flush(),
353
+ ]);
354
+ this.logger.log('Flush complete');
355
+ }
356
+
357
+ getSessionId(): string | null {
358
+ return this.sessionId;
359
+ }
360
+
361
+ getCurrentScreen(): string | null {
362
+ return this.currentScreen;
363
+ }
364
+
365
+ /**
366
+ * Force an immediate screenshot capture, outside the periodic schedule.
367
+ * Useful for capturing specific moments (e.g. right after a critical
368
+ * user action). Respects the in-flight lock and session cap.
369
+ */
370
+ async captureSnapshot(screenName?: string): Promise<void> {
371
+ if (!this.sessionId || !this.snapshotCapture) {
372
+ return;
373
+ }
374
+ if (screenName) {
375
+ this.snapshotCapture.setCurrentScreen(screenName);
376
+ }
377
+ await this.snapshotCapture.captureNow();
378
+ }
379
+
380
+ startSnapshotCapture(): void {
381
+ if (this.snapshotCapture) {
382
+ this.snapshotCapture.start();
383
+ this.logger.log('Snapshot capture started');
384
+ }
385
+ }
386
+
387
+ stopSnapshotCapture(): void {
388
+ if (this.snapshotCapture) {
389
+ this.snapshotCapture.stop();
390
+ this.logger.log('Snapshot capture stopped');
391
+ }
392
+ }
393
+
394
+ isInitialized(): boolean {
395
+ return true;
396
+ }
397
+
398
+ private setupAppStateListener(): void {
399
+ let wasInBackground = false;
400
+
401
+ this.appStateSubscription = AppState.addEventListener(
402
+ 'change',
403
+ (nextAppState: AppStateStatus) => {
404
+ if (nextAppState === 'background' || nextAppState === 'inactive') {
405
+ this.logger.log(
406
+ 'App going to background, pausing snapshots + flushing',
407
+ );
408
+ this.stopSnapshotCapture();
409
+ wasInBackground = true;
410
+
411
+ this.flush().catch((error) => {
412
+ this.logger.error('Error flushing on app state change:', error);
413
+ });
414
+ } else if (nextAppState === 'active' && wasInBackground) {
415
+ wasInBackground = false;
416
+ this.logger.log('App returned to foreground, resuming snapshots');
417
+
418
+ if (this.snapshotCapture && this.sessionId) {
419
+ this.startSnapshotCapture();
420
+ }
421
+ }
422
+ },
423
+ );
424
+ }
425
+ }
@@ -0,0 +1,27 @@
1
+ import { SDKConfig } from '../types/events';
2
+
3
+ export const DEFAULT_CONFIG: Required<Omit<SDKConfig, 'apiKey' | 'appId' | 'baseUrl'>> = {
4
+ batchSize: 20,
5
+ flushInterval: 30000,
6
+ enableTouchTracking: true,
7
+ enableNavigationTracking: true,
8
+ enableSnapshots: true,
9
+ // 0.5 fps. JPEG screenshots at 390px/40% quality are ~15-30 KB each,
10
+ // so 2 s gives a good quality/bandwidth tradeoff for MVP session replay.
11
+ snapshotInterval: 2000,
12
+ // Cap sessions at ~30 minutes of continuous capture (900 frames).
13
+ maxSnapshotsPerSession: 900,
14
+ // Smaller batches than events - JPEGs are heavier.
15
+ snapshotBatchSize: 8,
16
+ snapshotFlushInterval: 15000,
17
+ // ~32 * 30KB ≈ 1 MB cap on in-memory buffer if the backend is down.
18
+ snapshotMaxBufferSize: 32,
19
+ enableCrashTracking: true,
20
+ debug: false,
21
+ };
22
+
23
+ export type ResolvedConfig = SDKConfig & typeof DEFAULT_CONFIG;
24
+
25
+ export function resolveConfig(config: SDKConfig): ResolvedConfig {
26
+ return { ...DEFAULT_CONFIG, ...config };
27
+ }
@@ -0,0 +1,27 @@
1
+ export class Logger {
2
+ private debugEnabled: boolean;
3
+ private prefix: string;
4
+
5
+ constructor(debugEnabled: boolean, prefix: string = '[Repliqo]') {
6
+ this.debugEnabled = debugEnabled;
7
+ this.prefix = prefix;
8
+ }
9
+
10
+ log(...args: any[]): void {
11
+ if (this.debugEnabled) {
12
+ console.log(this.prefix, ...args);
13
+ }
14
+ }
15
+
16
+ warn(...args: any[]): void {
17
+ if (this.debugEnabled) {
18
+ console.warn(this.prefix, ...args);
19
+ }
20
+ }
21
+
22
+ error(...args: any[]): void {
23
+ if (this.debugEnabled) {
24
+ console.error(this.prefix, ...args);
25
+ }
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Main class
2
+ export { AppAnalytics } from './core/client';
3
+
4
+ // Trackers
5
+ export { TouchTracker } from './trackers/touch.tracker';
6
+ export {
7
+ useNavigationTracker,
8
+ trackScreenChange,
9
+ } from './trackers/navigation.tracker';
10
+ export {
11
+ trackScreenEnter,
12
+ trackScreenExit,
13
+ } from './trackers/screen.tracker';
14
+ export { ErrorTracker } from './trackers/error.tracker';
15
+
16
+ // Snapshot
17
+ export { SnapshotCapture } from './snapshot/capture';
18
+ export { captureScreenshot } from './snapshot/captureScreenshot';
19
+ export {
20
+ isNativeScreenCaptureAvailable,
21
+ captureNativeFrame,
22
+ } from './snapshot/NativeScreenCapture';
23
+
24
+ // Types
25
+ export type {
26
+ EventType,
27
+ AnalyticsEvent,
28
+ TouchEventData,
29
+ NavigationEventData,
30
+ ScreenVisit,
31
+ DeviceInfo,
32
+ SDKConfig,
33
+ } from './types/events';
34
+
35
+ export type {
36
+ ScreenshotData,
37
+ SnapshotPayload,
38
+ } from './types/snapshot';
39
+
40
+ export type { CrashReport } from './types/crash';
41
+
42
+ export type { SnapshotCaptureConfig } from './snapshot/capture';
43
+ export type { ScreenshotResult } from './snapshot/captureScreenshot';
44
+
45
+ // Config
46
+ export { DEFAULT_CONFIG } from './core/config';
47
+ export type { ResolvedConfig } from './core/config';
@@ -0,0 +1,54 @@
1
+ import { NativeModules, Platform } from 'react-native';
2
+
3
+ export interface NativeFrameResult {
4
+ image: string;
5
+ width: number;
6
+ height: number;
7
+ format: 'jpeg';
8
+ }
9
+
10
+ interface NativeScreenCaptureModule {
11
+ captureFrame(): Promise<{
12
+ image: string;
13
+ width: number;
14
+ height: number;
15
+ format: string;
16
+ } | null>;
17
+ isAvailable(): Promise<boolean>;
18
+ }
19
+
20
+ const NativeModule: NativeScreenCaptureModule | null =
21
+ Platform.OS === 'android'
22
+ ? (NativeModules.ScreenCaptureModule as NativeScreenCaptureModule) ?? null
23
+ : null;
24
+
25
+ /** Whether the native multi-window capture module is available. */
26
+ export function isNativeScreenCaptureAvailable(): boolean {
27
+ return NativeModule !== null;
28
+ }
29
+
30
+ /**
31
+ * Capture a single frame using native multi-window capture.
32
+ * Captures ALL visible windows: main view + modals + alerts + dialogs.
33
+ *
34
+ * No permissions required. No dialog. No foreground service.
35
+ *
36
+ * @returns NativeFrameResult or null if capture failed / not available
37
+ */
38
+ export async function captureNativeFrame(): Promise<NativeFrameResult | null> {
39
+ if (!NativeModule) return null;
40
+
41
+ try {
42
+ const result = await NativeModule.captureFrame();
43
+ if (!result || !result.image) return null;
44
+
45
+ return {
46
+ image: result.image,
47
+ width: result.width,
48
+ height: result.height,
49
+ format: 'jpeg',
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }