@mobana/react-native-sdk 0.2.10

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/android/build.gradle +50 -0
  4. package/android/src/main/AndroidManifest.xml +6 -0
  5. package/android/src/main/java/ai/mobana/sdk/MobanaModule.kt +67 -0
  6. package/android/src/main/java/ai/mobana/sdk/MobanaPackage.kt +19 -0
  7. package/app.plugin.js +274 -0
  8. package/ios/Mobana.h +11 -0
  9. package/ios/Mobana.m +20 -0
  10. package/lib/commonjs/Mobana.js +676 -0
  11. package/lib/commonjs/Mobana.js.map +1 -0
  12. package/lib/commonjs/NativeMobana.js +53 -0
  13. package/lib/commonjs/NativeMobana.js.map +1 -0
  14. package/lib/commonjs/api.js +201 -0
  15. package/lib/commonjs/api.js.map +1 -0
  16. package/lib/commonjs/bridge/index.js +19 -0
  17. package/lib/commonjs/bridge/index.js.map +1 -0
  18. package/lib/commonjs/bridge/injectBridge.js +528 -0
  19. package/lib/commonjs/bridge/injectBridge.js.map +1 -0
  20. package/lib/commonjs/components/FlowWebView.js +676 -0
  21. package/lib/commonjs/components/FlowWebView.js.map +1 -0
  22. package/lib/commonjs/components/MobanaProvider.js +275 -0
  23. package/lib/commonjs/components/MobanaProvider.js.map +1 -0
  24. package/lib/commonjs/components/index.js +20 -0
  25. package/lib/commonjs/components/index.js.map +1 -0
  26. package/lib/commonjs/device.js +49 -0
  27. package/lib/commonjs/device.js.map +1 -0
  28. package/lib/commonjs/index.js +20 -0
  29. package/lib/commonjs/index.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/storage.js +277 -0
  32. package/lib/commonjs/storage.js.map +1 -0
  33. package/lib/commonjs/types.js +2 -0
  34. package/lib/commonjs/types.js.map +1 -0
  35. package/lib/module/Mobana.js +673 -0
  36. package/lib/module/Mobana.js.map +1 -0
  37. package/lib/module/NativeMobana.js +49 -0
  38. package/lib/module/NativeMobana.js.map +1 -0
  39. package/lib/module/api.js +194 -0
  40. package/lib/module/api.js.map +1 -0
  41. package/lib/module/bridge/index.js +4 -0
  42. package/lib/module/bridge/index.js.map +1 -0
  43. package/lib/module/bridge/injectBridge.js +523 -0
  44. package/lib/module/bridge/injectBridge.js.map +1 -0
  45. package/lib/module/components/FlowWebView.js +672 -0
  46. package/lib/module/components/FlowWebView.js.map +1 -0
  47. package/lib/module/components/MobanaProvider.js +270 -0
  48. package/lib/module/components/MobanaProvider.js.map +1 -0
  49. package/lib/module/components/index.js +5 -0
  50. package/lib/module/components/index.js.map +1 -0
  51. package/lib/module/device.js +45 -0
  52. package/lib/module/device.js.map +1 -0
  53. package/lib/module/index.js +53 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/storage.js +257 -0
  56. package/lib/module/storage.js.map +1 -0
  57. package/lib/module/types.js +2 -0
  58. package/lib/module/types.js.map +1 -0
  59. package/lib/typescript/Mobana.d.ts +209 -0
  60. package/lib/typescript/Mobana.d.ts.map +1 -0
  61. package/lib/typescript/NativeMobana.d.ts +11 -0
  62. package/lib/typescript/NativeMobana.d.ts.map +1 -0
  63. package/lib/typescript/api.d.ts +34 -0
  64. package/lib/typescript/api.d.ts.map +1 -0
  65. package/lib/typescript/bridge/index.d.ts +3 -0
  66. package/lib/typescript/bridge/index.d.ts.map +1 -0
  67. package/lib/typescript/bridge/injectBridge.d.ts +23 -0
  68. package/lib/typescript/bridge/injectBridge.d.ts.map +1 -0
  69. package/lib/typescript/components/FlowWebView.d.ts +38 -0
  70. package/lib/typescript/components/FlowWebView.d.ts.map +1 -0
  71. package/lib/typescript/components/MobanaProvider.d.ts +65 -0
  72. package/lib/typescript/components/MobanaProvider.d.ts.map +1 -0
  73. package/lib/typescript/components/index.d.ts +5 -0
  74. package/lib/typescript/components/index.d.ts.map +1 -0
  75. package/lib/typescript/device.d.ts +6 -0
  76. package/lib/typescript/device.d.ts.map +1 -0
  77. package/lib/typescript/index.d.ts +46 -0
  78. package/lib/typescript/index.d.ts.map +1 -0
  79. package/lib/typescript/storage.d.ts +68 -0
  80. package/lib/typescript/storage.d.ts.map +1 -0
  81. package/lib/typescript/types.d.ts +298 -0
  82. package/lib/typescript/types.d.ts.map +1 -0
  83. package/mobana.podspec +19 -0
  84. package/package.json +131 -0
  85. package/src/Mobana.ts +742 -0
  86. package/src/NativeMobana.ts +61 -0
  87. package/src/api.ts +259 -0
  88. package/src/bridge/index.ts +2 -0
  89. package/src/bridge/injectBridge.ts +542 -0
  90. package/src/components/FlowWebView.tsx +826 -0
  91. package/src/components/MobanaProvider.tsx +393 -0
  92. package/src/components/index.ts +4 -0
  93. package/src/device.ts +42 -0
  94. package/src/index.ts +66 -0
  95. package/src/storage.ts +262 -0
  96. package/src/types.ts +362 -0
