Skip to content

Wire WebView camera adapter and harden Android SDK launch flow#1805

Open
transphorm wants to merge 6 commits intodevfrom
justin/kmp-chunks-1e-and-2f
Open

Wire WebView camera adapter and harden Android SDK launch flow#1805
transphorm wants to merge 6 commits intodevfrom
justin/kmp-chunks-1e-and-2f

Conversation

@transphorm
Copy link
Member

@transphorm transphorm commented Mar 3, 2026

Summary

  • Chunk 1E (WebView App Shell): Replace bridge-based MRZ scanning with the camera adapter API. DocumentCameraScreen now receives documentType and countryCode from navigation state and passes them through to camera.scanMRZ(). Adds stale-scan guard (mountedRef + scanGenerationRef) to prevent state updates after unmount/cancel, MRZ field validation before NFC navigation, and sanitized error handling (generic user
    message, stable error codes for analytics).

  • Chunk 2F (SDK Public API + Test App): Add activity binding and ActivityResultLauncher management to SelfSdk.android.kt so SelfSdk.launch() works from any ComponentActivity. Includes in-flight verification guard (pendingCallback check), ensureLauncher() with stable registry key, try-catch around launcher.launch() to prevent stuck state, and WeakReference-based activity tracking. KMP test app gets a new
    SdkLaunchScreen that exercises the full configure → launch path.

  • Tests: Added VerificationResult serialization round-trip test.

Tested

Explain how the change has been tested (for example by manual testing, unit tests etc) or why it's not necessary (for example version bump).

How to QA

How can the change be tested in a repeatable manner?

Summary by CodeRabbit

  • New Features

    • Android: activity binding API, explicit-activity launch overload, per-activity launcher lifecycle, and concurrent-launch protection with clearer error handling.
  • New Screens

    • Test app: SdkLaunchScreen to configure and launch verifications and display callback results.
  • Tests

    • Added VerificationResult JSON round-trip serialization test.
  • Refactor

    • WebView MRZ scanning moved to camera API; camera adapter accepts optional MRZ params (exported type added).
  • Behavior

    • Android lifecycle/result codes clarified for explicit success/error/cancel outcomes.

@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 3, 2026

📝 Walkthrough

Walkthrough

Adds per-Activity binding and lifecycle-aware launcher management to the Android KMP SDK launch flow, standardizes lifecycle result codes, introduces a Compose SDK launch screen, replaces Bridge MRZ with camera.scanMRZ(params?), and adds a VerificationResult round-trip test and spec/status updates.

Changes

Cohort / File(s) Summary
Android SDK Launch & Activity Management
packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
Adds Companion.bindActivity, public launch(activity, request, callback) overload, launchInternal, ensureLauncher, activity-binding state (currentActivity, boundActivity, launcherOwner), pending-callback guards, launcher lifecycle observer/unregister, and explicit error/result handling (LAUNCHER_NOT_AVAILABLE, MISSING_ACTIVITY, LAUNCH_FAILED, etc.).
Result Code Standardization
packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandler.kt
Replaces Android framework result codes with SelfVerificationActivity.RESULT_CODE_* constants across result-setting and dismiss flows.
Test Infrastructure
packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/models/ModelSerializationTest.kt
Adds verificationResult_roundtrip() test to assert JSON serialize/deserialize round-trip for VerificationResult.
Test App Integration
packages/kmp-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/MainActivity.kt
Calls SelfSdk.bindActivity(this) in onCreate to provide a default activity binding for SDK launches.
Test App Navigation & Screen
packages/kmp-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/App.kt, .../screens/SdkLaunchScreen.kt
Changes start destination to sdk_launch and adds SdkLaunchScreen Compose UI to configure/launch SDK flows and display callback status/results.
WebView Camera API & MRZ Flow
packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx, packages/webview-bridge/src/adapters/camera.ts, packages/webview-bridge/src/adapters/index.ts
Replaces Bridge MRZ scan with camera.scanMRZ({documentType?,countryCode?}), adds MrzScanParams type, introduces generation/mounted/in-flight guards to avoid stale results, trims/validates MRZ fields, standardizes MRZ error codes/messages, and updates exports.
Documentation / Specs
specs/person1-webview/SPEC.md, specs/person2-native-shells/OVERVIEW.md, specs/person2-native-shells/SPEC.md
Updates status/acceptance rows to mark wiring/validation done for WebView and native shells and notes follow-ups.

