diff --git a/.coderabbit.yaml b/.coderabbit.yaml index f00627118..bec0fe25e 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -16,7 +16,7 @@ reviews: enable_prompt_for_ai_agents: false review_status: true auto_review: - enabled: true + enabled: false drafts: false base_branches: ["main", "dev", "staging"] tools: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 8d503fe22..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,7 +0,0 @@ -## Summary - - - -## Test plan - - diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml index aee38e37e..a8a4682bf 100644 --- a/.github/workflows/mobile-e2e.yml +++ b/.github/workflows/mobile-e2e.yml @@ -11,7 +11,7 @@ env: GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version # Performance optimizations YARN_TASK_POOL_CONCURRENCY: 2 # Limit parallel native module builds to prevent OOM on self-hosted runners - GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx6144m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8' + GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8' CI: true # Disable Maestro analytics in CI MAESTRO_CLI_NO_ANALYTICS: true @@ -324,7 +324,31 @@ jobs: env: SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }} CI: true + + - name: Capture private module SHAs for APK cache key + run: | + set -euo pipefail + : > .apk-cache-inputs.txt + for dir in app/android/android-passport-nfc-reader app/android/react-native-passport-reader; do + if [ -d "$dir/.git" ]; then + sha=$(git -C "$dir" rev-parse HEAD) + echo "$(basename "$dir")=$sha" >> .apk-cache-inputs.txt + fi + done + cat .apk-cache-inputs.txt + + - name: Stage APK path for cache restore + run: rm -f app/android/app/build/outputs/apk/debug/app-debug.apk + + - name: Restore Android APK cache + id: apk-cache + uses: actions/cache/restore@v4 + with: + path: app/android/app/build/outputs/apk/debug/app-debug.apk + key: ${{ runner.os }}-apk-v1-${{ hashFiles('app/android/app/src/**', 'app/android/app/build.gradle', 'app/android/app/proguard-rules.pro', 'app/android/build.gradle', 'app/android/settings.gradle', 'app/android/gradle.properties', 'app/android/gradle/wrapper/**', 'app/android/gradlew', 'app/src/**', 'app/App.tsx', 'app/index.js', 'app/app.json', 'app/package.json', 'app/babel.config.cjs', 'app/metro.config.cjs', 'app/react-native.config.cjs', 'app/patches/**', 'patches/**', 'packages/mobile-sdk-alpha/src/**', 'packages/mobile-sdk-alpha/package.json', 'yarn.lock', '.nvmrc', '.apk-cache-inputs.txt') }} + - name: Build Android APK + if: steps.apk-cache.outputs.cache-hit != 'true' run: | echo "Building Android APK..." chmod +x app/android/gradlew @@ -343,15 +367,44 @@ jobs: MONITOR_PID=$! trap 'kill "${MONITOR_PID}" 2>/dev/null || true' EXIT - ( - cd app/android && - E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --build-cache --no-configuration-cache - ) || { echo "❌ Android build failed"; exit 1; } + build_attempt() { + ( + cd app/android && + E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --build-cache --no-configuration-cache + ) + } + + BUILD_OK=false + for attempt in 1 2; do + echo "Gradle build attempt ${attempt}/2..." + if build_attempt; then + BUILD_OK=true + break + fi + echo "⚠️ Gradle build failed on attempt ${attempt}." + if [ "${attempt}" -lt 2 ]; then + echo "Stopping Gradle daemon and retrying in 15s..." + (cd app/android && ./gradlew --stop) || true + sleep 15 + fi + done kill "${MONITOR_PID}" 2>/dev/null || true + if [ "${BUILD_OK}" != "true" ]; then + echo "❌ Android build failed after retries" + exit 1 + fi echo "✅ Android build succeeded" + - name: Save Android APK cache + if: steps.apk-cache.outputs.cache-hit != 'true' && success() + uses: actions/cache/save@v4 + with: + path: app/android/app/build/outputs/apk/debug/app-debug.apk + key: ${{ steps.apk-cache.outputs.cache-primary-key }} + - name: Clean up Gradle build artifacts + if: steps.apk-cache.outputs.cache-hit != 'true' uses: ./.github/actions/cleanup-gradle-artifacts - name: Verify APK and android-passport-nfc-reader integration env: diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 924a684c8..026c84d7a 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -132,8 +132,8 @@ android { applicationId "com.proofofpassportapp" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 149 - versionName "2.9.18" + versionCode 150 + versionName "2.9.19" manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp'] externalNativeBuild { cmake { diff --git a/app/app.json b/app/app.json index cc9938042..b656e89df 100644 --- a/app/app.json +++ b/app/app.json @@ -4,7 +4,7 @@ "expo": { "name": "Self App", "slug": "self-app", - "version": "2.9.18", + "version": "2.9.19", "platforms": ["ios", "android"], "ios": { "bundleIdentifier": "com.proofofpassportapp" diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist index 7cdad8669..e3b1b9740 100644 --- a/app/ios/OpenPassport/Info.plist +++ b/app/ios/OpenPassport/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.18 + 2.9.19 CFBundleSignature ???? CFBundleURLTypes diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj index 22ec9580d..96021982f 100644 --- a/app/ios/Self.xcodeproj/project.pbxproj +++ b/app/ios/Self.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 218; + CURRENT_PROJECT_VERSION = 219; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_APP_SANDBOX = NO; ENABLE_BITCODE = NO; @@ -593,7 +593,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.18; + MARKETING_VERSION = 2.9.19; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -620,7 +620,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements; - CURRENT_PROJECT_VERSION = 218; + CURRENT_PROJECT_VERSION = 219; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_APP_SANDBOX = NO; ENABLE_RESOURCE_ACCESS_CAMERA = YES; @@ -735,7 +735,7 @@ "$(PROJECT_DIR)", "$(PROJECT_DIR)/MoproKit/Libs", ); - MARKETING_VERSION = 2.9.18; + MARKETING_VERSION = 2.9.19; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/package.json b/app/package.json index 6391d2e3e..640037fb1 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@selfxyz/mobile-app", - "version": "2.9.18", + "version": "2.9.19", "private": true, "type": "module", "scripts": { diff --git a/app/src/components/support/SupportUuidRow.tsx b/app/src/components/support/SupportUuidRow.tsx index 79f3dbcd2..2b720d4e0 100644 --- a/app/src/components/support/SupportUuidRow.tsx +++ b/app/src/components/support/SupportUuidRow.tsx @@ -26,7 +26,7 @@ const SupportUuidRow: React.FC = ({ title = 'Diagnostic ID', }) => { const [expanded, setExpanded] = useState(!collapsedByDefault); - const { supportUuid, copy } = useSupportUuid(); + const { isEnabled, supportUuid, copy } = useSupportUuid(); const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...'; const handleCopy = useCallback(() => { @@ -36,6 +36,10 @@ const SupportUuidRow: React.FC = ({ const toggle = useCallback(() => setExpanded(prev => !prev), []); + if (!isEnabled) { + return null; + } + if (!expanded) { return ( ); } diff --git a/app/src/config/sentry.ts b/app/src/config/sentry.ts index 799146919..f2a1b0de5 100644 --- a/app/src/config/sentry.ts +++ b/app/src/config/sentry.ts @@ -300,12 +300,16 @@ export const logProofEvent = ( extra?: Record, ) => logEvent(level, 'proof', message, context, extra); -export const setSupportUuidInSentry = (supportUuid: string | null) => { +export const setSupportUuidInSentry = ( + supportUuid: string | null, + enabled = true, +) => { if (isSentryDisabled) { return; } - setTag('support_uuid', supportUuid ?? 'unset'); + setTag('support_uuid_enabled', enabled ? 'true' : 'false'); + setTag('support_uuid', enabled ? (supportUuid ?? 'unset') : 'disabled'); }; export const wrapWithSentry = (App: React.ComponentType) => { diff --git a/app/src/config/sentry.web.ts b/app/src/config/sentry.web.ts index 5156a0ef7..3f0e738dc 100644 --- a/app/src/config/sentry.web.ts +++ b/app/src/config/sentry.web.ts @@ -10,6 +10,7 @@ import { captureMessage as sentryCaptureMessage, feedbackIntegration, init as sentryInit, + setTag, withProfiler, withScope, } from '@sentry/react'; @@ -267,7 +268,16 @@ export const logProofEvent = ( extra?: Record, ) => logEvent(level, 'proof', message, context, extra); -export const setSupportUuidInSentry = (_supportUuid: string | null) => {}; +export const setSupportUuidInSentry = ( + supportUuid: string | null, + enabled = true, +) => { + if (isSentryDisabled) { + return; + } + setTag('support_uuid_enabled', enabled ? 'true' : 'false'); + setTag('support_uuid', enabled ? (supportUuid ?? 'unset') : 'disabled'); +}; export const wrapWithSentry = (App: React.ComponentType) => { return isSentryDisabled ? App : withProfiler(App); diff --git a/app/src/hooks/useSupportUuid.ts b/app/src/hooks/useSupportUuid.ts index 9112817e3..fb11aedf9 100644 --- a/app/src/hooks/useSupportUuid.ts +++ b/app/src/hooks/useSupportUuid.ts @@ -8,27 +8,33 @@ import { copySupportUuid, getSupportUuid, regenerateSupportUuid, + setSupportUuidCollectionEnabled, } from '@/services/supportUuid'; import { useSettingStore } from '@/stores/settingStore'; export interface UseSupportUuidResult { + isEnabled: boolean; supportUuid: string | null; isReady: boolean; - copy: () => string; - regenerate: () => string; + copy: () => string | null; + regenerate: () => string | null; + setEnabled: (enabled: boolean) => string | null; } export function useSupportUuid(): UseSupportUuidResult { + const supportUuidEnabled = useSettingStore(state => state.supportUuidEnabled); const supportUuid = useSettingStore(state => state.supportUuid); useEffect(() => { - if (!supportUuid) getSupportUuid(); - }, [supportUuid]); + if (supportUuidEnabled && !supportUuid) getSupportUuid(); + }, [supportUuidEnabled, supportUuid]); return { + isEnabled: supportUuidEnabled, supportUuid, - isReady: supportUuid != null, + isReady: !supportUuidEnabled || supportUuid != null, copy: copySupportUuid, regenerate: regenerateSupportUuid, + setEnabled: setSupportUuidCollectionEnabled, }; } diff --git a/app/src/navigation/devTools.tsx b/app/src/navigation/devTools.tsx index dafe7b612..a75eaaac5 100644 --- a/app/src/navigation/devTools.tsx +++ b/app/src/navigation/devTools.tsx @@ -14,6 +14,7 @@ import DevLoadingScreen from '@/screens/dev/DevLoadingScreen'; import DevPrivateKeyScreen from '@/screens/dev/DevPrivateKeyScreen'; import DevSettingsScreen from '@/screens/dev/DevSettingsScreen'; import SocialLoginDemoScreen from '@/screens/dev/SocialLoginDemoScreen'; +import TroubleshootingScreen from '@/screens/dev/TroubleshootingScreen'; const devHeaderOptions: NativeStackNavigationOptions = { headerStyle: { @@ -89,6 +90,13 @@ const devScreens = { title: 'Social Login Demo', } as NativeStackNavigationOptions, }, + Troubleshooting: { + screen: TroubleshootingScreen, + options: { + ...devHeaderOptions, + title: 'Troubleshooting', + } as NativeStackNavigationOptions, + }, }; export default devScreens; diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx index 3c50f3cee..904ed37aa 100644 --- a/app/src/screens/account/settings/SettingsScreen.tsx +++ b/app/src/screens/account/settings/SettingsScreen.tsx @@ -3,7 +3,7 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type { PropsWithChildren } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Linking, Platform, Share, View as RNView } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -11,7 +11,7 @@ import type { SvgProps } from 'react-native-svg'; import { Button, ScrollView, View, XStack, YStack } from 'tamagui'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons'; +import { Bug, FileText, Settings2, Wrench } from '@tamagui/lucide-icons'; import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components'; import { @@ -80,6 +80,7 @@ const ROUTE_ICONS: Record> = { Support: Feedback, share: ShareIcon, DevSettings: Bug as React.FC, + Troubleshooting: Wrench as React.FC, }; const social = [ @@ -133,6 +134,7 @@ const SocialButton: React.FC = ({ const SettingsScreen: React.FC = () => { const { isDevMode, setDevModeOn } = useSettingStore(); + const [isTroubleshootingMode, setTroubleshootingMode] = useState(false); const navigation = useNavigation>(); const { hasRealDocument } = useHasRealDocument('SettingsScreen'); @@ -147,16 +149,25 @@ const SettingsScreen: React.FC = () => { platform: CURRENT_PLATFORM, hasRealDocument: hasRealDocument === true, isDevMode, + isTroubleshootingMode, }), - [hasRealDocument, isDevMode], + [hasRealDocument, isDevMode, isTroubleshootingMode], ); + const troubleshootingTap = Gesture.Tap() + .numberOfTaps(3) + .onStart(() => { + setTroubleshootingMode(true); + }); + const devModeTap = Gesture.Tap() .numberOfTaps(5) .onStart(() => { setDevModeOn(); }); + const combinedTap = Gesture.Exclusive(devModeTap, troubleshootingTap); + const onMenuPress = useCallback( (menuRoute: SettingsRouteKey) => { return async () => { @@ -180,7 +191,7 @@ const SettingsScreen: React.FC = () => { ); const { bottom } = useSafeAreaInsets(); return ( - + { - const { supportUuid, copy, regenerate } = useSupportUuid(); + const { isEnabled, supportUuid, copy, regenerate, setEnabled } = + useSupportUuid(); const openSupportForm = useOpenSupportForm(); const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...'; @@ -31,7 +34,7 @@ const SupportScreen: React.FC = () => { const handleRegenerate = useCallback(() => { Alert.alert( 'Regenerate diagnostic ID?', - 'This will immediately replace the current ID for future support diagnostics.', + "Use this if you've shared your ID publicly or want a fresh one for a new issue. Future support requests will use the new ID.", [ { text: 'Cancel', style: 'cancel' }, { @@ -39,13 +42,20 @@ const SupportScreen: React.FC = () => { style: 'destructive', onPress: () => { regenerate(); - Alert.alert('Updated', 'Diagnostic ID regenerated successfully.'); + Alert.alert('Updated', 'Your diagnostic ID has been replaced.'); }, }, ], ); }, [regenerate]); + const handleSupportUuidToggle = useCallback( + (enabled: boolean) => { + setEnabled(enabled); + }, + [setEnabled], + ); + return ( @@ -59,49 +69,97 @@ const SupportScreen: React.FC = () => { - Share the diagnostic ID below when contacting support so we can - locate your logs. + A diagnostic ID helps our team find the activity related to your + report. It's not tied to your identity. - - - Diagnostic ID - - - {diagnosticIdText} - - + + + + Share diagnostic ID + + + {isEnabled + ? 'Share the diagnostic ID below with support so we can find the activity related to your report. Turn this off to keep it out of support requests and error screens.' + : 'Diagnostic ID is off. Turn it on to include it in future support requests.'} + + + + - + {isEnabled ? ( + <> + + + Diagnostic ID + + + {diagnosticIdText} + + - + + + + + ) : null} ); }; +const styles = StyleSheet.create({ + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + settingTextContainer: { + flex: 1, + gap: 4, + }, + settingLabel: { + fontSize: 16, + fontFamily: dinot, + fontWeight: '500', + color: black, + }, + settingDescription: { + fontSize: 14, + fontFamily: dinot, + color: slate500, + }, +}); + export default SupportScreen; diff --git a/app/src/screens/account/settings/settingsMenu.ts b/app/src/screens/account/settings/settingsMenu.ts index e27bc4af4..d71586c74 100644 --- a/app/src/screens/account/settings/settingsMenu.ts +++ b/app/src/screens/account/settings/settingsMenu.ts @@ -11,6 +11,7 @@ export type SettingsGatingContext = { platform: SettingsPlatform; hasRealDocument: boolean; isDevMode: boolean; + isTroubleshootingMode: boolean; }; export type SettingsPlatform = 'ios' | 'android' | 'web'; @@ -21,7 +22,8 @@ export type SettingsRouteKey = | 'ProofSettings' | 'Support' | 'share' - | 'DevSettings'; + | 'DevSettings' + | 'Troubleshooting'; export const DEBUG_SETTINGS_ENTRY: SettingsEntry = { label: 'Debug menu', @@ -42,6 +44,11 @@ export const SETTINGS_ENTRIES_WEB: readonly SettingsEntry[] = [ { label: 'Get support', route: 'Support' }, ]; +export const TROUBLESHOOTING_ENTRY: SettingsEntry = { + label: 'Troubleshooting', + route: 'Troubleshooting', +}; + export const baseEntriesForPlatform = ( platform: SettingsPlatform, ): readonly SettingsEntry[] => @@ -55,10 +62,14 @@ export const baseEntriesForPlatform = ( export const buildSettingsMenu = ( context: SettingsGatingContext, ): SettingsEntry[] => { - const { platform, isDevMode } = context; + const { platform, isDevMode, isTroubleshootingMode } = context; const base = baseEntriesForPlatform(platform); - const withDebug = isDevMode ? [...base, DEBUG_SETTINGS_ENTRY] : base; - return withDebug.filter(entry => shouldShowSettingsEntry(entry, context)); + const entries = [ + ...base, + ...(isTroubleshootingMode || isDevMode ? [TROUBLESHOOTING_ENTRY] : []), + ...(isDevMode ? [DEBUG_SETTINGS_ENTRY] : []), + ]; + return entries.filter(entry => shouldShowSettingsEntry(entry, context)); }; export const shouldShowSettingsEntry = ( diff --git a/app/src/screens/dev/TroubleshootingScreen.tsx b/app/src/screens/dev/TroubleshootingScreen.tsx new file mode 100644 index 000000000..93baf288e --- /dev/null +++ b/app/src/screens/dev/TroubleshootingScreen.tsx @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import { poseidon2 } from 'poseidon-lite'; +import React, { useState } from 'react'; +import { Button, H4, Paragraph, Spinner, Text, YStack } from 'tamagui'; + +import { hashEndpointWithScope } from '@selfxyz/common/utils/scope'; +import { + black, + red500, + slate200, + slate500, + teal500, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; + +import { unsafe_getPrivateKey } from '@/providers/authProvider'; +import { POINTS_API_BASE_URL } from '@/services/points/constants'; +import { getPointsAddress } from '@/services/points/utils'; + +const POINTS_ENDPOINT = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0'; +const POINTS_SCOPE = 'minimal-disclosure-quest'; + +const TroubleshootingScreen: React.FC = () => { + const [status, setStatus] = useState< + 'idle' | 'loading' | 'success' | 'error' + >('idle'); + const [message, setMessage] = useState(''); + + const handleFix = async () => { + setStatus('loading'); + setMessage(''); + + try { + const secret = await unsafe_getPrivateKey(); + if (!secret) { + setStatus('error'); + setMessage( + 'Could not retrieve secret. Biometric auth may have failed.', + ); + return; + } + + const scopeHash = hashEndpointWithScope(POINTS_ENDPOINT, POINTS_SCOPE); + const nullifier = poseidon2([ + BigInt(secret), + BigInt(scopeHash), + ]).toString(); + const userAddress = await getPointsAddress(); + + const response = await fetch( + `${POINTS_API_BASE_URL}/points-disclose-fix`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + nullifier, + points_address: userAddress.toLowerCase(), + }), + }, + ); + + if (!response.ok) { + const data = await response.json().catch(() => null); + setStatus('error'); + setMessage(data?.message ?? `Request failed (${response.status})`); + return; + } + + setStatus('success'); + setMessage('Disclosure status fixed successfully.'); + } catch (error) { + setStatus('error'); + setMessage( + error instanceof Error + ? error.message + : 'An unexpected error occurred.', + ); + } + }; + + return ( + + +

