Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/ios/PassportReader.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions app/ios/PassportReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React
import NFCPassportReader
#endif
import Security
import Mixpanel
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard Mixpanel import to avoid E2E/CI compile breaks

Unconditional import Mixpanel can fail in E2E or CI where Mixpanel isn’t linked. Wrap it.

Apply:

-import Mixpanel
+#if canImport(Mixpanel)
+import Mixpanel
+#endif
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import Mixpanel
#if canImport(Mixpanel)
import Mixpanel
#endif
🤖 Prompt for AI Agents
In app/ios/PassportReader.swift around line 16, the unconditional import
Mixpanel can cause compile failures in E2E/CI when the Mixpanel framework isn’t
linked; wrap the import with a compile-time check using Swift's conditional
import (e.g., #if canImport(Mixpanel) import Mixpanel #endif) and ensure any
subsequent references to Mixpanel are either guarded by the same #if
canImport(Mixpanel) blocks or replaced with a no-op alternative so the file
compiles when Mixpanel is unavailable.


#if !E2E_TESTING
@available(iOS 13, macOS 10.15, *)
Expand Down Expand Up @@ -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()
}

Comment on lines +59 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add E2E stubs for trackEvent/flush to keep the RN API consistent

The bridge declares these methods unconditionally in Objective-C. In E2E builds, missing selectors can cause runtime errors if JS calls them.

Apply near the E2E stub class:

 class PassportReader: NSObject {
     override init() {
         super.init()
     }
 
+    @objc(trackEvent:properties:)
+    func trackEvent(_ name: String, properties: [String: Any]?) {
+        // No-op in E2E testing
+    }
+
+    @objc(flush)
+    func flush() {
+        // No-op in E2E testing
+    }
+
     @objc(configure:enableDebugLogs:)
     func configure(token: String, enableDebugLogs: Bool) {
         // No-op for E2E testing
     }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/ios/PassportReader.swift around lines 59 to 72, the Obj-C bridge methods
trackEvent(_:properties:) and flush() are declared unconditionally but not
present in the E2E stub class, which can cause missing-selector crashes when JS
calls them; add matching stub methods to the E2E stub class that expose
@objc(trackEvent:properties:) func trackEvent(_ name: String, properties:
[String: Any]?) and @objc(flush) func flush(), each implementing a no-op (or
safe logging) and returning without side effects so the RN API is consistent
across builds.

func getMRZKey(passportNumber: String, dateOfBirth: String, dateOfExpiry: String ) -> String {

// Pad fields if necessary
Expand Down
26 changes: 15 additions & 11 deletions app/ios/SelfAnalytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -62,4 +62,8 @@ public class SelfAnalytics: Analytics {
])
}
}
}

public func flush() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi @seshanthS added a flush method to the analytics package

Mixpanel.mainInstance().flush()
}
}
23 changes: 23 additions & 0 deletions app/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});

Comment on lines +243 to +264
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Mock the aliased module name and expose ESM default to avoid resolution edge cases.
Using a relative path here can miss consumers importing via '@/...'. Also, expose __esModule to keep default import interop stable.

Apply:

-// Mock @/utils/passportReader to properly expose the interface expected by tests
-jest.mock('./src/utils/passportReader', () => {
+// Mock @/utils/passportReader to properly expose the interface expected by tests
+jest.mock('@/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 {
+    __esModule: true,
     PassportReader: mockPassportReader,
     reset: jest.fn(),
     scan: jest.fn(),
     default: mockPassportReader,
   };
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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 @/utils/passportReader to properly expose the interface expected by tests
jest.mock('@/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 {
__esModule: true,
PassportReader: mockPassportReader,
reset: jest.fn(),
scan: jest.fn(),
default: mockPassportReader,
};
});
🤖 Prompt for AI Agents
In app/jest.setup.js around lines 243 to 264, the mock uses a relative path and
doesn't expose ESM default interop; change the jest.mock target to the aliased
module '@/utils/passportReader' and return an object that includes __esModule:
true plus the existing default and named exports (PassportReader, reset, scan)
so consumers importing via the alias or using default import see the same
interface; keep the mock functions and the adjusted length on scanPassport
unchanged.

// Mock @stablelib packages
jest.mock('@stablelib/cbor', () => ({
encode: jest.fn(),
Expand Down
13 changes: 11 additions & 2 deletions app/src/screens/passport/PassportNFCScanScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
});
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 37 additions & 7 deletions app/src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
// 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';

import { TrackEventParams } from '@selfxyz/mobile-sdk-alpha';

import { createSegmentClient } from '@/Segment';
import { PassportReader } from '@/utils/passportReader';

const segmentClient = createSegmentClient();

// --- Analytics flush strategy ---
let mixpanelConfigured = false;
let eventCount = 0;
let isConnected = true;
let isNfcScanningActive = false; // Track NFC scanning state
const eventQueue: Array<{
name: string;
properties?: Record<string, unknown>;
Expand Down Expand Up @@ -160,21 +161,31 @@ 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);
}
});
};

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
Expand Down Expand Up @@ -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 (
Expand All @@ -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;
}
Expand All @@ -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 {
Expand Down
29 changes: 24 additions & 5 deletions app/src/utils/nfcScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.'));
}
Comment on lines 59 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Guard and await reset to avoid TypeError and race.
reset may be null (when the native module fails to load) or async. Calling it unguarded can throw; not awaiting can leave stale state before scanDocument.

Apply:

-  reset();
+  if (typeof reset === 'function') {
+    await Promise.resolve(reset());
+  }
🤖 Prompt for AI Agents
In app/src/utils/nfcScanner.ts around lines 59 to 66, calling reset() unguarded
can throw if reset is null and must be awaited if it returns a Promise to avoid
a race; change the code to first check that reset is a function (e.g. typeof
reset === 'function'), await reset() inside a try/catch to handle any errors
(log or surface as rejection), then proceed to the existing scanDocument guard
and rejection when the native module is missing; also ensure the containing
function is declared async so you can use await.


return await scanDocument({
documentNumber: inputs.passportNumber,
dateOfBirth: inputs.dateOfBirth,
Expand All @@ -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,
Expand Down
Loading
Loading