Sequence Diagram

sequenceDiagram
    actor User
    participant MainActivity as MainActivity<br/>(ComponentActivity)
    participant SelfSdk as SelfSdk
    participant Launcher as ActivityResultLauncher
    participant SelfVerActivity as SelfVerificationActivity
    participant Callback as SelfSdkCallback

    User->>MainActivity: onCreate()
    MainActivity->>SelfSdk: bindActivity(this)
    SelfSdk->>SelfSdk: currentActivity = MainActivity

    User->>SelfSdk: launch(request, callback)

    rect rgba(100,150,200,0.5)
    Note over SelfSdk: Resolve activity and guard concurrent launches
    SelfSdk->>SelfSdk: resolveActivity() → activity
    SelfSdk->>SelfSdk: if pendingCallback → return VERIFICATION_IN_PROGRESS
    SelfSdk->>SelfSdk: ensureLauncher(activity)
    SelfSdk->>Launcher: registerForActivityResult()
    SelfSdk->>SelfSdk: pendingCallback = callback
    end

    rect rgba(150,100,200,0.5)
    Note over SelfSdk,SelfVerActivity: Launch verification UI
    SelfSdk->>SelfVerActivity: startActivityForResult(intent with serialized config/request)
    SelfVerActivity->>User: show verification UI
    User->>SelfVerActivity: complete or cancel
    SelfVerActivity->>Launcher: setResult(RESULT_CODE_*)
    Launcher->>SelfSdk: onActivityResult()
    end

    rect rgba(100,200,150,0.5)
    Note over SelfSdk,Callback: Result handling
    SelfSdk->>SelfSdk: parse result / handle PARSE_ERROR, MISSING_RESULT, UNEXPECTED_RESULT
    SelfSdk->>Callback: onSuccess/onError/onCancel
    SelfSdk->>SelfSdk: pendingCallback = null
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

codex

Suggested reviewers

  • remicolin
  • seshanthS

Poem

🚀 Bound activities hum in tune,
Launchers guard the verification moon,
Cameras swap the old relay,
Results parse truth from fray,
Tests and screens applaud the new day.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the main changes (Chunks 1E and 2F) and test additions, but the Tested and How to QA sections are incomplete/unpopulated. Complete the 'Tested' and 'How to QA' sections to explain testing approach and provide repeatable QA steps for both chunks.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the two main changes: wiring the WebView camera adapter (Chunk 1E) and hardening the Android SDK launch flow (Chunk 2F).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch justin/kmp-chunks-1e-and-2f

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx (3)

60-63: ⚠️ Potential issue | 🟠 Major

Stop sending raw scan error text to analytics/UI.

Line 63 logs err.message directly; scanner/native errors can include sensitive document context. Emit stable error codes and generic user copy instead.