Fix points disclosure

+ + If your points haven't updated after a successful verification, tap + below to repair your disclosure state. This is safe to run more than + once. + +
+ + + + {message !== '' && ( + + {message} + + )} +
+ ); +}; + +export default TroubleshootingScreen; diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx index f8d5e327b..bac4fca96 100644 --- a/app/src/screens/verification/ProofRequestStatusScreen.tsx +++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx @@ -4,7 +4,7 @@ import type { LottieViewProps } from 'lottie-react-native'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Linking, StyleSheet, View } from 'react-native'; +import { Linking, Platform, StyleSheet, Text, View } from 'react-native'; import { ScrollView, Spinner } from 'tamagui'; import { useIsFocused, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -19,10 +19,16 @@ import { typography, } from '@selfxyz/mobile-sdk-alpha/components'; import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics'; -import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors'; +import { + black, + slate200, + slate500, + white, +} from '@selfxyz/mobile-sdk-alpha/constants/colors'; import failAnimation from '@/assets/animations/proof_failed.json'; import succesAnimation from '@/assets/animations/proof_success.json'; +import { captureException } from '@/config/sentry'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import { buttonTap, @@ -40,6 +46,22 @@ import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; const PREREQ_CHECK_TIMEOUT_MS = 3000; +// Give each active proving state a generous 90s window before exposing an +// escape hatch. This avoids trapping users on silent stalls without cutting off +// normal slow-device proving too aggressively. +const PROVING_STALL_TIMEOUT_MS = 90_000; +const PROOF_TIMEOUT_ERROR_CODE = 'proof_timeout'; +const PROOF_TIMEOUT_REASON = 'timed_out_after_90s'; +const STALL_TIMEOUT_STATES = new Set([ + 'parsing_id_document', + 'fetching_data', + 'validating_document', + 'init_tee_connexion', + 'ready_to_prove', + 'listening_for_status', + 'proving', + 'post_proving', +]); const SuccessScreen: React.FC = () => { const selfClient = useSelfClient(); @@ -64,17 +86,35 @@ const SuccessScreen: React.FC = () => { useState(loadingAnimation); const [countdown, setCountdown] = useState(null); const [countdownStarted, setCountdownStarted] = useState(false); + const [hasTimedOut, setHasTimedOut] = useState(false); + const [isDismissingTimedOutProof, setIsDismissingTimedOutProof] = + useState(false); const [whitelistedPoints, setWhitelistedPoints] = useState< number | null | undefined >(undefined); const timerRef = useRef(null); + const provingTimeoutRef = useRef(null); + const timedOutSessionIdRef = useRef(null); + const dismissingTimedOutProofRef = useRef(false); + const timedOutAnalyticsTrackedRef = useRef(false); + + const displayState = hasTimedOut ? 'failure' : currentState; + const displayReason = hasTimedOut + ? PROOF_TIMEOUT_REASON + : (reason ?? undefined); const onOkPress = useCallback(async () => { - if (whitelistedPoints === undefined) return; + // The whitelistedPoints guard only applies to the success path — on + // failure/error it is never populated, and blocking would trap the user. + if (currentState === 'completed' && whitelistedPoints === undefined) return; buttonTap(); const completedSessionId = sessionId; const cleanupLater = () => { + // If completedSessionId is null (early failure before TEE negotiation), + // skip the delayed cleanup: a retry started in the next 2s would also + // have a null uuid and get wiped by cleanSelfApp. + if (completedSessionId === null) return; setTimeout(() => { if (useProvingStore.getState().uuid === completedSessionId) { selfClient.getSelfAppState().cleanSelfApp(); @@ -82,7 +122,7 @@ const SuccessScreen: React.FC = () => { }, 2000); }; - if (whitelistedPoints !== null) { + if (currentState === 'completed' && whitelistedPoints !== null) { // Bound the prereq checks so a stalled network call can't trap the user // on this screen. On timeout we fall through to goHome() — the safe // default, since Gratification would just bounce them via the guardrail. @@ -106,6 +146,7 @@ const SuccessScreen: React.FC = () => { goHome(); cleanupLater(); }, [ + currentState, whitelistedPoints, navigation, goHome, @@ -114,6 +155,41 @@ const SuccessScreen: React.FC = () => { useProvingStore, ]); + const clearProvingTimeout = useCallback(() => { + if (provingTimeoutRef.current) { + clearTimeout(provingTimeoutRef.current); + provingTimeoutRef.current = null; + } + }, []); + + const onTimedOutDismiss = useCallback(async () => { + if (dismissingTimedOutProofRef.current) { + return; + } + + dismissingTimedOutProofRef.current = true; + setIsDismissingTimedOutProof(true); + buttonTap(); + + try { + await useProvingStore.getState().cancel(selfClient); + } catch (error) { + captureException( + error instanceof Error ? error : new Error(String(error)), + { + module: 'proof-request-status-screen', + action: 'dismiss_timed_out_proof', + sessionId: timedOutSessionIdRef.current, + }, + ); + } finally { + dismissingTimedOutProofRef.current = false; + setIsDismissingTimedOutProof(false); + selfClient.getSelfAppState().cleanSelfApp(); + goHome(); + } + }, [goHome, selfClient, useProvingStore]); + function cancelDeeplinkCallbackRedirect() { setCountdown(null); } @@ -126,6 +202,13 @@ const SuccessScreen: React.FC = () => { setCountdown(null); } + useEffect(() => { + setHasTimedOut(false); + timedOutAnalyticsTrackedRef.current = false; + setCountdownStarted(false); + setCountdown(null); + }, [sessionId]); + useEffect(() => { if (currentState !== 'completed') return; @@ -154,10 +237,38 @@ const SuccessScreen: React.FC = () => { }, [currentState, selfApp?.endpoint]); useEffect(() => { - if (currentState === 'completed') { + if (hasTimedOut) { + setAnimationSource(failAnimation); + + if (!timedOutAnalyticsTrackedRef.current) { + timedOutAnalyticsTrackedRef.current = true; + notificationError(); + // sessionId (uuid) is only assigned once TEE negotiation starts, so + // pre-TEE stall states (parsing_id_document, fetching_data, etc.) + // have no uuid to attach to proof history. + if (sessionId) { + updateProofStatus( + sessionId, + ProofStatus.FAILURE, + PROOF_TIMEOUT_ERROR_CODE, + PROOF_TIMEOUT_REASON, + ); + } + trackEvent(ProofEvents.PROOF_FAILED, { + sessionId, + appName, + errorCode: PROOF_TIMEOUT_ERROR_CODE, + reason: PROOF_TIMEOUT_REASON, + state: 'timeout', + }); + } + } else if (currentState === 'completed') { + timedOutAnalyticsTrackedRef.current = false; notificationSuccess(); setAnimationSource(succesAnimation); - updateProofStatus(sessionId!, ProofStatus.SUCCESS); + if (sessionId) { + updateProofStatus(sessionId, ProofStatus.SUCCESS); + } trackEvent(ProofEvents.PROOF_COMPLETED, { sessionId, appName, @@ -177,14 +288,17 @@ const SuccessScreen: React.FC = () => { } } } else if (currentState === 'failure' || currentState === 'error') { + timedOutAnalyticsTrackedRef.current = false; notificationError(); setAnimationSource(failAnimation); - updateProofStatus( - sessionId!, - ProofStatus.FAILURE, - errorCode ?? undefined, - reason ?? undefined, - ); + if (sessionId) { + updateProofStatus( + sessionId, + ProofStatus.FAILURE, + errorCode ?? undefined, + reason ?? undefined, + ); + } trackEvent(ProofEvents.PROOF_FAILED, { sessionId, appName, @@ -193,9 +307,11 @@ const SuccessScreen: React.FC = () => { state: currentState, }); } else { + timedOutAnalyticsTrackedRef.current = false; setAnimationSource(loadingAnimation); } }, [ + hasTimedOut, trackEvent, currentState, isFocused, @@ -208,6 +324,28 @@ const SuccessScreen: React.FC = () => { countdownStarted, ]); + useEffect(() => { + if (hasTimedOut) { + clearProvingTimeout(); + return; + } + + if (!isFocused || !STALL_TIMEOUT_STATES.has(currentState)) { + clearProvingTimeout(); + return; + } + + clearProvingTimeout(); + provingTimeoutRef.current = setTimeout(() => { + timedOutSessionIdRef.current = sessionId; + setHasTimedOut(true); + }, PROVING_STALL_TIMEOUT_MS); + + return () => { + clearProvingTimeout(); + }; + }, [clearProvingTimeout, currentState, hasTimedOut, isFocused, sessionId]); + useEffect(() => { if (countdown === null) return; if (countdown > 0) { @@ -236,8 +374,9 @@ const SuccessScreen: React.FC = () => { } return () => { cancelCountdown(); + clearProvingTimeout(); }; - }, [isFocused]); + }, [clearProvingTimeout, isFocused]); return ( @@ -262,11 +401,14 @@ const SuccessScreen: React.FC = () => { backgroundColor={white} > - {getTitle(currentState)} + {getTitle(displayState)} { 0)) } onPress={ - countdown !== null && countdown > 0 - ? cancelDeeplinkCallbackRedirect - : onOkPress + hasTimedOut + ? onTimedOutDismiss + : countdown !== null && countdown > 0 + ? cancelDeeplinkCallbackRedirect + : onOkPress } > - {currentState !== 'completed' && - currentState !== 'error' && - currentState !== 'failure' ? ( + {isDismissingTimedOutProof ? ( + + ) : displayState === 'failure' || displayState === 'error' ? ( + 'Dismiss' + ) : displayState !== 'completed' ? ( ) : countdown !== null && countdown > 0 ? ( 'Cancel' - ) : whitelistedPoints === undefined ? ( + ) : currentState === 'completed' && + whitelistedPoints === undefined ? ( ) : ( 'OK' @@ -319,16 +467,45 @@ function getTitle(currentState: string) { } } +// Maps low-level proving errors to actionable, user-facing guidance. +// Raw `reason` is still shown below in a scrollable details box for support. +function getUserFacingErrorMessage( + currentState: string, + reason: string | undefined, + errorCode: string | undefined, + appName: string, +): string { + if ( + reason === PROOF_TIMEOUT_REASON || + errorCode === PROOF_TIMEOUT_ERROR_CODE + ) { + return `The proof request from ${appName} took too long to finish. Please try again, or refresh the QR code in ${appName} and scan again.`; + } + if (currentState === 'error') { + return `Unable to prove your identity to ${appName} due to a technical issue. Please try again.`; + } + const invalidRootPattern = /InvalidRoot/i; + if ( + (reason && invalidRootPattern.test(reason)) || + (errorCode && invalidRootPattern.test(errorCode)) + ) { + return `The QR code from ${appName} is out of date. Please refresh it in ${appName} and scan again.`; + } + return `Unable to prove your identity to ${appName}. Please try again, or contact support if the issue persists.`; +} + function Info({ currentState, appName, reason, + errorCode, countdown, deeplinkCallback, }: { currentState: string; appName: string; reason?: string; + errorCode?: string; countdown?: number | null; deeplinkCallback?: string; }) { @@ -360,30 +537,30 @@ function Info({ ); } else if (currentState === 'error' || currentState === 'failure') { + const userMessage = getUserFacingErrorMessage( + currentState, + reason, + errorCode, + appName, + ); return ( - - - Unable to prove your identity to{' '} - {appName} - {currentState === 'error' && '. Due to technical issues.'} - + + {userMessage} {currentState === 'failure' && reason && ( - <> - - - Reason: - - - - - - - {reason} - - - - - + + + Details + + + + {reason} + + + )} ); @@ -415,4 +592,25 @@ export const styles = StyleSheet.create({ marginBottom: 20, gap: 10, }, + reasonBox: { + alignSelf: 'stretch', + borderWidth: 1, + borderColor: slate200, + borderRadius: 8, + padding: 12, + gap: 6, + }, + reasonLabel: { + fontSize: 12, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + reasonScroll: { + maxHeight: 120, + }, + reasonText: { + color: slate500, + fontSize: 12, + fontFamily: Platform.select({ ios: 'Courier', android: 'monospace' }), + }, }); diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index 799c4f19f..097934719 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -39,6 +39,7 @@ import { truncateAddress, WalletAddressModal, } from '@/components/proof-request'; +import { captureMessage } from '@/config/sentry'; import { useSelfAppData } from '@/hooks/useSelfAppData'; import { buttonTap } from '@/integrations/haptics'; import type { RootStackParamList } from '@/navigation'; @@ -49,6 +50,7 @@ import { import { getPointsAddress, getWhiteListedDisclosureAddresses, + NULLIFIER_ALREADY_USED_ERROR_PREFIX, } from '@/services/points'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; @@ -102,6 +104,7 @@ const ProveScreen: React.FC = () => { ); const provingStore = useProvingStore(); const currentState = useProvingStore(state => state.currentState); + const reason = useProvingStore(state => state.reason); const isReadyToProve = currentState === 'ready_to_prove'; // Use window dimensions for dynamic scroll offset padding @@ -299,6 +302,40 @@ const ProveScreen: React.FC = () => { !isExpired && selectedAppRef.current?.sessionId !== selectedApp.sessionId ) { + // Set selfDefinedData before init so the proving machine has the address + if ( + !selectedApp.selfDefinedData && + !processedSessionsRef.current.has(selectedApp.sessionId) + ) { + try { + const [address, whitelistedAddresses] = await Promise.all([ + getPointsAddress(), + getWhiteListedDisclosureAddresses(), + ]); + + const isWhitelisted = whitelistedAddresses.some( + contract => + contract.contract_address.toLowerCase() === + selectedApp.endpoint?.toLowerCase(), + ); + + if (isWhitelisted) { + console.log( + 'enhancing app with whitelisted points address', + address, + ); + selfClient.getSelfAppState().setSelfApp({ + ...selectedApp, + selfDefinedData: address.toLowerCase(), + }); + } + + processedSessionsRef.current.add(selectedApp.sessionId); + } catch (error) { + console.error('Failed enhancing app with points address:', error); + } + } + provingStore.init(selfClient, 'disclose'); } selectedAppRef.current = selectedApp; @@ -315,57 +352,53 @@ const ProveScreen: React.FC = () => { hasCheckedForInactiveDocument, ]); - // Enhance selfApp with user's points address if not already set + // Track "already disclosed" failures for points disclosures so we can fix + // the backend state for affected users. useEffect(() => { - console.log('useEffect selectedApp', selectedApp); if ( - !selectedApp || - selectedApp.selfDefinedData || - !hasCheckedForInactiveDocument + currentState !== 'failure' || + !( + reason?.includes(NULLIFIER_ALREADY_USED_ERROR_PREFIX) || + reason?.includes('NullifierAlreadyUsed') + ) ) { return; } - const sessionId = selectedApp.sessionId; - - if (processedSessionsRef.current.has(sessionId)) { - return; - } - - const enhanceApp = async () => { - const currentSessionId = sessionId; - + const trackAlreadyDisclosed = async () => { try { - const address = await getPointsAddress(); const whitelistedAddresses = await getWhiteListedDisclosureAddresses(); - - const isWhitelisted = whitelistedAddresses.some( + const isPointsDisclosure = whitelistedAddresses.some( contract => - contract.contract_address.toLowerCase() === address.toLowerCase(), + contract.contract_address.toLowerCase() === + selectedApp?.endpoint?.toLowerCase(), ); - const currentApp = selfClient.getSelfAppState().selfApp; - if (currentApp?.sessionId === currentSessionId) { - if (isWhitelisted) { - console.log( - 'enhancing app with whitelisted points address', - address, - ); - selfClient.getSelfAppState().setSelfApp({ - ...currentApp, - selfDefinedData: address.toLowerCase(), - }); - } + if (!isPointsDisclosure) { + return; } - processedSessionsRef.current.add(currentSessionId); + const pointsAddress = + selectedApp?.selfDefinedData || (await getPointsAddress()); + + trackEvent(ProofEvents.POINTS_NULLIFIER_ALREADY_USED, { + pointsAddress, + endpoint: selectedApp?.endpoint, + sessionId: provingStore.uuid, + }); + + captureMessage('Points disclosure already registered on-chain', { + pointsAddress, + endpoint: selectedApp?.endpoint, + sessionId: provingStore.uuid, + }); } catch (error) { - console.error('Failed enhancing app:', error); + console.error('Failed tracking NullifierAlreadyUsed event:', error); } }; - enhanceApp(); - }, [selectedApp, selfClient, hasCheckedForInactiveDocument]); + trackAlreadyDisclosed(); + }, [currentState, reason, selectedApp, provingStore.uuid, trackEvent]); function onVerify() { buttonTap(); diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 4b867175b..063096b19 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -231,22 +231,14 @@ const identifyInSegment = (nextSupportUuid: string) => { }); }; -export const resetAnalyticsIdentityForSupportUuid = ( - nextSupportUuid: string, -) => { - supportUuid = nextSupportUuid; - - if (segmentClient) { - segmentClient - .reset() - .catch(err => { - if (__DEV__) console.warn('Failed to reset Segment identity:', err); - }) - .then(() => identifyInSegment(nextSupportUuid)); - } else { - identifyInSegment(nextSupportUuid); - } +const resetSegmentIdentity = () => { + if (!segmentClient) return Promise.resolve(); + return segmentClient.reset().catch(err => { + if (__DEV__) console.warn('Failed to reset Segment identity:', err); + }); +}; +const resetMixpanelIdentity = () => { if (PassportReader && typeof PassportReader.resetIdentity === 'function') { try { PassportReader.resetIdentity(); @@ -256,7 +248,9 @@ export const resetAnalyticsIdentityForSupportUuid = ( } } } +}; +const setMixpanelDistinctId = (nextSupportUuid: string) => { if (PassportReader && typeof PassportReader.setDistinctId === 'function') { try { PassportReader.setDistinctId(nextSupportUuid); @@ -268,20 +262,37 @@ export const resetAnalyticsIdentityForSupportUuid = ( } }; -export const setAnalyticsSupportUuid = (nextSupportUuid: string) => { +export const resetAnalyticsIdentityForSupportUuid = ( + nextSupportUuid: string, +) => { supportUuid = nextSupportUuid; - identifyInSegment(nextSupportUuid); + if (segmentClient) { + resetSegmentIdentity().then(() => identifyInSegment(nextSupportUuid)); + } else { + identifyInSegment(nextSupportUuid); + } - if (PassportReader && typeof PassportReader.setDistinctId === 'function') { - try { - PassportReader.setDistinctId(nextSupportUuid); - } catch (error) { - if (__DEV__) { - console.warn('Failed to set Mixpanel distinct_id:', error); + resetMixpanelIdentity(); + setMixpanelDistinctId(nextSupportUuid); +}; + +export const setAnalyticsSupportUuid = (nextSupportUuid: string | null) => { + supportUuid = nextSupportUuid; + + if (!nextSupportUuid) { + for (const evt of eventQueue) { + if (evt.properties) { + delete (evt.properties as Record).support_uuid; } } + resetSegmentIdentity(); + resetMixpanelIdentity(); + return; } + + identifyInSegment(nextSupportUuid); + setMixpanelDistinctId(nextSupportUuid); }; /** diff --git a/app/src/services/logging/logger/lokiTransport.ts b/app/src/services/logging/logger/lokiTransport.ts index 446959ede..194c139d2 100644 --- a/app/src/services/logging/logger/lokiTransport.ts +++ b/app/src/services/logging/logger/lokiTransport.ts @@ -200,15 +200,23 @@ const lokiTransport: transportFunctionType = props => { message: string; timestamp: string; session_id: string; - support_uuid: string; + support_uuid_enabled: boolean; + support_uuid?: string; data?: unknown; - } = { - level: level.text, - message: actualMessage, - timestamp, - session_id: sessionId, - support_uuid: useSettingStore.getState().supportUuid ?? 'unset', - }; + } = (() => { + const { supportUuid, supportUuidEnabled } = useSettingStore.getState(); + + return { + level: level.text, + message: actualMessage, + timestamp, + session_id: sessionId, + support_uuid_enabled: supportUuidEnabled, + ...(supportUuidEnabled + ? { support_uuid: supportUuid ?? 'unset' } + : undefined), + }; + })(); if (actualData) { logObject.data = actualData; diff --git a/app/src/services/points/index.ts b/app/src/services/points/index.ts index 40f4578d3..694fd8b9b 100644 --- a/app/src/services/points/index.ts +++ b/app/src/services/points/index.ts @@ -8,10 +8,9 @@ export type { PointEvent, PointEventType, } from '@/services/points/types'; -export { POINT_VALUES } from '@/services/points/types'; - // Re-export all utility functions export { + NULLIFIER_ALREADY_USED_ERROR_PREFIX, formatTimeUntilDate, getIncomingPoints, getNextSundayNoonUTC, @@ -23,6 +22,8 @@ export { pointsSelfApp, } from '@/services/points/utils'; +export { POINT_VALUES } from '@/services/points/types'; + // Re-export event getter functions export { getAllPointEvents, diff --git a/app/src/services/points/utils.ts b/app/src/services/points/utils.ts index 6a9942a4a..f1739d737 100644 --- a/app/src/services/points/utils.ts +++ b/app/src/services/points/utils.ts @@ -17,6 +17,8 @@ export type WhitelistedContract = { num_disclosures: number; }; +export const NULLIFIER_ALREADY_USED_ERROR_PREFIX = '0xdc215c0a'; + export const formatTimeUntilDate = (targetDate: Date): string => { const now = new Date(); const diffMs = targetDate.getTime() - now.getTime(); diff --git a/app/src/services/supportUuid.ts b/app/src/services/supportUuid.ts index 0c98c13fe..d10da260b 100644 --- a/app/src/services/supportUuid.ts +++ b/app/src/services/supportUuid.ts @@ -12,8 +12,12 @@ import { } from '@/services/analytics'; import { useSettingStore } from '@/stores/settingStore'; -const ensureSupportUuid = (): string => { +const ensureSupportUuid = (): string | null => { const state = useSettingStore.getState(); + if (!state.supportUuidEnabled) { + return null; + } + if (state.supportUuid) { return state.supportUuid; } @@ -28,43 +32,75 @@ export const appendSupportUuidToUrl = (url: string): string => { try { const parsed = new URL(url); - parsed.searchParams.set('support_uuid', supportUuid); + if (supportUuid) { + parsed.searchParams.set('support_uuid', supportUuid); + } else { + parsed.searchParams.delete('support_uuid'); + } return parsed.toString(); } catch { - // Fallback for malformed URLs / unsupported URL parsing edge-cases. - // Preserve any fragment by appending the query before `#`. - const hashIndex = url.indexOf('#'); - const baseUrl = hashIndex === -1 ? url : url.slice(0, hashIndex); - const hash = hashIndex === -1 ? '' : url.slice(hashIndex); - const separator = baseUrl.includes('?') ? '&' : '?'; - return `${baseUrl}${separator}support_uuid=${encodeURIComponent(supportUuid)}${hash}`; + return url; } }; -export const copySupportUuid = (): string => { +export const copySupportUuid = (): string | null => { const supportUuid = ensureSupportUuid(); + if (!supportUuid) { + return null; + } + Clipboard.setString(supportUuid); return supportUuid; }; -export const getSupportUuid = (): string => { +export const getSupportUuid = (): string | null => { return ensureSupportUuid(); }; -export const initializeSupportUuidContext = (): string => { +export const initializeSupportUuidContext = (): string | null => { + const { supportUuidEnabled } = useSettingStore.getState(); const supportUuid = ensureSupportUuid(); - setSupportUuidInSentry(supportUuid); - setAnalyticsSupportUuid(supportUuid); + setSupportUuidInSentry(supportUuid, supportUuidEnabled); + setAnalyticsSupportUuid(supportUuidEnabled ? supportUuid : null); return supportUuid; }; -export const regenerateSupportUuid = (): string => { - const nextUuid = uuidv4(); +export const regenerateSupportUuid = (): string | null => { const state = useSettingStore.getState(); + if (!state.supportUuidEnabled) { + return null; + } + + const nextUuid = uuidv4(); state.setSupportUuid(nextUuid); - setSupportUuidInSentry(nextUuid); + setSupportUuidInSentry(nextUuid, true); resetAnalyticsIdentityForSupportUuid(nextUuid); return nextUuid; }; + +export const setSupportUuidCollectionEnabled = ( + enabled: boolean, +): string | null => { + const state = useSettingStore.getState(); + + if (enabled === state.supportUuidEnabled) { + return enabled ? ensureSupportUuid() : null; + } + + state.setSupportUuidEnabled(enabled); + + if (!enabled) { + state.setSupportUuid(null); + setSupportUuidInSentry(null, false); + setAnalyticsSupportUuid(null); + return null; + } + + const nextUuid = uuidv4(); + state.setSupportUuid(nextUuid); + setSupportUuidInSentry(nextUuid, true); + resetAnalyticsIdentityForSupportUuid(nextUuid); + return nextUuid; +}; diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts index 339486ed3..20553f7e3 100644 --- a/app/src/stores/settingStore.ts +++ b/app/src/stores/settingStore.ts @@ -23,6 +23,7 @@ interface PersistedSettingsState { isDevMode: boolean; loggingSeverity: LoggingSeverity; pointsAddress: string | null; + supportUuidEnabled: boolean; supportUuid: string | null; removeSubscribedTopic: (topic: string) => void; resetBackupForPoints: () => void; @@ -35,6 +36,7 @@ interface PersistedSettingsState { setKeychainMigrationCompleted: () => void; setLoggingSeverity: (severity: LoggingSeverity) => void; setPointsAddress: (address: string | null) => void; + setSupportUuidEnabled: (enabled: boolean) => void; setSupportUuid: (supportUuid: string | null) => void; setSkipDocumentSelector: (value: boolean) => void; setSubscribedTopics: (topics: string[]) => void; @@ -141,6 +143,9 @@ export const useSettingStore = create()( setPointsAddress: (address: string | null) => set({ pointsAddress: address }), + supportUuidEnabled: true, + setSupportUuidEnabled: (supportUuidEnabled: boolean) => + set({ supportUuidEnabled }), supportUuid: null, setSupportUuid: (supportUuid: string | null) => set({ supportUuid }), diff --git a/app/tests/src/components/support/SupportUuidRow.test.tsx b/app/tests/src/components/support/SupportUuidRow.test.tsx new file mode 100644 index 000000000..7364a43d4 --- /dev/null +++ b/app/tests/src/components/support/SupportUuidRow.test.tsx @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import SupportUuidRow from '@/components/support/SupportUuidRow'; +import { useSupportUuid } from '@/hooks/useSupportUuid'; + +jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({ + BodyText: ({ children, ...props }: any) => ( + {children} + ), +})); + +jest.mock('@/hooks/useSupportUuid', () => ({ + useSupportUuid: jest.fn(), +})); + +describe('SupportUuidRow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not render when diagnostic IDs are disabled', () => { + (useSupportUuid as jest.Mock).mockReturnValue({ + isEnabled: false, + supportUuid: null, + isReady: true, + copy: jest.fn(), + regenerate: jest.fn(), + setEnabled: jest.fn(), + }); + + const { toJSON } = render(); + + expect(toJSON()).toBeNull(); + }); + + it('renders the collapsed affordance when diagnostic IDs are enabled', () => { + (useSupportUuid as jest.Mock).mockReturnValue({ + isEnabled: true, + supportUuid: '11111111-1111-1111-1111-111111111111', + isReady: true, + copy: jest.fn(), + regenerate: jest.fn(), + setEnabled: jest.fn(), + }); + + const { toJSON } = render(); + + expect(JSON.stringify(toJSON())).toContain('Show diagnostic ID'); + }); +}); diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index b2a41e0a4..a5cebe049 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -111,6 +111,7 @@ describe('navigation', () => { 'Splash', 'StarfallPushCode', 'Support', + 'Troubleshooting', 'WebView', ]); }); diff --git a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx index 7f16d9750..0b004fb21 100644 --- a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx +++ b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx @@ -27,6 +27,7 @@ declare global { 'mock-top': any; 'mock-bottom': any; 'mock-scroll': any; + 'mock-rn-text': any; } } } @@ -36,6 +37,11 @@ jest.mock('react-native', () => ({ Linking: { openURL: jest.fn(), }, + Platform: { + OS: 'ios', + select: (spec: { ios?: unknown; android?: unknown; default?: unknown }) => + spec.ios ?? spec.default, + }, StyleSheet: { create: (styles: unknown) => styles, flatten: (style: unknown) => style, @@ -43,6 +49,9 @@ jest.mock('react-native', () => ({ View: ({ children, ...props }: any) => ( {children} ), + Text: ({ children, ...props }: any) => ( + {children} + ), })); jest.mock('@react-navigation/native', () => ({ @@ -61,6 +70,8 @@ jest.mock('tamagui', () => ({ jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({ black: '#000000', white: '#ffffff', + slate200: '#E2E8F0', + slate500: '#64748B', })); jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({ @@ -113,6 +124,10 @@ jest.mock('@/integrations/haptics', () => ({ notificationSuccess: jest.fn(), })); +jest.mock('@/config/sentry', () => ({ + captureException: jest.fn(), +})); + jest.mock('@/layouts/ExpandableBottomLayout', () => ({ ExpandableBottomLayout: { Layout: ({ children, ...props }: any) => ( @@ -157,6 +172,9 @@ const { buttonTap, notificationSuccess } = jest.requireMock( buttonTap: jest.Mock; notificationSuccess: jest.Mock; }; +const { captureException } = jest.requireMock('@/config/sentry') as { + captureException: jest.Mock; +}; const { getWhiteListedDisclosureAddresses } = jest.requireMock( '@/services/points/utils', ) as { @@ -179,12 +197,14 @@ describe('ProofRequestStatusScreen', () => { const mockTrackEvent = jest.fn(); const mockCleanSelfApp = jest.fn(); const mockUpdateProofStatus = jest.fn(); + const mockCancelProof = jest.fn(); let provingState: { currentState: string; reason: string | null; uuid: string; error_code: string | null; + cancel: jest.Mock; }; let selfAppState: { selfApp: { @@ -197,12 +217,15 @@ describe('ProofRequestStatusScreen', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + mockCancelProof.mockReset(); + mockCancelProof.mockResolvedValue(undefined); provingState = { currentState: 'completed', reason: null, uuid: 'session-1', error_code: null, + cancel: mockCancelProof, }; selfAppState = { selfApp: { @@ -277,6 +300,30 @@ describe('ProofRequestStatusScreen', () => { }); }); + it('keeps the button disabled on completed while whitelist fetch is pending', async () => { + selfAppState.selfApp.endpoint = '0xABC'; + // Simulate a never-resolving whitelist lookup to hold whitelistedPoints === undefined. + getWhiteListedDisclosureAddresses.mockImplementation( + () => new Promise(() => {}), + ); + + render(); + + await waitFor(() => { + expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1); + }); + + const button = screen.getByTestId('primary-button'); + expect(button.props.disabled).toBe(true); + + fireEvent.press(button); + + expect(buttonTap).not.toHaveBeenCalled(); + expect(mockGoHome).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockCleanSelfApp).not.toHaveBeenCalled(); + }); + it('navigates to Gratification when the endpoint is whitelisted for points', async () => { selfAppState.selfApp.endpoint = '0xABC'; getWhiteListedDisclosureAddresses.mockResolvedValue([ @@ -399,6 +446,82 @@ describe('ProofRequestStatusScreen', () => { expect(mockCleanSelfApp).not.toHaveBeenCalled(); }); + describe('failure / error', () => { + it('shows Dismiss on failure and routing Home clears session on press', async () => { + provingState.currentState = 'failure'; + provingState.reason = '[InvalidRoot]: Onchain root does not exist'; + + render(); + + const button = screen.getByTestId('primary-button'); + expect(button.props.children).toBe('Dismiss'); + expect(button.props.disabled).toBe(false); + + fireEvent.press(button); + + expect(buttonTap).toHaveBeenCalledTimes(1); + expect(mockGoHome).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(mockCleanSelfApp).toHaveBeenCalledTimes(1); + }); + + it('shows Dismiss on error and still exits even without a reason', async () => { + provingState.currentState = 'error'; + provingState.reason = null; + + render(); + + const button = screen.getByTestId('primary-button'); + expect(button.props.children).toBe('Dismiss'); + + fireEvent.press(button); + + expect(mockGoHome).toHaveBeenCalledTimes(1); + }); + + it('renders the QR-refresh copy for InvalidRoot failures', async () => { + provingState.currentState = 'failure'; + provingState.reason = + '[InvalidRoot]: Onchain root does not exist, received: 4589...'; + + const { toJSON } = render(); + + const tree = JSON.stringify(toJSON()); + expect(tree).toMatch(/QR code from Verifier is out of date/i); + }); + + it('renders a fallback copy for unknown failure reasons', async () => { + provingState.currentState = 'failure'; + provingState.reason = 'Something else went wrong'; + + const { toJSON } = render(); + + const tree = JSON.stringify(toJSON()); + expect(tree).toMatch( + /Unable to prove your identity to Verifier\. Please try again/i, + ); + }); + + it('renders the raw reason in the selectable details box for support', async () => { + const reason = + '[InvalidRoot]: Onchain root does not exist, received: 4589506917688709078187632663628833702807225'; + provingState.currentState = 'failure'; + provingState.reason = reason; + + const { toJSON } = render(); + + const tree = JSON.stringify(toJSON()); + expect(tree).toContain(reason); + expect(tree).toContain('Details'); + // The raw reason must be selectable so users/support can copy it. + expect(tree).toMatch(/"selectable":true/); + }); + }); + it('cancels deeplink redirect before it opens the external URL', async () => { selfAppState.selfApp.deeplinkCallback = 'https://callback.self.xyz/complete'; @@ -419,4 +542,267 @@ describe('ProofRequestStatusScreen', () => { expect(Linking.openURL).not.toHaveBeenCalled(); expect(mockGoHome).not.toHaveBeenCalled(); }); + + it('times out stalled proving on mount and lets the user dismiss safely', async () => { + provingState.currentState = 'proving'; + + const { toJSON } = render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + + const tree = JSON.stringify(toJSON()); + expect(tree).toMatch(/took too long to finish/i); + expect(tree).toContain('timed_out_after_90s'); + expect(mockUpdateProofStatus).toHaveBeenCalledWith( + 'session-1', + ProofStatus.FAILURE, + 'proof_timeout', + 'timed_out_after_90s', + ); + + await act(async () => { + fireEvent.press(screen.getByTestId('primary-button')); + }); + + await waitFor(() => { + expect(mockCancelProof).toHaveBeenCalledTimes(1); + }); + expect(mockCleanSelfApp).toHaveBeenCalledTimes(1); + expect(mockGoHome).toHaveBeenCalledTimes(1); + }); + + it('does not write proof history when timeout fires before a session id exists', async () => { + provingState.currentState = 'fetching_data'; + provingState.uuid = null as any; + + render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + + expect(mockUpdateProofStatus).not.toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_FAILED', { + sessionId: null, + appName: 'Verifier', + errorCode: 'proof_timeout', + reason: 'timed_out_after_90s', + state: 'timeout', + }); + }); + + it('does not write proof history for early completion without a session id', async () => { + provingState.currentState = 'completed'; + provingState.uuid = null as any; + + render(); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_COMPLETED', { + sessionId: null, + appName: 'Verifier', + }); + }); + + expect(mockUpdateProofStatus).not.toHaveBeenCalled(); + }); + + it('does not write proof history for early failure without a session id', async () => { + provingState.currentState = 'error'; + provingState.uuid = null as any; + provingState.error_code = 'early_error'; + provingState.reason = 'failed before tee'; + + render(); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_FAILED', { + sessionId: null, + appName: 'Verifier', + errorCode: 'early_error', + reason: 'failed before tee', + state: 'error', + }); + }); + + expect(mockUpdateProofStatus).not.toHaveBeenCalled(); + }); + + it('times out when stuck in post_proving', async () => { + provingState.currentState = 'post_proving'; + + render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + }); + + it('does not schedule delayed cleanup when acknowledging a failure with no session id', async () => { + provingState.currentState = 'failure'; + provingState.uuid = null as any; + provingState.reason = 'early failure'; + + render(); + + fireEvent.press(screen.getByTestId('primary-button')); + + expect(mockGoHome).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(mockCleanSelfApp).not.toHaveBeenCalled(); + }); + + it('resets the stall timer when the proving state changes', async () => { + provingState.currentState = 'fetching_data'; + const { rerender } = render(); + + act(() => { + jest.advanceTimersByTime(89_000); + }); + + expect(screen.queryByText('Proof Failed')).toBeNull(); + + provingState.currentState = 'proving'; + rerender(); + + act(() => { + jest.advanceTimersByTime(2_000); + }); + + expect(screen.queryByText('Proof Failed')).toBeNull(); + + act(() => { + jest.advanceTimersByTime(88_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + }); + + it('resets timeout state when a new session id arrives', async () => { + provingState.currentState = 'proving'; + const { rerender } = render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + + provingState.uuid = 'session-2'; + rerender(); + + await waitFor(() => { + expect(screen.queryByText('timed_out_after_90s')).toBeNull(); + }); + expect(screen.getByTestId('primary-button').props.children).not.toBe( + 'Dismiss', + ); + }); + + it('clears the stall timer when the screen loses focus', () => { + let focused = true; + (useIsFocused as jest.Mock).mockImplementation(() => focused); + provingState.currentState = 'proving'; + + const { rerender } = render(); + + act(() => { + jest.advanceTimersByTime(45_000); + }); + + focused = false; + rerender(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + expect(screen.queryByText('Proof Failed')).toBeNull(); + }); + + it('still exits home if cancelling a timed-out proof throws', async () => { + provingState.currentState = 'proving'; + mockCancelProof.mockRejectedValueOnce(new Error('close failed')); + + render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + + await act(async () => { + fireEvent.press(screen.getByTestId('primary-button')); + }); + + await waitFor(() => { + expect(mockGoHome).toHaveBeenCalledTimes(1); + }); + expect(mockCleanSelfApp).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it('ignores repeated dismiss taps while timed-out cancellation is in flight', async () => { + provingState.currentState = 'proving'; + mockCancelProof.mockImplementation( + () => new Promise(resolve => setTimeout(resolve, 10)), + ); + + render(); + + act(() => { + jest.advanceTimersByTime(90_000); + }); + + await waitFor(() => { + expect(screen.getByTestId('primary-button').props.children).toBe( + 'Dismiss', + ); + }); + + await act(async () => { + fireEvent.press(screen.getByTestId('primary-button')); + fireEvent.press(screen.getByTestId('primary-button')); + jest.advanceTimersByTime(10); + }); + + expect(mockCancelProof).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts index 525970658..223041130 100644 --- a/app/tests/src/services/analytics.test.ts +++ b/app/tests/src/services/analytics.test.ts @@ -366,6 +366,11 @@ describe('analytics', () => { expect(mockSegmentClient.identify).toHaveBeenCalledWith('uuid-1'); }); + it('resets analytics identity when support UUID is cleared', () => { + setAnalyticsSupportUuid(null); + expect(mockSegmentClient.reset).toHaveBeenCalled(); + }); + it('resets and re-identifies on resetAnalyticsIdentityForSupportUuid', async () => { resetAnalyticsIdentityForSupportUuid('uuid-2'); expect(mockSegmentClient.reset).toHaveBeenCalled(); diff --git a/app/tests/src/services/logging/lokiTransport.test.ts b/app/tests/src/services/logging/lokiTransport.test.ts index 92d8431cf..b0890235f 100644 --- a/app/tests/src/services/logging/lokiTransport.test.ts +++ b/app/tests/src/services/logging/lokiTransport.test.ts @@ -29,10 +29,16 @@ jest.mock('../../../../env', () => ({ })); jest.mock('@/stores/settingStore', () => { - const state = { supportUuid: null as string | null }; + const state = { + supportUuid: null as string | null, + supportUuidEnabled: true, + }; return { useSettingStore: { getState: () => ({ + get supportUuidEnabled() { + return state.supportUuidEnabled; + }, get supportUuid() { return state.supportUuid; }, @@ -44,7 +50,7 @@ jest.mock('@/stores/settingStore', () => { const storeState = ( useSettingStore as unknown as { - __state: { supportUuid: string | null }; + __state: { supportUuid: string | null; supportUuidEnabled: boolean }; } ).__state; @@ -65,6 +71,7 @@ describe('lokiTransport', () => { fetchMock = jest.fn().mockResolvedValue({ ok: true }); (global as unknown as { fetch: jest.Mock }).fetch = fetchMock; storeState.supportUuid = null; + storeState.supportUuidEnabled = true; }); afterEach(() => { @@ -84,6 +91,7 @@ describe('lokiTransport', () => { const payload = JSON.parse(init.body as string); const logLine = JSON.parse(payload.streams[0].values[0][1]); expect(logLine.support_uuid).toBe('abc-123'); + expect(logLine.support_uuid_enabled).toBe(true); }); it('falls back to "unset" when no support UUID is stored', async () => { @@ -96,6 +104,21 @@ describe('lokiTransport', () => { const payload = JSON.parse(init.body as string); const logLine = JSON.parse(payload.streams[0].values[0][1]); expect(logLine.support_uuid).toBe('unset'); + expect(logLine.support_uuid_enabled).toBe(true); + }); + + it('omits support_uuid when diagnostic IDs are disabled', async () => { + storeState.supportUuidEnabled = false; + callTransport(); + flushLokiTransport(); + await Promise.resolve(); + await Promise.resolve(); + + const [, init] = fetchMock.mock.calls[0]; + const payload = JSON.parse(init.body as string); + const logLine = JSON.parse(payload.streams[0].values[0][1]); + expect(logLine).not.toHaveProperty('support_uuid'); + expect(logLine.support_uuid_enabled).toBe(false); }); it('does not put support_uuid or session_id in Loki stream labels', async () => { diff --git a/app/tests/src/services/supportUuid.test.ts b/app/tests/src/services/supportUuid.test.ts index 16a67135f..75348ff61 100644 --- a/app/tests/src/services/supportUuid.test.ts +++ b/app/tests/src/services/supportUuid.test.ts @@ -15,23 +15,35 @@ import { getSupportUuid, initializeSupportUuidContext, regenerateSupportUuid, + setSupportUuidCollectionEnabled, } from '@/services/supportUuid'; import { useSettingStore } from '@/stores/settingStore'; jest.mock('@/stores/settingStore', () => { - const state = { supportUuid: null as string | null }; + const state = { + supportUuid: null as string | null, + supportUuidEnabled: true, + }; const setSupportUuid = jest.fn((next: string | null) => { state.supportUuid = next; }); + const setSupportUuidEnabled = jest.fn((next: boolean) => { + state.supportUuidEnabled = next; + }); return { useSettingStore: { getState: () => ({ + get supportUuidEnabled() { + return state.supportUuidEnabled; + }, get supportUuid() { return state.supportUuid; }, + setSupportUuidEnabled, setSupportUuid, }), __state: state, + __setSupportUuidEnabled: setSupportUuidEnabled, __setSupportUuid: setSupportUuid, }, }; @@ -52,15 +64,21 @@ jest.mock('@react-native-clipboard/clipboard', () => ({ })); const storeState = ( - useSettingStore as unknown as { __state: { supportUuid: string | null } } + useSettingStore as unknown as { + __state: { supportUuid: string | null; supportUuidEnabled: boolean }; + } ).__state; const mockSetSupportUuid = ( useSettingStore as unknown as { __setSupportUuid: jest.Mock } ).__setSupportUuid; +const mockSetSupportUuidEnabled = ( + useSettingStore as unknown as { __setSupportUuidEnabled: jest.Mock } +).__setSupportUuidEnabled; describe('supportUuid service', () => { beforeEach(() => { storeState.supportUuid = null; + storeState.supportUuidEnabled = true; jest.clearAllMocks(); }); @@ -76,6 +94,12 @@ describe('supportUuid service', () => { expect(getSupportUuid()).toBe(storeState.supportUuid); expect(mockSetSupportUuid).not.toHaveBeenCalled(); }); + + it('returns null when diagnostic IDs are disabled', () => { + storeState.supportUuidEnabled = false; + expect(getSupportUuid()).toBeNull(); + expect(mockSetSupportUuid).not.toHaveBeenCalled(); + }); }); describe('appendSupportUuidToUrl', () => { @@ -104,24 +128,43 @@ describe('supportUuid service', () => { expect(result).not.toContain('support_uuid=stale'); }); - it('preserves fragments when falling back on malformed URLs', () => { + it('returns the URL unchanged when URL parsing fails', () => { const urlSpy = jest.spyOn(global, 'URL').mockImplementationOnce(() => { throw new TypeError('invalid'); }); - const result = appendSupportUuidToUrl('support?x=1#section'); - expect(result).toBe( - `support?x=1&support_uuid=${storeState.supportUuid}#section`, + expect(appendSupportUuidToUrl('support?x=1#section')).toBe( + 'support?x=1#section', ); urlSpy.mockRestore(); }); + + it('strips support_uuid from the URL when diagnostic IDs are disabled', () => { + storeState.supportUuidEnabled = false; + expect( + appendSupportUuidToUrl('https://example.com/help?x=1&support_uuid=abc'), + ).toBe('https://example.com/help?x=1'); + expect( + appendSupportUuidToUrl('https://example.com/help?support_uuid=abc'), + ).toBe('https://example.com/help'); + expect(appendSupportUuidToUrl('https://example.com/help?x=1')).toBe( + 'https://example.com/help?x=1', + ); + }); }); describe('initializeSupportUuidContext', () => { it('wires the UUID into Sentry and analytics', () => { const uuid = initializeSupportUuidContext(); - expect(setSupportUuidInSentry).toHaveBeenCalledWith(uuid); + expect(setSupportUuidInSentry).toHaveBeenCalledWith(uuid, true); expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(uuid); }); + + it('clears support UUID context when diagnostic IDs are disabled', () => { + storeState.supportUuidEnabled = false; + expect(initializeSupportUuidContext()).toBeNull(); + expect(setSupportUuidInSentry).toHaveBeenCalledWith(null, false); + expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(null); + }); }); describe('regenerateSupportUuid', () => { @@ -132,9 +175,16 @@ describe('supportUuid service', () => { expect(next).not.toBe('old-uuid'); expect(mockSetSupportUuid).toHaveBeenCalledWith(next); expect(setSupportUuidInSentry).toHaveBeenCalledTimes(1); - expect(setSupportUuidInSentry).toHaveBeenCalledWith(next); + expect(setSupportUuidInSentry).toHaveBeenCalledWith(next, true); expect(resetAnalyticsIdentityForSupportUuid).toHaveBeenCalledWith(next); }); + + it('returns null when regenerate is called while disabled', () => { + storeState.supportUuidEnabled = false; + expect(regenerateSupportUuid()).toBeNull(); + expect(mockSetSupportUuid).not.toHaveBeenCalled(); + expect(resetAnalyticsIdentityForSupportUuid).not.toHaveBeenCalled(); + }); }); describe('copySupportUuid', () => { @@ -144,5 +194,37 @@ describe('supportUuid service', () => { expect(uuid).toBe(storeState.supportUuid); expect(Clipboard.setString).toHaveBeenCalledWith(storeState.supportUuid); }); + + it('returns null when copy is called while disabled', () => { + storeState.supportUuidEnabled = false; + expect(copySupportUuid()).toBeNull(); + expect(Clipboard.setString).not.toHaveBeenCalled(); + }); + }); + + describe('setSupportUuidCollectionEnabled', () => { + it('disables diagnostic IDs and clears the current UUID', () => { + storeState.supportUuid = 'existing-uuid'; + expect(setSupportUuidCollectionEnabled(false)).toBeNull(); + + expect(mockSetSupportUuidEnabled).toHaveBeenCalledWith(false); + expect(mockSetSupportUuid).toHaveBeenCalledWith(null); + expect(setSupportUuidInSentry).toHaveBeenCalledWith(null, false); + expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(null); + }); + + it('re-enables diagnostic IDs with a fresh UUID', () => { + storeState.supportUuidEnabled = false; + + const nextUuid = setSupportUuidCollectionEnabled(true); + + expect(nextUuid).toMatch(/^[0-9a-f-]{36}$/); + expect(mockSetSupportUuidEnabled).toHaveBeenCalledWith(true); + expect(mockSetSupportUuid).toHaveBeenCalledWith(nextUuid); + expect(setSupportUuidInSentry).toHaveBeenCalledWith(nextUuid, true); + expect(resetAnalyticsIdentityForSupportUuid).toHaveBeenCalledWith( + nextUuid, + ); + }); }); }); diff --git a/app/version.json b/app/version.json index ee3e922e3..51fc5f356 100644 --- a/app/version.json +++ b/app/version.json @@ -1,10 +1,10 @@ { "ios": { - "build": 218, - "lastDeployed": "2026-04-14T21:02:24.000Z" + "build": 219, + "lastDeployed": "2026-04-20T03:28:28.779Z" }, "android": { - "build": 149, - "lastDeployed": "2026-04-20T00:22:24.761Z" + "build": 150, + "lastDeployed": "2026-04-20T03:28:28.779Z" } } diff --git a/packages/mobile-sdk-alpha/src/constants/analytics.ts b/packages/mobile-sdk-alpha/src/constants/analytics.ts index 36d5483df..116eeca82 100644 --- a/packages/mobile-sdk-alpha/src/constants/analytics.ts +++ b/packages/mobile-sdk-alpha/src/constants/analytics.ts @@ -185,6 +185,7 @@ export const ProofEvents = { PROOF_COMPLETED: 'Proof: Proof Completed', PROOF_DISCLOSURES_SCROLLED: 'Proof: Proof Disclosures Scrolled', PROOF_FAILED: 'Proof: Proof Failed', + POINTS_NULLIFIER_ALREADY_USED: 'Proof: Points Nullifier Already Used', PROOF_RESULT_ACKNOWLEDGED: 'Proof: Proof Result Acknowledged', PROOF_VERIFY_CONFIRMATION_ACCEPTED: 'Proof: Verify Confirmation Accepted', PROOF_VERIFY_LONG_PRESS: 'Proof: Verify Button Long Pressed', diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts index 7b8a6e6d7..768b6b3f3 100644 --- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts +++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts @@ -235,6 +235,7 @@ export interface ProvingState { circuitType: 'dsc' | 'disclose' | 'register', userConfirmed?: boolean, ) => Promise; + cancel: (selfClient: SelfClient) => Promise; parseIDDocument: (selfClient: SelfClient) => Promise; startFetchingData: (selfClient: SelfClient) => Promise; validatingDocument: (selfClient: SelfClient) => Promise; @@ -393,6 +394,29 @@ export const getPostVerificationRoute = () => { export const useProvingStore = create((set, get) => { let actor: AnyActorRef | null = null; + function resetProvingState(partial?: Partial) { + set({ + currentState: 'idle', + attestation: null, + serverPublicKey: null, + sharedKey: null, + wsConnection: null, + wsHandlers: null, + wsReconnectAttempts: 0, + socketConnection: null, + uuid: null, + userConfirmed: false, + passportData: null, + secret: null, + circuitType: null, + endpointType: null, + env: null, + error_code: null, + reason: null, + ...partial, + }); + } + function setupActorSubscriptions(newActor: AnyActorRef, selfClient: SelfClient) { let lastTransition = Date.now(); let lastEvent: AnyEventObject = { type: 'init' }; @@ -547,6 +571,38 @@ export const useProvingStore = create((set, get) => { error_code: null, reason: null, endpointType: null, + cancel: async (selfClient: SelfClient) => { + const context = createProofContext(selfClient, 'cancel'); + selfClient.logProofEvent('warn', 'Proving flow cancelled by UI', context); + let cancellationError: Error | null = null; + + // Stop and null the actor BEFORE closing the socket. _closeConnections + // triggers the socket 'disconnect' handler, which reads module-scope + // `actor` and would otherwise fire a spurious PROVE_ERROR on the + // about-to-be-stopped actor. + if (actor) { + try { + actor.stop(); + } catch (error) { + cancellationError = error instanceof Error ? error : new Error(String(error)); + } finally { + actor = null; + } + } + + try { + get()._closeConnections(selfClient); + } catch (error) { + cancellationError ??= error instanceof Error ? error : new Error(String(error)); + } + + selfClient.navigation?.disableKeychainErrorModal?.(); + resetProvingState(); + + if (cancellationError) { + throw cancellationError; + } + }, _handleWebSocketMessage: async (event: MessageEvent, selfClient: SelfClient) => { if (!actor) { console.error('Cannot process message: State machine not initialized.'); @@ -1010,23 +1066,11 @@ export const useProvingStore = create((set, get) => { } catch (error) { console.error('Error stopping actor:', error); } + actor = null; } - set({ - currentState: 'idle', - attestation: null, - serverPublicKey: null, - sharedKey: null, - wsConnection: null, - socketConnection: null, - uuid: null, - userConfirmed: userConfirmed, - passportData: null, - secret: null, + resetProvingState({ + userConfirmed, circuitType, - endpointType: null, - env: null, - error_code: null, - reason: null, }); actor = createActor(provingMachine); diff --git a/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts b/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts index 1ec8631db..2f8f1e0c5 100644 --- a/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts +++ b/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts @@ -105,6 +105,10 @@ describe('provingMachine Socket.IO Integration', () => { emit: vi.fn(), getPrivateKey: vi.fn(() => Promise.resolve('mock-private-key')), logProofEvent: vi.fn(), + navigation: { + disableKeychainErrorModal: vi.fn(), + enableKeychainErrorModal: vi.fn(), + }, getSelfAppState: () => ({ selfApp: {}, }), @@ -281,4 +285,63 @@ describe('provingMachine Socket.IO Integration', () => { // Should still track the status received event (covered elsewhere) }); }); + + describe('cancel', () => { + it('closes active connections and resets proving state to idle', async () => { + mockActor.stop.mockClear(); + const removeEventListener = vi.fn(); + const closeWs = vi.fn(); + const closeSocket = vi.fn(); + + useProvingStore.setState({ + currentState: 'proving', + uuid: 'session-1', + userConfirmed: true, + circuitType: 'register', + error_code: 'E001', + reason: 'stalled', + wsConnection: { + removeEventListener, + close: closeWs, + } as any, + wsHandlers: { + message: vi.fn(), + open: vi.fn(), + error: vi.fn(), + close: vi.fn(), + }, + socketConnection: { + close: closeSocket, + } as any, + } as any); + + await useProvingStore.getState().cancel(mockSelfClient); + + const finalState = useProvingStore.getState(); + expect(removeEventListener).toHaveBeenCalledTimes(4); + expect(closeWs).toHaveBeenCalledTimes(1); + expect(closeSocket).toHaveBeenCalledTimes(1); + expect(mockActor.stop).toHaveBeenCalledTimes(1); + expect(mockSelfClient.navigation.disableKeychainErrorModal).toHaveBeenCalledTimes(1); + expect(finalState.currentState).toBe('idle'); + expect(finalState.uuid).toBe(null); + expect(finalState.reason).toBe(null); + expect(finalState.error_code).toBe(null); + expect(finalState.socketConnection).toBe(null); + expect(finalState.wsConnection).toBe(null); + }); + + it('allows init to run again after cancel', async () => { + const store = useProvingStore.getState(); + + await store.cancel(mockSelfClient); + await store.init(mockSelfClient, 'register', true); + + expect(mockActor.stop).toHaveBeenCalled(); + expect(mockActor.start).toHaveBeenCalledTimes(2); + expect(useProvingStore.getState().currentState).toBe('idle'); + expect(useProvingStore.getState().circuitType).toBe('register'); + expect(useProvingStore.getState().userConfirmed).toBe(true); + }); + }); });