diff --git a/app/ios/PassportReader.m b/app/ios/PassportReader.m index 9f0a3bb01..18353cb1c 100644 --- a/app/ios/PassportReader.m +++ b/app/ios/PassportReader.m @@ -15,6 +15,11 @@ @interface RCT_EXTERN_MODULE(PassportReader, NSObject) RCT_EXTERN_METHOD(configure:(NSString *)token enableDebugLogs:(BOOL)enableDebugLogs) +RCT_EXTERN_METHOD(trackEvent:(NSString *)name + properties:(NSDictionary *)properties) + +RCT_EXTERN_METHOD(flush) + RCT_EXTERN_METHOD(scanPassport:(NSString *)passportNumber dateOfBirth:(NSString *)dateOfBirth dateOfExpiry:(NSString *)dateOfExpiry diff --git a/app/ios/PassportReader.swift b/app/ios/PassportReader.swift index 2a6910429..e3b1b6c60 100644 --- a/app/ios/PassportReader.swift +++ b/app/ios/PassportReader.swift @@ -13,6 +13,7 @@ import React import NFCPassportReader #endif import Security +import Mixpanel #if !E2E_TESTING @available(iOS 13, macOS 10.15, *) @@ -46,12 +47,29 @@ class PassportReader: NSObject { super.init() } + private var analytics: SelfAnalytics? + @objc(configure:enableDebugLogs:) func configure(token: String, enableDebugLogs: Bool) { let analytics = SelfAnalytics(token: token, enableDebugLogs: enableDebugLogs) + self.analytics = analytics self.passportReader = NFCPassportReader.PassportReader(analytics: analytics) } + @objc(trackEvent:properties:) + func trackEvent(_ name: String, properties: [String: Any]?) { + if let mpProps = properties as? Properties { + analytics?.trackEvent(name, properties: mpProps) + } else { + analytics?.trackEvent(name, properties: nil) + } + } + + @objc(flush) + func flush() { + analytics?.flush() + } + func getMRZKey(passportNumber: String, dateOfBirth: String, dateOfExpiry: String ) -> String { // Pad fields if necessary diff --git a/app/ios/SelfAnalytics.swift b/app/ios/SelfAnalytics.swift index cf2f5b915..c0b96f9de 100644 --- a/app/ios/SelfAnalytics.swift +++ b/app/ios/SelfAnalytics.swift @@ -6,17 +6,17 @@ import Mixpanel public class SelfAnalytics: Analytics { private let enableDebugLogs: Bool - + public override init(token: String, enableDebugLogs: Bool = false, trackAutomaticEvents: Bool = false) { self.enableDebugLogs = enableDebugLogs super.init(token: token, enableDebugLogs: enableDebugLogs, trackAutomaticEvents: trackAutomaticEvents) } - + public override func trackEvent(_ name: String, properties: Properties? = nil) { super.trackEvent(name, properties: properties) - + print("[NFC Analytics] Event: \(name), Properties: \(properties ?? [:])") - + if let logger = NativeLoggerBridge.shared { logger.sendEvent(withName: "logEvent", body: [ "level": "info", @@ -29,10 +29,10 @@ public class SelfAnalytics: Analytics { public override func trackDebugEvent(_ name: String, properties: Properties? = nil) { super.trackDebugEvent(name, properties: properties) - - if enableDebugLogs { + + if enableDebugLogs { print("[NFC Analytics Debug] Event: \(name), Properties: \(properties ?? [:])") - + if let logger = NativeLoggerBridge.shared { logger.sendEvent(withName: "logEvent", body: [ "level": "debug", @@ -43,12 +43,12 @@ public class SelfAnalytics: Analytics { } } } - + public override func trackError(_ error: Error, context: String) { super.trackError(error, context: context) - + print("[NFC Analytics Error] Context: \(context), Error: \(error.localizedDescription)") - + if let logger = NativeLoggerBridge.shared { logger.sendEvent(withName: "logEvent", body: [ "level": "error", @@ -62,4 +62,8 @@ public class SelfAnalytics: Analytics { ]) } } -} + + public func flush() { + Mixpanel.mainInstance().flush() + } +} diff --git a/app/jest.setup.js b/app/jest.setup.js index 5a0f99656..3c57ed2c3 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -237,8 +237,31 @@ NativeModules.PassportReader = { scanPassport: jest.fn(), trackEvent: jest.fn(), flush: jest.fn(), + reset: jest.fn(), }; +// Mock @/utils/passportReader to properly expose the interface expected by tests +jest.mock('./src/utils/passportReader', () => { + const mockScanPassport = jest.fn(); + // Mock the parameter count for scanPassport (iOS native method takes 9 parameters) + Object.defineProperty(mockScanPassport, 'length', { value: 9 }); + + const mockPassportReader = { + configure: jest.fn(), + scanPassport: mockScanPassport, + trackEvent: jest.fn(), + flush: jest.fn(), + reset: jest.fn(), + }; + + return { + PassportReader: mockPassportReader, + reset: jest.fn(), + scan: jest.fn(), + default: mockPassportReader, + }; +}); + // Mock @stablelib packages jest.mock('@stablelib/cbor', () => ({ encode: jest.fn(), diff --git a/app/src/screens/passport/PassportNFCScanScreen.tsx b/app/src/screens/passport/PassportNFCScanScreen.tsx index abbd29dc3..54dc04eeb 100644 --- a/app/src/screens/passport/PassportNFCScanScreen.tsx +++ b/app/src/screens/passport/PassportNFCScanScreen.tsx @@ -45,7 +45,11 @@ import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; import { useFeedback } from '@/providers/feedbackProvider'; import { storePassportData } from '@/providers/passportDataProvider'; import useUserStore from '@/stores/userStore'; -import { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics'; +import { + flushAllAnalytics, + setNfcScanningActive, + trackNfcEvent, +} from '@/utils/analytics'; import { black, slate100, slate400, slate500, white } from '@/utils/colors'; import { sendFeedbackEmail } from '@/utils/email'; import { dinot } from '@/utils/fonts'; @@ -200,12 +204,17 @@ const PassportNFCScanScreen: React.FC = () => { // Add timestamp when scan starts scanCancelledRef.current = false; const scanStartTime = Date.now(); + + // Mark NFC scanning as active to prevent analytics flush interference + setNfcScanningActive(true); + if (scanTimeoutRef.current) { clearTimeout(scanTimeoutRef.current); scanTimeoutRef.current = null; } scanTimeoutRef.current = setTimeout(() => { scanCancelledRef.current = true; + setNfcScanningActive(false); // Clear scanning state on timeout trackEvent(PassportEvents.NFC_SCAN_FAILED, { error: 'timeout', }); @@ -367,9 +376,9 @@ const PassportNFCScanScreen: React.FC = () => { scanTimeoutRef.current = null; } setIsNfcSheetOpen(false); + setNfcScanningActive(false); } } else if (isNfcSupported) { - flushAllAnalytics(); if (Platform.OS === 'ios') { Linking.openURL('App-Prefs:root=General&path=About'); } else { diff --git a/app/src/utils/analytics.ts b/app/src/utils/analytics.ts index ad7b85a4b..5889d059c 100644 --- a/app/src/utils/analytics.ts +++ b/app/src/utils/analytics.ts @@ -3,7 +3,6 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { AppState, type AppStateStatus } from 'react-native'; -import { PassportReader } from 'react-native-passport-reader'; import { ENABLE_DEBUG_LOGS, MIXPANEL_NFC_PROJECT_TOKEN } from '@env'; import NetInfo from '@react-native-community/netinfo'; import type { JsonMap, JsonValue } from '@segment/analytics-react-native'; @@ -11,6 +10,7 @@ import type { JsonMap, JsonValue } from '@segment/analytics-react-native'; import { TrackEventParams } from '@selfxyz/mobile-sdk-alpha'; import { createSegmentClient } from '@/Segment'; +import { PassportReader } from '@/utils/passportReader'; const segmentClient = createSegmentClient(); @@ -18,6 +18,7 @@ const segmentClient = createSegmentClient(); let mixpanelConfigured = false; let eventCount = 0; let isConnected = true; +let isNfcScanningActive = false; // Track NFC scanning state const eventQueue: Array<{ name: string; properties?: Record; @@ -160,14 +161,19 @@ export const cleanupAnalytics = () => { const setupFlushPolicies = () => { AppState.addEventListener('change', (state: AppStateStatus) => { - if (state === 'background' || state === 'active') { + // Never flush during active NFC scanning to prevent interference + if ( + (state === 'background' || state === 'active') && + !isNfcScanningActive + ) { flushMixpanelEvents().catch(console.warn); } }); NetInfo.addEventListener(state => { isConnected = state.isConnected ?? true; - if (isConnected) { + // Never flush during active NFC scanning to prevent interference + if (isConnected && !isNfcScanningActive) { flushMixpanelEvents().catch(console.warn); } }); @@ -175,6 +181,11 @@ const setupFlushPolicies = () => { const flushMixpanelEvents = async () => { if (!MIXPANEL_NFC_PROJECT_TOKEN) return; + // Skip flush if NFC scanning is active to prevent interference + if (isNfcScanningActive) { + if (__DEV__) console.log('[Mixpanel] flush skipped - NFC scanning active'); + return; + } try { if (__DEV__) console.log('[Mixpanel] flush'); // Send any queued events before flushing @@ -231,8 +242,26 @@ export const flushAllAnalytics = () => { const { flush: flushAnalytics } = analytics(); flushAnalytics(); - // Flush Mixpanel events - flushMixpanelEvents().catch(console.warn); + // Never flush Mixpanel during active NFC scanning to prevent interference + if (!isNfcScanningActive) { + flushMixpanelEvents().catch(console.warn); + } +}; + +/** + * Set NFC scanning state to prevent analytics flush interference + */ +export const setNfcScanningActive = (active: boolean) => { + isNfcScanningActive = active; + if (__DEV__) + console.log( + `[NFC Analytics] Scanning state: ${active ? 'active' : 'inactive'}`, + ); + + // Flush queued events when scanning completes + if (!active && eventQueue.length > 0) { + flushMixpanelEvents().catch(console.warn); + } }; export const trackNfcEvent = async ( @@ -242,7 +271,7 @@ export const trackNfcEvent = async ( if (!MIXPANEL_NFC_PROJECT_TOKEN) return; if (!mixpanelConfigured) await configureNfcAnalytics(); - if (!isConnected) { + if (!isConnected || isNfcScanningActive) { eventQueue.push({ name, properties }); return; } @@ -252,7 +281,8 @@ export const trackNfcEvent = async ( await Promise.resolve(PassportReader.trackEvent(name, properties)); } eventCount++; - if (eventCount >= 5) { + // Prevent automatic flush during NFC scanning + if (eventCount >= 5 && !isNfcScanningActive) { flushMixpanelEvents().catch(console.warn); } } catch { diff --git a/app/src/utils/nfcScanner.ts b/app/src/utils/nfcScanner.ts index 65cee6e9a..487825ca8 100644 --- a/app/src/utils/nfcScanner.ts +++ b/app/src/utils/nfcScanner.ts @@ -4,15 +4,15 @@ import { Buffer } from 'buffer'; import { Platform } from 'react-native'; -import { - PassportReader, - reset, - scan as scanDocument, -} from 'react-native-passport-reader'; import type { PassportData } from '@selfxyz/common/types'; import { configureNfcAnalytics } from '@/utils/analytics'; +import { + PassportReader, + reset, + scan as scanDocument, +} from '@/utils/passportReader'; interface AndroidScanResponse { mrz: string; @@ -57,6 +57,14 @@ export const scan = async (inputs: Inputs) => { const scanAndroid = async (inputs: Inputs) => { reset(); + + if (!scanDocument) { + console.warn( + 'Android passport scanner is not available - native module failed to load', + ); + return Promise.reject(new Error('NFC scanning is currently unavailable.')); + } + return await scanDocument({ documentNumber: inputs.passportNumber, dateOfBirth: inputs.dateOfBirth, @@ -67,6 +75,17 @@ const scanAndroid = async (inputs: Inputs) => { }; const scanIOS = async (inputs: Inputs) => { + if (!PassportReader?.scanPassport) { + console.warn( + 'iOS passport scanner is not available - native module failed to load', + ); + return Promise.reject( + new Error( + 'NFC scanning is currently unavailable. Please ensure the app is properly installed.', + ), + ); + } + return await Promise.resolve( PassportReader.scanPassport( inputs.passportNumber, diff --git a/app/src/utils/passportReader.ts b/app/src/utils/passportReader.ts new file mode 100644 index 000000000..bb8431784 --- /dev/null +++ b/app/src/utils/passportReader.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { NativeModules, Platform } from 'react-native'; + +type ScanOptions = { + documentNumber: string; + dateOfBirth: string; // YYMMDD + dateOfExpiry: string; // YYMMDD + canNumber?: string; + useCan?: boolean; + skipPACE?: boolean; + skipCA?: boolean; + extendedMode?: boolean; + usePacePolling?: boolean; +}; + +// Platform-specific PassportReader implementation +let PassportReader: any; +let reset: any; +let scan: ((options: ScanOptions) => Promise) | null; + +if (Platform.OS === 'android') { + // Android uses the react-native-passport-reader package + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const AndroidPassportReader = require('react-native-passport-reader'); + PassportReader = AndroidPassportReader; + reset = AndroidPassportReader.reset; + scan = AndroidPassportReader.scan; + } catch (error) { + console.warn('Failed to load Android PassportReader:', error); + PassportReader = null; + reset = null; + scan = null; + } +} else if (Platform.OS === 'ios') { + // iOS uses the native PassportReader module directly + PassportReader = NativeModules.PassportReader || null; + + // iOS doesn't have reset function + reset = null; + + // iOS uses scanPassport method with different signature + scan = PassportReader?.scanPassport + ? async (options: ScanOptions) => { + const { + documentNumber, + dateOfBirth, + dateOfExpiry, + canNumber = '', + useCan = false, + skipPACE = false, + skipCA = false, + extendedMode = false, + usePacePolling = true, + } = options; + + const result = await PassportReader.scanPassport( + documentNumber, + dateOfBirth, + dateOfExpiry, + canNumber, + useCan, + skipPACE, + skipCA, + extendedMode, + usePacePolling, + ); + // iOS native returns a JSON string; normalize to object. + try { + return typeof result === 'string' ? JSON.parse(result) : result; + } catch { + return result; + } + } + : null; +} else { + // Unsupported platform + console.warn('PassportReader: Unsupported platform'); + PassportReader = null; + reset = null; + scan = null; +} + +export type { ScanOptions }; +export { PassportReader, reset, scan }; +export default PassportReader; diff --git a/app/tests/src/nativeModules/passportReader.simple.test.ts b/app/tests/src/nativeModules/passportReader.simple.test.ts index 39d6044d0..af589cccb 100644 --- a/app/tests/src/nativeModules/passportReader.simple.test.ts +++ b/app/tests/src/nativeModules/passportReader.simple.test.ts @@ -7,7 +7,7 @@ * These tests verify critical interface requirements without conditional expects */ -import { PassportReader } from 'react-native-passport-reader'; +import { PassportReader } from '@/utils/passportReader'; describe('PassportReader Simple Contract Tests', () => { describe('Critical Interface Requirements', () => { diff --git a/app/tests/utils/nfcScanner.test.ts b/app/tests/utils/nfcScanner.test.ts index 794d9330a..b68b3e48c 100644 --- a/app/tests/utils/nfcScanner.test.ts +++ b/app/tests/utils/nfcScanner.test.ts @@ -3,10 +3,10 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { Platform } from 'react-native'; -import { PassportReader } from 'react-native-passport-reader'; import { configureNfcAnalytics } from '@/utils/analytics'; import { parseScanResponse, scan } from '@/utils/nfcScanner'; +import { PassportReader } from '@/utils/passportReader'; // Mock the analytics module jest.mock('@/utils/analytics', () => ({