🔒 Proposed fix
-    } catch (err) {
-      const message = err instanceof Error ? err.message : 'MRZ scan failed';
-      setError(message);
-      analytics.trackEvent('camera_mrz_scan_failed', { error: message });
+    } catch (err) {
+      const userMessage = 'MRZ scan failed. Please try again.';
+      setError(userMessage);
+      analytics.trackEvent('camera_mrz_scan_failed', {
+        errorCode: 'mrz_scan_failed',
+      });

As per coding guidelines: "NEVER log sensitive data including PII..." and "ALWAYS redact/mask sensitive fields in logs..."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx` around
lines 60 - 63, The catch block in DocumentCameraScreen.tsx currently exposes raw
err.message to setError and analytics.trackEvent; instead map errors to stable,
non-sensitive error codes and a generic user-facing message: replace using
err.message with a lookup (e.g., getMrzErrorCode(err) → returns codes like
"MRZ_TIMEOUT", "MRZ_IMAGE_BLUR", "MRZ_UNKNOWN") and call setError with a generic
copy ("MRZ scan failed, please try again") while sending only the stable code
and non-sensitive metadata to analytics (e.g.,
analytics.trackEvent('camera_mrz_scan_failed', { error_code })) and keep the
full err captured only in internal logs (internalLogger.error(err)) but never in
UI/analytics.

37-71: ⚠️ Potential issue | 🟠 Major

Guard pending MRZ scans from resolving after cancel/unmount.

The useEffect at line 70 auto-starts scanning without cleanup. When user clicks cancel (line 101), the component navigates immediately (line 75), but the pending camera.scanMRZ() promise can still resolve afterward, triggering state updates (line 51 navigation, line 62 error) on the unmounted component. Since the camera bridge has no cancel/abort API, use a stale-scan guard:

Stale-scan guard with generation and mounted tracking
  const [scanning, setScanning] = useState(false);
  const [error, setError] = useState<string | null>(null);
+ const scanGenerationRef = React.useRef(0);
+ const mountedRef = React.useRef(true);

  const startMRZScan = useCallback(async () => {
+   const scanId = ++scanGenerationRef.current;
    setScanning(true);
    setError(null);
    analytics.trackEvent('camera_mrz_scan_started', {
      documentType,
      countryCode,
    });

    try {
      const result = await camera.scanMRZ({ documentType, countryCode });
+     if (!mountedRef.current || scanId !== scanGenerationRef.current) return;

      haptic.trigger('success');
      analytics.trackEvent('camera_mrz_scan_success');

      navigate('/onboarding/nfc', {
        state: {
          countryCode,
          documentType,
          passportNumber: result.documentNumber,
          dateOfBirth: result.dateOfBirth,
          dateOfExpiry: result.dateOfExpiry,
        },
      });
    } catch (err) {
+     if (!mountedRef.current || scanId !== scanGenerationRef.current) return;
+
      const message = err instanceof Error ? err.message : 'MRZ scan failed';
      setError(message);
      analytics.trackEvent('camera_mrz_scan_failed', { error: message });
    } finally {
-     setScanning(false);
+     if (mountedRef.current && scanId === scanGenerationRef.current) {
+       setScanning(false);
+     }
    }
  }, [camera, navigate, analytics, haptic, documentType, countryCode]);

  useEffect(() => {
+   mountedRef.current = true;
    startMRZScan();
+   return () => {
+     mountedRef.current = false;
+     scanGenerationRef.current += 1;
+   };
  }, [startMRZScan]);

  const onCancel = useCallback(() => {
+   scanGenerationRef.current += 1;
    analytics.trackEvent('camera_screen_closed');
    navigate('/');
  }, [navigate, analytics]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx` around
lines 37 - 71, The startMRZScan function can resolve after cancel/unmount
causing state updates/navigation on an unmounted component; fix by adding a
stale-scan guard: introduce a ref like scanGeneration (and optionally isMounted)
incremented before calling camera.scanMRZ, capture the current generation in a
local variable inside startMRZScan, and before calling haptic.trigger,
analytics.trackEvent, navigate, setError or setScanning verify the captured
generation still matches the current ref (and that isMounted is true in
cleanup). Also mark isMounted = false in a cleanup returned from the useEffect
so any pending promise resolution is ignored and avoid updating state or
navigating when the scan is stale or component unmounted.

46-57: ⚠️ Potential issue | 🟡 Minor

Add validation for required MRZ fields before NFC navigation.

The camera.scanMRZ return type declares non-nullable strings (documentNumber, dateOfBirth, dateOfExpiry), so this is not a nullability issue. However, defensive validation between the camera scan result and NFC navigation is reasonable given that the NFC screen already has empty string defaults—indicating the team anticipates incomplete data.

Consider adding an early check:

      const result = await camera.scanMRZ({ documentType, countryCode });
+     if (!result.documentNumber?.trim() || !result.dateOfBirth?.trim() || !result.dateOfExpiry?.trim()) {
+       throw new Error('Incomplete MRZ data. Please rescan your document.');
+     }

This provides better UX by failing fast rather than progressing to NFC with empty fields.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx` around
lines 46 - 57, In DocumentCameraScreen, add defensive validation after
camera.scanMRZ returns to ensure result.documentNumber, result.dateOfBirth, and
result.dateOfExpiry are non-empty before calling navigate('/onboarding/nfc'); if
any are missing/empty, do not navigate—trigger a failure haptic (e.g.,
haptic.trigger('notificationFailed')), log/track the failure with analytics
(e.g., analytics.trackEvent('camera_mrz_scan_incomplete')), and show a
user-facing error/toast so the user can retry scanning; update the code paths
around scanMRZ, haptic, analytics.trackEvent and navigate to enforce this
early-return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 125-137: The launcher.launch(intent) call can throw synchronously
leaving pendingCallback set and blocking future verifications; wrap the launch
in a try-catch around the code that uses activityLauncher/launcher and if an
exception occurs clear pendingCallback and call callback.onFailure with a
SelfSdkError (use a code like "LAUNCH_FAILED" or the existing
"VERIFICATION_IN_PROGRESS" handling style) including the exception message so
the failure is surfaced; ensure the normal path still calls
launcher.launch(intent) and returns without clearing pendingCallback (only clear
it in the exception path) so verification state is consistent.
- Around line 160-162: The launcher is being registered with a non-deterministic
key using activity.hashCode(), which changes on Activity recreation and breaks
callback delivery; update the call that registers the launcher (the
activity.activityResultRegistry.register(...) invocation) to use a stable
deterministic key instead (for example a constant like "self-sdk-verification"
or a derived stable string such as
"${activity::class.java.name}-self-sdk-verification") so the registry preserves
the callback mapping across recreation and pendingCallback remains deliverable;
ensure every place that builds the key (and where pendingCallback is referenced)
uses the same stable identifier (e.g., define SELF_SDK_VERIFICATION_KEY and use
it in the register call and related logic).

In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandler.kt`:
- Around line 74-90: Remove the business-logic branching in
LifecycleBridgeHandler that interprets type/success/errorCode/errorMessage;
instead always forward the raw lifecycle payload fields to the intent and let
TypeScript decide outcome. Concretely, stop using the if/else that sets
SelfVerificationActivity.RESULT_CODE_SUCCESS/RESULT_CODE_ERROR/RESULT_CODE_CANCELLED
based on type/success/errorCode, and instead always add the raw extras
(EXTRA_RESULT_TYPE if present, EXTRA_RESULT_DATA if present,
EXTRA_ERROR_CODE/EXTRA_ERROR_MESSAGE if present) to the intent and call
activity.setResult with a single neutral delivery code (e.g. keep
RESULT_CODE_SUCCESS as the transport result) so all success/error/cancel
semantics are resolved in the TypeScript layer.

In `@specs/person1-webview/SPEC.md`:
- Line 1340: The table row labeled "1E WebView App Shell" currently claims
camera and biometrics adapters are wired, but later sections still list those
items as out-of-scope; reconcile by updating the single source of truth: locate
the "1E WebView App Shell" entry and either change its status from "Done" to "In
Progress/Blocked" if camera/biometrics are not implemented, or remove/update the
later out-of-scope follow-ups that reference camera/biometrics so they reflect
completion; ensure both the table row and the later follow-up section describing
camera, biometrics, and camera adapters are consistent and reference the same
final status.

In `@specs/person2-native-shells/OVERVIEW.md`:
- Line 25: Update the OV ERVIEW.md and PR description to back the "KMP test app
validation on both platforms" checkbox with reproducible evidence: populate the
"Tested" and "How to QA" sections with the exact commands you ran (e.g., Android
assemble and iOS compile commands), terminal outputs or CI job URLs showing
success, and attach any device/emulator info; additionally document
platform-specific native module code paths touched in this change (briefly
reference the affected native modules/classes and their platform branches) so
reviewers can reproduce verification steps locally or via CI before marking the
item completed.

---

Outside diff comments:
In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx`:
- Around line 60-63: The catch block in DocumentCameraScreen.tsx currently
exposes raw err.message to setError and analytics.trackEvent; instead map errors
to stable, non-sensitive error codes and a generic user-facing message: replace
using err.message with a lookup (e.g., getMrzErrorCode(err) → returns codes like
"MRZ_TIMEOUT", "MRZ_IMAGE_BLUR", "MRZ_UNKNOWN") and call setError with a generic
copy ("MRZ scan failed, please try again") while sending only the stable code
and non-sensitive metadata to analytics (e.g.,
analytics.trackEvent('camera_mrz_scan_failed', { error_code })) and keep the
full err captured only in internal logs (internalLogger.error(err)) but never in
UI/analytics.
- Around line 37-71: The startMRZScan function can resolve after cancel/unmount
causing state updates/navigation on an unmounted component; fix by adding a
stale-scan guard: introduce a ref like scanGeneration (and optionally isMounted)
incremented before calling camera.scanMRZ, capture the current generation in a
local variable inside startMRZScan, and before calling haptic.trigger,
analytics.trackEvent, navigate, setError or setScanning verify the captured
generation still matches the current ref (and that isMounted is true in
cleanup). Also mark isMounted = false in a cleanup returned from the useEffect
so any pending promise resolution is ignored and avoid updating state or
navigating when the scan is stale or component unmounted.
- Around line 46-57: In DocumentCameraScreen, add defensive validation after
camera.scanMRZ returns to ensure result.documentNumber, result.dateOfBirth, and
result.dateOfExpiry are non-empty before calling navigate('/onboarding/nfc'); if
any are missing/empty, do not navigate—trigger a failure haptic (e.g.,
haptic.trigger('notificationFailed')), log/track the failure with analytics
(e.g., analytics.trackEvent('camera_mrz_scan_incomplete')), and show a
user-facing error/toast so the user can retry scanning; update the code paths
around scanMRZ, haptic, analytics.trackEvent and navigate to enforce this
early-return behavior.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cd89775 and 0728dbd.

📒 Files selected for processing (12)
  • packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
  • packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandler.kt
  • packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/models/ModelSerializationTest.kt
  • packages/kmp-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/MainActivity.kt
  • packages/kmp-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/App.kt
  • packages/kmp-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt
  • packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx
  • packages/webview-bridge/src/adapters/camera.ts
  • packages/webview-bridge/src/adapters/index.ts
  • specs/person1-webview/SPEC.md
  • specs/person2-native-shells/OVERVIEW.md
  • specs/person2-native-shells/SPEC.md

@transphorm transphorm marked this pull request as draft March 3, 2026 16:53
@transphorm transphorm changed the title Justin/kmp chunks 1e and 2f Wire WebView camera adapter and harden Android SDK launch flow Mar 4, 2026
@transphorm transphorm marked this pull request as ready for review March 4, 2026 01:09
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 170-189: Move the ActivityResult registration out of the lazy
ensureLauncher() and perform it unconditionally inside bindActivity():
unregister any existing activityLauncher, set launcherOwner =
WeakReference(activity), and call activity.activityResultRegistry.register(...)
there to assign activityLauncher and its callback (preserving the existing
pendingCallback variable handling). Update launchInternal() to assume the
launcher is already registered and remove lazy registration logic in
ensureLauncher() (or call ensureLauncher() from bindActivity()); this guarantees
registration before STARTED and that recreated Activities receive results so
pendingCallback is not lost when the Activity is recreated.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx`:
- Around line 43-44: The scanGenerationRef increment alone doesn't prevent
concurrent native scans; add an in-flight lock (e.g., isScanningRef or
scanLockRef) checked at the top of startMRZScan and set to true immediately
before calling camera.scanMRZ, then clear it in a finally block so duplicate
calls return early and the lock is always released on success/error; apply the
same pattern around other camera.scanMRZ usages (the blocks guarded by
scanGenerationRef) to ensure only one native scan runs at a time.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0728dbd and 8375e95.

📒 Files selected for processing (3)
  • packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
  • packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx
  • specs/person2-native-shells/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • specs/person2-native-shells/SPEC.md

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt (1)

98-264: ⚠️ Potential issue | 🟠 Major

Native layer now contains business logic that should live in TypeScript per repo rule.

This segment implements verification-state policy, result classification, serialization/deserialization, and error contract mapping directly in Kotlin. That exceeds a thin native bridge and increases platform divergence risk.

As per coding guidelines "{app,packages}/**/*.kt: Native handlers (Kotlin/Swift) should be thin wrappers with no business logic - all logic must live in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`
around lines 98 - 264, The Kotlin file implements business logic that must live
in TypeScript; update the native bridge functions (launchInternal,
ensureLauncher, handleActivityResult, serializeRequest, serializeConfig,
deserializeResult) to be thin: stop classifying results or parsing JSON on
Android, only pass raw payloads and error codes through the callback so TS can
handle logic. Concretely, have serializeRequest/serializeConfig remain as simple
passthrough JSON encoders if needed but remove deserializeResult usage in
handleActivityResult and instead forward the raw EXTRA_RESULT_DATA (JSON string)
and EXTRA_RESULT_TYPE or error extras directly to the pending SelfSdkCallback
(e.g., onSuccess should receive rawResultJson and/or type, onFailure should
receive the raw errorCode and errorMessage), remove any parsing/classification
(PARSE_ERROR, MISSING_RESULT, UNEXPECTED_RESULT mapping) and ensure
launchInternal only handles lifecycle/errors around launching the activity
without embedding business rules.
packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx (1)

52-55: ⚠️ Potential issue | 🟠 Major

Make telemetry/haptics best-effort so they cannot block scan/cancel flow.

Line 52 runs analytics.trackEvent(...) before the try/finally; if it throws, scanInFlightRef is never released. Also, Lines 70-72 and Line 112 keep non-critical side effects in the critical control path, so exceptions can flip success into failure or block cancel navigation.

🔧 Proposed fix
+  const safeTrackEvent = useCallback(
+    (event: string, props?: Record<string, unknown>) => {
+      try {
+        analytics.trackEvent(event, props);
+      } catch {}
+    },
+    [analytics],
+  );
+
+  const safeSuccessHaptic = useCallback(() => {
+    try {
+      haptic.trigger('success');
+    } catch {}
+  }, [haptic]);
+
   const startMRZScan = useCallback(async () => {
     if (scanInFlightRef.current) return;
     scanInFlightRef.current = true;

@@
-    analytics.trackEvent('camera_mrz_scan_started', {
+    safeTrackEvent('camera_mrz_scan_started', {
       documentType,
       countryCode,
     });

@@
-      haptic.trigger('success');
-      analytics.trackEvent('camera_mrz_scan_success');
+      safeSuccessHaptic();
+      safeTrackEvent('camera_mrz_scan_success');

@@
-      analytics.trackEvent('camera_mrz_scan_failed', { errorCode });
+      safeTrackEvent('camera_mrz_scan_failed', { errorCode });

@@
-  }, [camera, navigate, analytics, haptic, documentType, countryCode]);
+  }, [camera, navigate, safeTrackEvent, safeSuccessHaptic, documentType, countryCode]);

   const onCancel = useCallback(() => {
     scanGenerationRef.current += 1;
-    analytics.trackEvent('camera_screen_closed');
+    safeTrackEvent('camera_screen_closed');
     navigate('/');
-  }, [navigate, analytics]);
+  }, [navigate, safeTrackEvent]);