@@ -0,0 +1,826 @@
1
+ import React, { useRef, useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ View,
4
+ StyleSheet,
5
+ ActivityIndicator,
6
+ Linking,
7
+ Vibration,
8
+ Platform,
9
+ Dimensions,
10
+ useColorScheme,
11
+ } from 'react-native';
12
+ import type { WebView as WebViewType, WebViewMessageEvent } from 'react-native-webview';
13
+ import type {
14
+ FlowConfig,
15
+ FlowResult,
16
+ Attribution,
17
+ BridgeMessage,
18
+ HapticStyle,
19
+ SafeArea,
20
+ } from '../types';
21
+ import { generateBridgeScript, buildFlowHtml } from '../bridge/injectBridge';
22
+ import { setLocalData, getAllLocalData, getLocalData } from '../storage';
23
+ import { trackFlowEvent } from '../api';
24
+
25
+ // Optional peer dependencies - gracefully handle if not installed
26
+ let HapticFeedback: {
27
+ trigger: (type: string, options?: { enableVibrateFallback?: boolean }) => void;
28
+ } | null = null;
29
+ let Geolocation: {
30
+ getCurrentPosition: (
31
+ success: (position: { coords: GeolocationCoordinates; timestamp: number }) => void,
32
+ error: (error: { code: number; message: string }) => void,
33
+ options?: { enableHighAccuracy?: boolean; timeout?: number; maximumAge?: number }
34
+ ) => void;
35
+ } | null = null;
36
+
37
+ // react-native-permissions types
38
+ type PermissionStatus = 'unavailable' | 'denied' | 'limited' | 'granted' | 'blocked';
39
+ interface NotificationSettings {
40
+ alert?: boolean;
41
+ badge?: boolean;
42
+ sound?: boolean;
43
+ carPlay?: boolean;
44
+ criticalAlert?: boolean;
45
+ provisional?: boolean;
46
+ providesAppSettings?: boolean;
47
+ lockScreen?: boolean;
48
+ notificationCenter?: boolean;
49
+ }
50
+ interface PermissionsModule {
51
+ check: (permission: string) => Promise<PermissionStatus>;
52
+ request: (permission: string) => Promise<PermissionStatus>;
53
+ checkNotifications: () => Promise<{ status: PermissionStatus; settings: NotificationSettings }>;
54
+ requestNotifications: (options: string[]) => Promise<{ status: PermissionStatus; settings: NotificationSettings }>;
55
+ openSettings: () => Promise<void>;
56
+ PERMISSIONS: {
57
+ IOS: {
58
+ APP_TRACKING_TRANSPARENCY: string;
59
+ LOCATION_WHEN_IN_USE: string;
60
+ LOCATION_ALWAYS: string;
61
+ };
62
+ ANDROID: {
63
+ ACCESS_FINE_LOCATION: string;
64
+ ACCESS_COARSE_LOCATION: string;
65
+ ACCESS_BACKGROUND_LOCATION: string;
66
+ };
67
+ };
68
+ RESULTS: {
69
+ UNAVAILABLE: PermissionStatus;
70
+ DENIED: PermissionStatus;
71
+ LIMITED: PermissionStatus;
72
+ GRANTED: PermissionStatus;
73
+ BLOCKED: PermissionStatus;
74
+ };
75
+ }
76
+
77
+ let Permissions: PermissionsModule | null = null;
78
+
79
+ interface GeolocationCoordinates {
80
+ latitude: number;
81
+ longitude: number;
82
+ accuracy: number;
83
+ altitude: number | null;
84
+ altitudeAccuracy: number | null;
85
+ heading: number | null;
86
+ speed: number | null;
87
+ }
88
+
89
+ let hapticFeedbackWarningShown = false;
90
+ let geolocationWarningShown = false;
91
+ let permissionsWarningShown = false;
92
+
93
+ // Storage keys for tracking permission request states (for "not_requested" detection on Android)
94
+ const LOCATION_REQUESTED_KEY = '@mobana:location_requested';
95
+ const BACKGROUND_LOCATION_REQUESTED_KEY = '@mobana:bg_location_requested';
96
+
97
+ /**
98
+ * Show warning about missing react-native-permissions and return unavailable response
99
+ */
100
+ function warnPermissionsNotInstalled(feature: string): void {
101
+ if (!permissionsWarningShown) {
102
+ permissionsWarningShown = true;
103
+ console.warn(
104
+ `[Mobana] react-native-permissions is not installed. ` +
105
+ `Permission features (${feature}) will not work. To enable permission handling in Flows, install: ` +
106
+ `npm install react-native-permissions\n` +
107
+ `See: https://github.com/zoontek/react-native-permissions for setup instructions.`
108
+ );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Location permission status object returned by getLocationPermissionStatus
114
+ */
115
+ interface LocationPermissionStatus {
116
+ foreground: 'granted' | 'denied' | 'blocked' | 'not_requested';
117
+ background: 'granted' | 'denied' | 'blocked' | 'not_requested';
118
+ precision: 'precise' | 'coarse' | 'unknown';
119
+ }
120
+
121
+ try {
122
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
123
+ HapticFeedback = require('react-native-haptic-feedback').default;
124
+ } catch {
125
+ // Not installed - will use Vibration fallback
126
+ }
127
+
128
+ try {
129
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
130
+ Geolocation = require('react-native-geolocation-service').default;
131
+ } catch {
132
+ // Not installed
133
+ }
134
+
135
+ try {
136
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
137
+ Permissions = require('react-native-permissions');
138
+ } catch {
139
+ // Not installed - permission features will show warning when used
140
+ }
141
+
142
+ // Safe area context - try to import for accurate insets
143
+ let SafeAreaContext: {
144
+ useSafeAreaInsets?: () => { top: number; bottom: number; left: number; right: number };
145
+ initialWindowMetrics?: { insets: { top: number; bottom: number; left: number; right: number } } | null;
146
+ } | null = null;
147
+
148
+ try {
149
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
150
+ SafeAreaContext = require('react-native-safe-area-context');
151
+ } catch {
152
+ // Not installed - will use platform defaults
153
+ }
154
+
155
+ // WebView - required for flows, but loaded dynamically to avoid build failure if not installed
156
+ // MobanaProvider checks for availability before rendering FlowWebView
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ let WebView: React.ComponentType<any> | null = null;
159
+ try {
160
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
161
+ WebView = require('react-native-webview').WebView;
162
+ } catch {
163
+ // Not installed - MobanaProvider will handle this case
164
+ }
165
+
166
+ /**
167
+ * Get safe area insets with fallback to platform defaults
168
+ */
169
+ function getSafeAreaInsets(): { top: number; bottom: number; left: number; right: number } {
170
+ // Try to get from initialWindowMetrics (available at module load time)
171
+ if (SafeAreaContext?.initialWindowMetrics?.insets) {
172
+ return SafeAreaContext.initialWindowMetrics.insets;
173
+ }
174
+
175
+ // Fall back to reasonable platform-specific defaults
176
+ if (Platform.OS === 'ios') {
177
+ // Modern iPhones with notch/dynamic island: ~59pt top (47pt status + 12pt extra for island)
178
+ // Home indicator: ~34pt bottom
179
+ // Older iPhones: ~20pt status bar, 0pt bottom
180
+ const { height } = Dimensions.get('window');
181
+ const hasNotch = height >= 812; // iPhone X and later
182
+ return {
183
+ top: hasNotch ? 59 : 20,
184
+ bottom: hasNotch ? 34 : 0,
185
+ left: 0,
186
+ right: 0,
187
+ };
188
+ } else {
189
+ // Android: typically ~24-32pt for status bar, ~48pt for gesture nav
190
+ return {
191
+ top: 24,
192
+ bottom: 0, // Android gesture nav is usually handled by system
193
+ left: 0,
194
+ right: 0,
195
+ };
196
+ }
197
+ }
198
+
199
+ export interface FlowWebViewProps {
200
+ /** Flow configuration (HTML, CSS, JS) */
201
+ config: FlowConfig;
202
+ /** Flow slug identifier */
203
+ slug: string;
204
+ /** Install ID for tracking */
205
+ installId: string;
206
+ /** API endpoint */
207
+ endpoint: string;
208
+ /** App key for X-App-Key header */
209
+ appKey: string;
210
+ /** Attribution data to pass to flow */
211
+ attribution: Attribution | null;
212
+ /** Custom parameters to pass to flow */
213
+ params?: Record<string, unknown>;
214
+ /** Session ID for grouping all events from this flow presentation */
215
+ sessionId: string;
216
+ /** Called when flow is completed */
217
+ onComplete: (data?: Record<string, unknown>) => void;
218
+ /** Called when flow is dismissed */
219
+ onDismiss: () => void;
220
+ /** Called when flow emits a custom event */
221
+ onEvent?: (name: string) => void;
222
+ /** Async callback for flow-initiated app actions (e.g., purchases) */
223
+ onCallback?: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
224
+ /** Custom props forwarded to the underlying WebView */
225
+ webViewProps?: Record<string, unknown>;
226
+ /** Enable debug logging */
227
+ debug?: boolean;
228
+ }
229
+
230
+ /**
231
+ * Internal WebView component for rendering flows
232
+ * Handles bridge communication between flow JS and native capabilities
233
+ */
234
+ export function FlowWebView({
235
+ config,
236
+ slug,
237
+ installId,
238
+ endpoint,
239
+ appKey,
240
+ attribution,
241
+ params = {},
242
+ sessionId,
243
+ onComplete,
244
+ onDismiss,
245
+ onEvent,
246
+ onCallback,
247
+ webViewProps,
248
+ debug = false,
249
+ }: FlowWebViewProps) {
250
+ const webViewRef = useRef<WebViewType>(null);
251
+ const [isLoading, setIsLoading] = useState(true);
252
+ const [htmlContent, setHtmlContent] = useState<string | null>(null);
253
+ const colorScheme = useColorScheme();
254
+ const isDark = colorScheme === 'dark';
255
+ const bgColor = isDark ? '#1c1c1e' : '#FFFFFF';
256
+
257
+ // Build HTML with bridge on mount
258
+ useEffect(() => {
259
+ const buildHtml = async () => {
260
+ const localData = await getAllLocalData();
261
+ const insets = getSafeAreaInsets();
262
+ const { width, height } = Dimensions.get('window');
263
+
264
+ const safeArea: SafeArea = {
265
+ ...insets,
266
+ width,
267
+ height,
268
+ };
269
+
270
+ const bridgeScript = generateBridgeScript({
271
+ attribution,
272
+ params,
273
+ installId,
274
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
275
+ colorScheme: colorScheme === 'dark' ? 'dark' : 'light',
276
+ localData,
277
+ safeArea,
278
+ });
279
+
280
+ const fullHtml = buildFlowHtml(
281
+ config.html,
282
+ config.css,
283
+ config.js,
284
+ bridgeScript,
285
+ safeArea,
286
+ colorScheme === 'dark' ? 'dark' : 'light'
287
+ );
288
+
289
+ setHtmlContent(fullHtml);
290
+ };
291
+
292
+ buildHtml();
293
+ }, [config, attribution, params, installId, colorScheme]);
294
+
295
+ // Send response back to WebView for async requests
296
+ const sendResponse = useCallback((requestId: number, success: boolean, result: unknown) => {
297
+ const js = `window.__mobanaBridgeResponse(${requestId}, ${success}, ${JSON.stringify(result)});`;
298
+ webViewRef.current?.injectJavaScript(js);
299
+ }, []);
300
+
301
+ // Track flow event
302
+ const trackEvent = useCallback(
303
+ (event: string, step?: number, data?: unknown) => {
304
+ trackFlowEvent(
305
+ endpoint,
306
+ appKey,
307
+ slug,
308
+ installId,
309
+ config.versionId,
310
+ sessionId,
311
+ event,
312
+ step,
313
+ data,
314
+ debug
315
+ );
316
+ },
317
+ [endpoint, appKey, slug, installId, config.versionId, sessionId, debug]
318
+ );
319
+
320
+ // Handle haptic feedback
321
+ const triggerHaptic = useCallback((style: HapticStyle) => {
322
+ if (HapticFeedback) {
323
+ const typeMap: Record<HapticStyle, string> = {
324
+ light: 'impactLight',
325
+ medium: 'impactMedium',
326
+ heavy: 'impactHeavy',
327
+ success: 'notificationSuccess',
328
+ warning: 'notificationWarning',
329
+ error: 'notificationError',
330
+ selection: 'selection',
331
+ };
332
+ HapticFeedback.trigger(typeMap[style] || 'impactMedium', {
333
+ enableVibrateFallback: true,
334
+ });
335
+ } else {
336
+ // Show warning once about missing optional dependency
337
+ if (!hapticFeedbackWarningShown) {
338
+ hapticFeedbackWarningShown = true;
339
+ console.warn(
340
+ '[Mobana] react-native-haptic-feedback is not installed. ' +
341
+ 'Falling back to basic Vibration API. For better haptic feedback, install: ' +
342
+ 'npm install react-native-haptic-feedback'
343
+ );
344
+ }
345
+ // Fallback to basic vibration
346
+ const durationMap: Record<HapticStyle, number> = {
347
+ light: 10,
348
+ medium: 20,
349
+ heavy: 30,
350
+ success: 30,
351
+ warning: 40,
352
+ error: 50,
353
+ selection: 5,
354
+ };
355
+ Vibration.vibrate(durationMap[style] || 20);
356
+ }
357
+ }, []);
358
+
359
+ // Handle messages from WebView
360
+ const handleMessage = useCallback(
361
+ async (event: WebViewMessageEvent) => {
362
+ try {
363
+ const message: BridgeMessage = JSON.parse(event.nativeEvent.data);
364
+ const { type, payload, requestId } = message;
365
+
366
+ if (debug) {
367
+ console.log(`[Mobana] Bridge message: ${type}`, payload);
368
+ }
369
+
370
+ switch (type) {
371
+ // Flow control
372
+ case 'complete':
373
+ onComplete(payload?.data);
374
+ break;
375
+
376
+ case 'dismiss':
377
+ onDismiss();
378
+ break;
379
+
380
+ case 'trackEvent':
381
+ trackEvent(payload?.name);
382
+ onEvent?.(payload?.name);
383
+ break;
384
+
385
+ // Permissions
386
+ case 'requestNotificationPermission':
387
+ if (!Permissions) {
388
+ warnPermissionsNotInstalled('notifications');
389
+ sendResponse(requestId!, false, 'react-native-permissions is not installed');
390
+ break;
391
+ }
392
+ try {
393
+ // Use requestNotifications for both platforms - it's cross-platform and handles
394
+ // Android API level differences (POST_NOTIFICATIONS only exists on API 33+)
395
+ const { status } = await Permissions.requestNotifications(['alert', 'sound', 'badge']);
396
+ sendResponse(requestId!, true, status === Permissions.RESULTS.GRANTED);
397
+ } catch (error) {
398
+ if (debug) {
399
+ console.log('[Mobana] Notification permission request error:', error);
400
+ }
401
+ sendResponse(requestId!, false, 'Permission request failed');
402
+ }
403
+ break;
404
+
405
+ case 'checkNotificationPermission':
406
+ if (!Permissions) {
407
+ warnPermissionsNotInstalled('notifications');
408
+ sendResponse(requestId!, true, { status: 'unavailable', granted: false });
409
+ break;
410
+ }
411
+ try {
412
+ // Use checkNotifications for both platforms - it's cross-platform and handles
413
+ // Android API level differences (POST_NOTIFICATIONS only exists on API 33+)
414
+ const { status, settings } = await Permissions.checkNotifications();
415
+ sendResponse(requestId!, true, {
416
+ status,
417
+ granted: status === Permissions.RESULTS.GRANTED,
418
+ settings, // Detailed settings (alert, badge, sound, etc.)
419
+ });
420
+ } catch (error) {
421
+ if (debug) {
422
+ console.log('[Mobana] Notification permission check error:', error);
423
+ }
424
+ sendResponse(requestId!, true, { status: 'unavailable', granted: false });
425
+ }
426
+ break;
427
+
428
+ case 'requestATTPermission':
429
+ if (Platform.OS !== 'ios') {
430
+ // ATT is iOS only
431
+ sendResponse(requestId!, true, 'authorized');
432
+ break;
433
+ }
434
+ if (!Permissions) {
435
+ warnPermissionsNotInstalled('ATT');
436
+ sendResponse(requestId!, true, 'not-determined');
437
+ break;
438
+ }
439
+ try {
440
+ const result = await Permissions.request(Permissions.PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY);
441
+ const statusMap: Record<string, string> = {
442
+ [Permissions.RESULTS.GRANTED]: 'authorized',
443
+ [Permissions.RESULTS.DENIED]: 'denied',
444
+ [Permissions.RESULTS.BLOCKED]: 'denied',
445
+ [Permissions.RESULTS.UNAVAILABLE]: 'not-determined',
446
+ [Permissions.RESULTS.LIMITED]: 'restricted',
447
+ };
448
+ sendResponse(requestId!, true, statusMap[result] || 'not-determined');
449
+ } catch {
450
+ sendResponse(requestId!, true, 'not-determined');
451
+ }
452
+ break;
453
+
454
+ case 'checkATTPermission':
455
+ if (Platform.OS !== 'ios') {
456
+ // ATT is iOS only - Android doesn't have this restriction
457
+ sendResponse(requestId!, true, 'authorized');
458
+ break;
459
+ }
460
+ if (!Permissions) {
461
+ warnPermissionsNotInstalled('ATT');
462
+ sendResponse(requestId!, true, 'not-determined');
463
+ break;
464
+ }
465
+ try {
466
+ const result = await Permissions.check(Permissions.PERMISSIONS.IOS.APP_TRACKING_TRANSPARENCY);
467
+ const statusMap: Record<string, string> = {
468
+ [Permissions.RESULTS.GRANTED]: 'authorized',
469
+ [Permissions.RESULTS.DENIED]: 'denied',
470
+ [Permissions.RESULTS.BLOCKED]: 'denied',
471
+ [Permissions.RESULTS.UNAVAILABLE]: 'not-determined',
472
+ [Permissions.RESULTS.LIMITED]: 'restricted',
473
+ };
474
+ sendResponse(requestId!, true, statusMap[result] || 'not-determined');
475
+ } catch {
476
+ sendResponse(requestId!, true, 'not-determined');
477
+ }
478
+ break;
479
+
480
+ case 'requestLocationPermission':
481
+ if (!Permissions) {
482
+ warnPermissionsNotInstalled('location');
483
+ sendResponse(requestId!, false, 'react-native-permissions is not installed');
484
+ break;
485
+ }
486
+ try {
487
+ // Mark as requested for "not_requested" detection on Android
488
+ await setLocalData(LOCATION_REQUESTED_KEY, true);
489
+
490
+ // Get precision option: 'precise' (default) or 'coarse'
491
+ const precision = payload?.precision === 'coarse' ? 'coarse' : 'precise';
492
+
493
+ const permission = Platform.select({
494
+ ios: Permissions.PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
495
+ // Android: choose between fine and coarse based on precision option
496
+ android: precision === 'coarse'
497
+ ? Permissions.PERMISSIONS.ANDROID.ACCESS_COARSE_LOCATION
498
+ : Permissions.PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
499
+ });
500
+ if (debug) {
501
+ console.log(`[Mobana] Requesting location permission (precision: ${precision}): ${permission}`);
502
+ }
503
+ if (permission) {
504
+ const result = await Permissions.request(permission);
505
+ if (debug) {
506
+ console.log(`[Mobana] Location permission result: ${result}`);
507
+ }
508
+ sendResponse(requestId!, true, result);
509
+ } else {
510
+ sendResponse(requestId!, true, 'unavailable');
511
+ }
512
+ } catch (error) {
513
+ if (debug) {
514
+ console.log(`[Mobana] Location permission error:`, error);
515
+ }
516
+ sendResponse(requestId!, false, 'Permission request failed');
517
+ }
518
+ break;
519
+
520
+ case 'requestBackgroundLocationPermission':
521
+ if (!Permissions) {
522
+ warnPermissionsNotInstalled('background location');
523
+ sendResponse(requestId!, false, 'react-native-permissions is not installed');
524
+ break;
525
+ }
526
+ try {
527
+ // Mark as requested for "not_requested" detection on Android
528
+ await setLocalData(BACKGROUND_LOCATION_REQUESTED_KEY, true);
529
+
530
+ const permission = Platform.select({
531
+ ios: Permissions.PERMISSIONS.IOS.LOCATION_ALWAYS,
532
+ android: Permissions.PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION,
533
+ });
534
+ if (debug) {
535
+ console.log(`[Mobana] Requesting background location permission: ${permission}`);
536
+ }
537
+ if (permission) {
538
+ const result = await Permissions.request(permission);
539
+ if (debug) {
540
+ console.log(`[Mobana] Background location permission result: ${result}`);
541
+ }
542
+ sendResponse(requestId!, true, result);
543
+ } else {
544
+ sendResponse(requestId!, true, 'unavailable');
545
+ }
546
+ } catch (error) {
547
+ if (debug) {
548
+ console.log(`[Mobana] Background location permission error:`, error);
549
+ }
550
+ sendResponse(requestId!, false, 'Permission request failed');
551
+ }
552
+ break;
553
+
554
+ case 'getLocationPermissionStatus':
555
+ if (!Permissions) {
556
+ warnPermissionsNotInstalled('location status');
557
+ sendResponse(requestId!, true, {
558
+ foreground: 'denied',
559
+ background: 'not_requested',
560
+ precision: 'unknown',
561
+ } as LocationPermissionStatus);
562
+ break;
563
+ }
564
+ try {
565
+ // Check if we've ever requested these permissions (for "not_requested" on Android)
566
+ const locationRequested = await getLocalData(LOCATION_REQUESTED_KEY);
567
+ const bgLocationRequested = await getLocalData(BACKGROUND_LOCATION_REQUESTED_KEY);
568
+
569
+ // Helper to convert react-native-permissions result to our status
570
+ const mapStatus = (result: string, wasRequested: boolean): 'granted' | 'denied' | 'blocked' | 'not_requested' => {
571
+ if (result === Permissions!.RESULTS.GRANTED || result === Permissions!.RESULTS.LIMITED) return 'granted';
572
+ if (result === Permissions!.RESULTS.BLOCKED) return 'blocked';
573
+ // On iOS, RESULTS.DENIED means "not determined" (can ask)
574
+ // On Android, we need to track if we've asked before
575
+ if (result === Permissions!.RESULTS.DENIED) {
576
+ if (Platform.OS === 'ios') {
577
+ return 'not_requested'; // iOS "denied" means "not yet asked"
578
+ }
579
+ // Android: check our tracking flag
580
+ return wasRequested ? 'denied' : 'not_requested';
581
+ }
582
+ if (result === Permissions!.RESULTS.UNAVAILABLE) return 'denied';
583
+ return 'denied';
584
+ };
585
+
586
+ // Check foreground location
587
+ let foregroundStatus: 'granted' | 'denied' | 'blocked' | 'not_requested' = 'not_requested';
588
+ let locationPrecision: 'precise' | 'coarse' | 'unknown' = 'unknown';
589
+
590
+ if (Platform.OS === 'ios') {
591
+ const iosResult = await Permissions.check(Permissions.PERMISSIONS.IOS.LOCATION_WHEN_IN_USE);
592
+ foregroundStatus = mapStatus(iosResult, !!locationRequested);
593
+ // iOS precision is user-controlled, we can't easily detect it without getting location
594
+ // Mark as unknown since we can't determine without actually getting a location
595
+ if (foregroundStatus === 'granted') {
596
+ locationPrecision = 'unknown'; // iOS user may have chosen precise or approximate
597
+ }
598
+ } else {
599
+ // Android: check both fine and coarse to determine precision
600
+ const fineResult = await Permissions.check(Permissions.PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION);
601
+ const coarseResult = await Permissions.check(Permissions.PERMISSIONS.ANDROID.ACCESS_COARSE_LOCATION);
602
+
603
+ if (fineResult === Permissions.RESULTS.GRANTED) {
604
+ foregroundStatus = 'granted';
605
+ locationPrecision = 'precise';
606
+ } else if (coarseResult === Permissions.RESULTS.GRANTED) {
607
+ foregroundStatus = 'granted';
608
+ locationPrecision = 'coarse';
609
+ } else if (fineResult === Permissions.RESULTS.BLOCKED || coarseResult === Permissions.RESULTS.BLOCKED) {
610
+ foregroundStatus = 'blocked';
611
+ } else {
612
+ foregroundStatus = mapStatus(fineResult, !!locationRequested);
613
+ }
614
+ }
615
+
616
+ // Check background location
617
+ let backgroundStatus: 'granted' | 'denied' | 'blocked' | 'not_requested' = 'not_requested';
618
+ const bgPermission = Platform.select({
619
+ ios: Permissions.PERMISSIONS.IOS.LOCATION_ALWAYS,
620
+ android: Permissions.PERMISSIONS.ANDROID.ACCESS_BACKGROUND_LOCATION,
621
+ });
622
+
623
+ if (bgPermission) {
624
+ const bgResult = await Permissions.check(bgPermission);
625
+ backgroundStatus = mapStatus(bgResult, !!bgLocationRequested);
626
+ }
627
+
628
+ const status: LocationPermissionStatus = {
629
+ foreground: foregroundStatus,
630
+ background: backgroundStatus,
631
+ precision: locationPrecision,
632
+ };
633
+
634
+ if (debug) {
635
+ console.log(`[Mobana] Location permission status:`, status);
636
+ }
637
+
638
+ sendResponse(requestId!, true, status);
639
+ } catch (error) {
640
+ if (debug) {
641
+ console.log(`[Mobana] Location permission status error:`, error);
642
+ }
643
+ sendResponse(requestId!, true, {
644
+ foreground: 'denied',
645
+ background: 'not_requested',
646
+ precision: 'unknown',
647
+ } as LocationPermissionStatus);
648
+ }
649
+ break;
650
+
651
+ case 'getCurrentLocation':
652
+ if (Geolocation) {
653
+ Geolocation.getCurrentPosition(
654
+ (position) => {
655
+ sendResponse(requestId!, true, {
656
+ latitude: position.coords.latitude,
657
+ longitude: position.coords.longitude,
658
+ accuracy: position.coords.accuracy,
659
+ altitude: position.coords.altitude,
660
+ altitudeAccuracy: position.coords.altitudeAccuracy,
661
+ heading: position.coords.heading,
662
+ speed: position.coords.speed,
663
+ timestamp: position.timestamp,
664
+ });
665
+ },
666
+ (error) => {
667
+ sendResponse(requestId!, false, error.message);
668
+ },
669
+ { enableHighAccuracy: true, timeout: 15000, maximumAge: 10000 }
670
+ );
671
+ } else {
672
+ if (!geolocationWarningShown) {
673
+ geolocationWarningShown = true;
674
+ console.warn(
675
+ '[Mobana] react-native-geolocation-service is not installed. ' +
676
+ 'getCurrentLocation will not work. To enable location features, install: ' +
677
+ 'npm install react-native-geolocation-service'
678
+ );
679
+ }
680
+ sendResponse(requestId!, false, 'Geolocation not available - react-native-geolocation-service is not installed');
681
+ }
682
+ break;
683
+
684
+ // Native utilities
685
+ case 'requestAppReview':
686
+ // App review can't be shown while Modal is visible (StoreKit limitation)
687
+ // Complete the flow with action, and the provider will show review after modal closes
688
+ onComplete({ action: 'request-app-review' });
689
+ break;
690
+
691
+ case 'haptic':
692
+ triggerHaptic(payload?.style || 'medium');
693
+ break;
694
+
695
+ case 'openURL':
696
+ if (payload?.url) {
697
+ Linking.openURL(payload.url).catch(() => {
698
+ if (debug) {
699
+ console.log(`[Mobana] Failed to open URL: ${payload.url}`);
700
+ }
701
+ });
702
+ }
703
+ break;
704
+
705
+ case 'openSettings':
706
+ if (!Permissions) {
707
+ warnPermissionsNotInstalled('openSettings');
708
+ // Try to open settings via Linking as fallback
709
+ Linking.openSettings().catch(() => {
710
+ if (debug) {
711
+ console.log('[Mobana] Failed to open settings via Linking fallback');
712
+ }
713
+ });
714
+ break;
715
+ }
716
+ Permissions.openSettings().catch(() => {
717
+ if (debug) {
718
+ console.log('[Mobana] Failed to open settings');
719
+ }
720
+ });
721
+ break;
722
+
723
+ // Local data
724
+ case 'setLocalData':
725
+ if (payload?.key !== undefined) {
726
+ await setLocalData(payload.key, payload.value);
727
+ }
728
+ break;
729
+
730
+ // App callback
731
+ case 'requestCallback':
732
+ if (!onCallback) {
733
+ sendResponse(requestId!, false, 'No onCallback handler provided to startFlow()');
734
+ break;
735
+ }
736
+ try {
737
+ const callbackResult = await onCallback(payload?.data || {});
738
+ sendResponse(requestId!, true, callbackResult);
739
+ } catch (error) {
740
+ if (debug) {
741
+ console.log('[Mobana] onCallback error:', error);
742
+ }
743
+ sendResponse(
744
+ requestId!,
745
+ false,
746
+ error instanceof Error ? error.message : 'onCallback handler failed'
747
+ );
748
+ }
749
+ break;
750
+
751
+ default:
752
+ if (debug) {
753
+ console.log(`[Mobana] Unknown bridge message type: ${type}`);
754
+ }
755
+ }
756
+ } catch (error) {
757
+ if (debug) {
758
+ console.log('[Mobana] Failed to parse bridge message:', error);
759
+ }
760
+ }
761
+ },
762
+ [debug, onComplete, onDismiss, onEvent, onCallback, sendResponse, trackEvent, triggerHaptic]
763
+ );
764
+
765
+ if (!htmlContent || !WebView) {
766
+ return (
767
+ <View style={[styles.container, { backgroundColor: bgColor }]}>
768
+ <ActivityIndicator size="large" color={isDark ? '#0A84FF' : '#007AFF'} />
769
+ </View>
770
+ );
771
+ }
772
+
773
+ return (
774
+ <View style={[styles.container, { backgroundColor: bgColor }]}>
775
+ <WebView
776
+ ref={webViewRef}
777
+ source={{ html: htmlContent }}
778
+ style={styles.webview}
779
+ onMessage={handleMessage}
780
+ onLoadStart={() => setIsLoading(true)}
781
+ onLoadEnd={() => setIsLoading(false)}
782
+ originWhitelist={['*']}
783
+ javaScriptEnabled={true}
784
+ domStorageEnabled={true}
785
+ allowsInlineMediaPlayback={true}
786
+ mediaPlaybackRequiresUserAction={false}
787
+ scrollEnabled={true}
788
+ bounces={false}
789
+ showsHorizontalScrollIndicator={false}
790
+ showsVerticalScrollIndicator={false}
791
+ // Security: don't allow navigation away from the flow
792
+ onShouldStartLoadWithRequest={(request: { url: string }) => {
793
+ // Allow initial load and javascript: URLs
794
+ if (request.url === 'about:blank' || request.url.startsWith('data:')) {
795
+ return true;
796
+ }
797
+ // Block external navigation - use openURL bridge instead
798
+ if (request.url.startsWith('http://') || request.url.startsWith('https://')) {
799
+ return false;
800
+ }
801
+ return true;
802
+ }}
803
+ {...webViewProps}
804
+ />
805
+ {isLoading && (
806
+ <View style={[styles.loadingOverlay, { backgroundColor: bgColor }]}>
807
+ <ActivityIndicator size="large" color={isDark ? '#0A84FF' : '#007AFF'} />
808
+ </View>
809
+ )}
810
+ </View>
811
+ );
812
+ }
813
+
814
+ const styles = StyleSheet.create({
815
+ container: {
816
+ flex: 1,
817
+ },
818
+ webview: {
819
+ flex: 1,
820
+ },
821
+ loadingOverlay: {
822
+ ...StyleSheet.absoluteFillObject,
823
+ justifyContent: 'center',
824
+ alignItems: 'center',
825
+ },
826
+ });