As per coding guidelines, "Always use try-catch for async operations with graceful degradation when native modules fail and comprehensive error boundaries."

Also applies to: 70-72, 92-92, 110-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx` around
lines 52 - 55, The telemetry and haptics calls (analytics.trackEvent and the
haptics/vibrate calls) are running in the critical scan/cancel control path and
can throw, preventing scanInFlightRef from being released; make these operations
best-effort by wrapping each call in their own try/catch or invoking them
without awaiting and attaching .catch to swallow/log errors, ensure
scanInFlightRef is set/cleared inside the existing try/finally around the
scanning flow (leave the finally to always release scanInFlightRef), and move
non-critical side effects out of the critical success/failure branches so
failures in analytics or haptics cannot flip scan success or block navigation
(refer to analytics.trackEvent, scanInFlightRef, and the haptics/vibrate calls
to locate spots to wrap or decouple).
♻️ Duplicate comments (1)
packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt (1)

135-153: ⚠️ Potential issue | 🟠 Major

Handle all synchronous launch failures to avoid stuck in-flight state.

pendingCallback is cleared only for two exception types. If launcher.launch(intent) throws any other synchronous exception, future launches can stay blocked by VERIFICATION_IN_PROGRESS.

🔧 Proposed fix
-        } catch (e: IllegalStateException) {
+        } catch (e: IllegalStateException) {
             pendingCallback = null
             callback.onFailure(
                 SelfSdkError(
                     code = "LAUNCH_FAILED",
                     message = "Could not launch verification activity: ${e.message}",
                 ),
             )
+        } catch (t: Throwable) {
+            pendingCallback = null
+            callback.onFailure(
+                SelfSdkError(
+                    code = "LAUNCH_FAILED",
+                    message = "Could not launch verification activity: ${t.message ?: "Unknown launch error"}",
+                ),
+            )
         }
For androidx.activity.result.ActivityResultLauncher.launch(Intent), which exception types can be thrown synchronously, and is catching only ActivityNotFoundException + IllegalStateException considered sufficient for robust state management?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`
around lines 135 - 153, The try/catch only handles ActivityNotFoundException and
IllegalStateException so other synchronous exceptions from
launcher.launch(intent) can leave pendingCallback set and block
VERIFICATION_IN_PROGRESS; update the block around launcher.launch(intent) to
catch a general exception (e.g., Exception or Throwable) as a final fallback,
ensure pendingCallback is set to null in that fallback, and call
callback.onFailure with a SelfSdkError (use a generic code like "LAUNCH_FAILED"
or "UNKNOWN_LAUNCH_ERROR" and include e.message) so any unexpected synchronous
launch failure always clears state and reports an error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 169-188: The current ensureLauncher implementation registers
activityLauncher via activity.activityResultRegistry.register(...) without a
LifecycleOwner, causing the launcher (and captured pendingCallback/Activity) to
leak if the Activity is destroyed; update ensureLauncher to perform
lifecycle-aware registration by either calling the register overload that takes
a LifecycleOwner (use the passed activity as the LifecycleOwner) or attach a
LifecycleObserver to launcherOwner/activity that calls
activityLauncher?.unregister() and clears launcherOwner/pendingCallback in
onDestroy; reference ensureLauncher, activityLauncher, launcherOwner,
pendingCallback, and the ActivityResultRegistry.register call when making the
change.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx`:
- Around line 87-90: The MRZ error classification only checks err.message, so
update the errorCode assignment (the errorCode variable in
DocumentCameraScreen.tsx) to first check err.code === MRZ_INVALID_DATA_ERROR
and/or err.code === 'MRZ_INVALID_DATA' before falling back to testing
err.message === MRZ_INVALID_DATA_ERROR; ensure the logic covers both
native-provided err.code and JS Error.message so native bridge error codes are
correctly mapped to 'MRZ_INVALID_DATA' otherwise default to 'MRZ_SCAN_FAILED'.

---

Outside diff comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 98-264: The Kotlin file implements business logic that must live
in TypeScript; update the native bridge functions (launchInternal,
ensureLauncher, handleActivityResult, serializeRequest, serializeConfig,
deserializeResult) to be thin: stop classifying results or parsing JSON on
Android, only pass raw payloads and error codes through the callback so TS can
handle logic. Concretely, have serializeRequest/serializeConfig remain as simple
passthrough JSON encoders if needed but remove deserializeResult usage in
handleActivityResult and instead forward the raw EXTRA_RESULT_DATA (JSON string)
and EXTRA_RESULT_TYPE or error extras directly to the pending SelfSdkCallback
(e.g., onSuccess should receive rawResultJson and/or type, onFailure should
receive the raw errorCode and errorMessage), remove any parsing/classification
(PARSE_ERROR, MISSING_RESULT, UNEXPECTED_RESULT mapping) and ensure
launchInternal only handles lifecycle/errors around launching the activity
without embedding business rules.

In `@packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx`:
- Around line 52-55: The telemetry and haptics calls (analytics.trackEvent and
the haptics/vibrate calls) are running in the critical scan/cancel control path
and can throw, preventing scanInFlightRef from being released; make these
operations best-effort by wrapping each call in their own try/catch or invoking
them without awaiting and attaching .catch to swallow/log errors, ensure
scanInFlightRef is set/cleared inside the existing try/finally around the
scanning flow (leave the finally to always release scanInFlightRef), and move
non-critical side effects out of the critical success/failure branches so
failures in analytics or haptics cannot flip scan success or block navigation
(refer to analytics.trackEvent, scanInFlightRef, and the haptics/vibrate calls
to locate spots to wrap or decouple).

---

Duplicate comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 135-153: The try/catch only handles ActivityNotFoundException and
IllegalStateException so other synchronous exceptions from
launcher.launch(intent) can leave pendingCallback set and block
VERIFICATION_IN_PROGRESS; update the block around launcher.launch(intent) to
catch a general exception (e.g., Exception or Throwable) as a final fallback,
ensure pendingCallback is set to null in that fallback, and call
callback.onFailure with a SelfSdkError (use a generic code like "LAUNCH_FAILED"
or "UNKNOWN_LAUNCH_ERROR" and include e.message) so any unexpected synchronous
launch failure always clears state and reports an error.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8375e95 and 8add238.

📒 Files selected for processing (2)
  • packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
  • packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`:
- Around line 158-176: bindActivity currently adds a new
DefaultLifecycleObserver on every call causing duplicate observers; to fix,
track the registered observer (e.g., add a private lifecycleObserver field) and
only call activity.lifecycle.addObserver when either lifecycleObserver is null
or is associated with a different activity, or unregister the previous observer
before adding a new one; ensure the observer you register clears
lifecycleObserver when onDestroy runs and still performs the existing cleanup
for activityLauncher, launcherOwner, boundActivity and pendingCallback so
duplicate onDestroy executions are prevented.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d5df74bc-c426-49b1-9618-9128735a2769

📥 Commits

Reviewing files that changed from the base of the PR and between 8add238 and 51a184b.

📒 Files selected for processing (4)
  • packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
  • packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx
  • specs/person1-webview/SPEC.md
  • specs/person2-native-shells/SPEC.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/webview-app/src/screens/onboarding/DocumentCameraScreen.tsx
  • specs/person2-native-shells/SPEC.md
  • specs/person1-webview/SPEC.md

Comment on lines +158 to +176
private fun bindActivity(activity: ComponentActivity) {
boundActivity = WeakReference(activity)
ensureLauncher(activity)
activity.lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
if (launcherOwner?.get() === activity) {
activityLauncher?.unregister()
activityLauncher = null
launcherOwner = null
}
if (boundActivity?.get() === activity) {
boundActivity = null
}
pendingCallback = null
}
},
)
}
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 | 🟠 Major

Duplicate lifecycle observers accumulate on repeated bindActivity calls.

Each call to bindActivity adds a new DefaultLifecycleObserver unconditionally. If launch(activity, ...) is called multiple times with the same Activity, observers pile up. This leads to multiple onDestroy executions—potentially causing redundant unregister() calls and premature pendingCallback = null assignments.

Consider guarding observer registration to only add once per Activity instance.

🔧 Proposed fix
 actual class SelfSdk private constructor(
     private val config: SelfSdkConfig,
 ) {
     private var activityLauncher: ActivityResultLauncher<Intent>? = null
     private var launcherOwner: WeakReference<ComponentActivity>? = null
     private var boundActivity: WeakReference<ComponentActivity>? = null
     private var pendingCallback: SelfSdkCallback? = null
+    private var observedActivity: WeakReference<ComponentActivity>? = null
...
     private fun bindActivity(activity: ComponentActivity) {
         boundActivity = WeakReference(activity)
         ensureLauncher(activity)
+        // Only add observer once per activity instance
+        if (observedActivity?.get() === activity) {
+            return
+        }
+        observedActivity = WeakReference(activity)
         activity.lifecycle.addObserver(
             object : DefaultLifecycleObserver {
                 override fun onDestroy(owner: LifecycleOwner) {
                     if (launcherOwner?.get() === activity) {
                         activityLauncher?.unregister()
                         activityLauncher = null
                         launcherOwner = null
                     }
                     if (boundActivity?.get() === activity) {
                         boundActivity = null
                     }
+                    if (observedActivity?.get() === activity) {
+                        observedActivity = null
+                    }
                     pendingCallback = null
                 }
             },
         )
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt`
around lines 158 - 176, bindActivity currently adds a new
DefaultLifecycleObserver on every call causing duplicate observers; to fix,
track the registered observer (e.g., add a private lifecycleObserver field) and
only call activity.lifecycle.addObserver when either lifecycleObserver is null
or is associated with a different activity, or unregister the previous observer
before adding a new one; ensure the observer you register clears
lifecycleObserver when onDestroy runs and still performs the existing cleanup
for activityLauncher, launcherOwner, boundActivity and pendingCallback so
duplicate onDestroy executions are prevented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant