mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Merge pull request #2006 from selfxyz/release/staging-2026-04-21
Release to Staging v2.9.19 - 2026-04-21
This commit is contained in:
@@ -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:
|
||||
|
||||
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@@ -1,7 +0,0 @@
|
||||
## Summary
|
||||
|
||||
<!-- Brief description of changes -->
|
||||
|
||||
## Test plan
|
||||
|
||||
<!-- How was this tested? -->
|
||||
63
.github/workflows/mobile-e2e.yml
vendored
63
.github/workflows/mobile-e2e.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.9.18</string>
|
||||
<string>2.9.19</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfxyz/mobile-app",
|
||||
"version": "2.9.18",
|
||||
"version": "2.9.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,7 +26,7 @@ const SupportUuidRow: React.FC<SupportUuidRowProps> = ({
|
||||
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<SupportUuidRowProps> = ({
|
||||
|
||||
const toggle = useCallback(() => setExpanded(prev => !prev), []);
|
||||
|
||||
if (!isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<Button
|
||||
@@ -47,7 +51,7 @@ const SupportUuidRow: React.FC<SupportUuidRowProps> = ({
|
||||
paddingVertical={10}
|
||||
paddingHorizontal={12}
|
||||
>
|
||||
<BodyText style={{ color: slate500 }}>Show diagnostic info</BodyText>
|
||||
<BodyText style={{ color: slate500 }}>Show diagnostic ID</BodyText>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,12 +300,16 @@ export const logProofEvent = (
|
||||
extra?: Record<string, unknown>,
|
||||
) => 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) => {
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => 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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SettingsRouteKey, React.FC<SvgProps>> = {
|
||||
Support: Feedback,
|
||||
share: ShareIcon,
|
||||
DevSettings: Bug as React.FC<SvgProps>,
|
||||
Troubleshooting: Wrench as React.FC<SvgProps>,
|
||||
};
|
||||
|
||||
const social = [
|
||||
@@ -133,6 +134,7 @@ const SocialButton: React.FC<SocialButtonProps> = ({
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const { isDevMode, setDevModeOn } = useSettingStore();
|
||||
const [isTroubleshootingMode, setTroubleshootingMode] = useState(false);
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<MinimalRootStackParamList>>();
|
||||
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 (
|
||||
<GestureDetector gesture={devModeTap}>
|
||||
<GestureDetector gesture={combinedTap}>
|
||||
<RNView collapsable={false}>
|
||||
<View backgroundColor={white}>
|
||||
<YStack
|
||||
|
||||
@@ -3,23 +3,26 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { Alert, StyleSheet, Switch, View } from 'react-native';
|
||||
import { Button, ScrollView, YStack } from 'tamagui';
|
||||
|
||||
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
black,
|
||||
blue600,
|
||||
slate100,
|
||||
slate200,
|
||||
slate500,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
|
||||
|
||||
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
|
||||
import { useSupportUuid } from '@/hooks/useSupportUuid';
|
||||
|
||||
const SupportScreen: React.FC = () => {
|
||||
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 (
|
||||
<ScrollView flex={1} backgroundColor={slate100}>
|
||||
<YStack padding={20} gap={20}>
|
||||
@@ -59,49 +69,97 @@ const SupportScreen: React.FC = () => {
|
||||
|
||||
<YStack gap={8}>
|
||||
<BodyText style={{ color: slate500, fontSize: 13 }}>
|
||||
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.
|
||||
</BodyText>
|
||||
|
||||
<YStack
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={12}
|
||||
backgroundColor={white}
|
||||
padding={16}
|
||||
gap={8}
|
||||
>
|
||||
<BodyText style={{ color: black, fontSize: 16 }}>
|
||||
Diagnostic ID
|
||||
</BodyText>
|
||||
<BodyText style={{ color: slate500, fontSize: 14 }}>
|
||||
{diagnosticIdText}
|
||||
</BodyText>
|
||||
</YStack>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<BodyText style={styles.settingLabel}>
|
||||
Share diagnostic ID
|
||||
</BodyText>
|
||||
<BodyText style={styles.settingDescription}>
|
||||
{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.'}
|
||||
</BodyText>
|
||||
</View>
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleSupportUuidToggle}
|
||||
trackColor={{ false: slate200, true: blue600 }}
|
||||
thumbColor={white}
|
||||
testID="support-uuid-toggle"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderColor={slate200}
|
||||
borderWidth={1}
|
||||
borderRadius={12}
|
||||
onPress={handleCopy}
|
||||
>
|
||||
<BodyText style={{ color: black }}>Copy diagnostic ID</BodyText>
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<>
|
||||
<YStack
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={12}
|
||||
backgroundColor={white}
|
||||
padding={16}
|
||||
gap={8}
|
||||
>
|
||||
<BodyText style={{ color: black, fontSize: 16 }}>
|
||||
Diagnostic ID
|
||||
</BodyText>
|
||||
<BodyText style={{ color: slate500, fontSize: 14 }}>
|
||||
{diagnosticIdText}
|
||||
</BodyText>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderColor={slate200}
|
||||
borderWidth={1}
|
||||
borderRadius={12}
|
||||
onPress={handleRegenerate}
|
||||
>
|
||||
<BodyText style={{ color: black }}>Regenerate</BodyText>
|
||||
</Button>
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderColor={slate200}
|
||||
borderWidth={1}
|
||||
borderRadius={12}
|
||||
onPress={handleCopy}
|
||||
>
|
||||
<BodyText style={{ color: black }}>Copy diagnostic ID</BodyText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderColor={slate200}
|
||||
borderWidth={1}
|
||||
borderRadius={12}
|
||||
onPress={handleRegenerate}
|
||||
>
|
||||
<BodyText style={{ color: black }}>Regenerate</BodyText>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
122
app/src/screens/dev/TroubleshootingScreen.tsx
Normal file
122
app/src/screens/dev/TroubleshootingScreen.tsx
Normal file
@@ -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 (
|
||||
<YStack padding="$4" gap="$4">
|
||||
<YStack gap="$2">
|
||||
<H4>Fix points disclosure</H4>
|
||||
<Paragraph color={slate500}>
|
||||
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.
|
||||
</Paragraph>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
backgroundColor={status === 'success' ? teal500 : black}
|
||||
color={white}
|
||||
borderColor={slate200}
|
||||
borderRadius="$3"
|
||||
height="$5"
|
||||
disabled={status === 'loading'}
|
||||
onPress={handleFix}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<Spinner color={white} />
|
||||
) : status === 'success' ? (
|
||||
'Fixed'
|
||||
) : (
|
||||
'Fix Points Issue'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{message !== '' && (
|
||||
<Text fontSize="$3" color={status === 'error' ? red500 : teal500}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TroubleshootingScreen;
|
||||
@@ -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<LottieViewProps['source']>(loadingAnimation);
|
||||
const [countdown, setCountdown] = useState<number | null>(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<NodeJS.Timeout | null>(null);
|
||||
const provingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const timedOutSessionIdRef = useRef<string | null>(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 (
|
||||
<ExpandableBottomLayout.Layout backgroundColor={white}>
|
||||
@@ -262,11 +401,14 @@ const SuccessScreen: React.FC = () => {
|
||||
backgroundColor={white}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Title size="large">{getTitle(currentState)}</Title>
|
||||
<Title size="large">{getTitle(displayState)}</Title>
|
||||
<Info
|
||||
currentState={currentState}
|
||||
currentState={displayState}
|
||||
appName={appName ?? 'The app'}
|
||||
reason={reason ?? undefined}
|
||||
reason={displayReason}
|
||||
errorCode={
|
||||
hasTimedOut ? PROOF_TIMEOUT_ERROR_CODE : (errorCode ?? undefined)
|
||||
}
|
||||
countdown={countdown}
|
||||
deeplinkCallback={selfApp?.deeplinkCallback?.replace(
|
||||
/^https?:\/\//,
|
||||
@@ -277,26 +419,32 @@ const SuccessScreen: React.FC = () => {
|
||||
<PrimaryButton
|
||||
trackEvent={ProofEvents.PROOF_RESULT_ACKNOWLEDGED}
|
||||
disabled={
|
||||
(currentState !== 'completed' &&
|
||||
currentState !== 'error' &&
|
||||
currentState !== 'failure') ||
|
||||
(currentState === 'completed' &&
|
||||
isDismissingTimedOutProof ||
|
||||
(displayState !== 'completed' &&
|
||||
displayState !== 'error' &&
|
||||
displayState !== 'failure') ||
|
||||
(displayState === 'completed' &&
|
||||
whitelistedPoints === undefined &&
|
||||
!(countdown !== null && countdown > 0))
|
||||
}
|
||||
onPress={
|
||||
countdown !== null && countdown > 0
|
||||
? cancelDeeplinkCallbackRedirect
|
||||
: onOkPress
|
||||
hasTimedOut
|
||||
? onTimedOutDismiss
|
||||
: countdown !== null && countdown > 0
|
||||
? cancelDeeplinkCallbackRedirect
|
||||
: onOkPress
|
||||
}
|
||||
>
|
||||
{currentState !== 'completed' &&
|
||||
currentState !== 'error' &&
|
||||
currentState !== 'failure' ? (
|
||||
{isDismissingTimedOutProof ? (
|
||||
<Spinner />
|
||||
) : displayState === 'failure' || displayState === 'error' ? (
|
||||
'Dismiss'
|
||||
) : displayState !== 'completed' ? (
|
||||
<Spinner />
|
||||
) : countdown !== null && countdown > 0 ? (
|
||||
'Cancel'
|
||||
) : whitelistedPoints === undefined ? (
|
||||
) : currentState === 'completed' &&
|
||||
whitelistedPoints === undefined ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
'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({
|
||||
</Description>
|
||||
);
|
||||
} else if (currentState === 'error' || currentState === 'failure') {
|
||||
const userMessage = getUserFacingErrorMessage(
|
||||
currentState,
|
||||
reason,
|
||||
errorCode,
|
||||
appName,
|
||||
);
|
||||
return (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Description>
|
||||
Unable to prove your identity to{' '}
|
||||
<BodyText style={typography.strong}>{appName}</BodyText>
|
||||
{currentState === 'error' && '. Due to technical issues.'}
|
||||
</Description>
|
||||
<View style={{ gap: 12 }}>
|
||||
<Description>{userMessage}</Description>
|
||||
{currentState === 'failure' && reason && (
|
||||
<>
|
||||
<Description>
|
||||
<BodyText style={[typography.strong, { fontSize: 14 }]}>
|
||||
Reason:
|
||||
</BodyText>
|
||||
</Description>
|
||||
<View style={{ maxHeight: 60 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={true}>
|
||||
<Description>
|
||||
<BodyText style={[typography.strong, { fontSize: 14 }]}>
|
||||
{reason}
|
||||
</BodyText>
|
||||
</Description>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</>
|
||||
<View style={styles.reasonBox}>
|
||||
<BodyText style={[typography.strong, styles.reasonLabel]}>
|
||||
Details
|
||||
</BodyText>
|
||||
<ScrollView
|
||||
style={styles.reasonScroll}
|
||||
showsVerticalScrollIndicator={true}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<Text selectable style={styles.reasonText}>
|
||||
{reason}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -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' }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, unknown>).support_uuid;
|
||||
}
|
||||
}
|
||||
resetSegmentIdentity();
|
||||
resetMixpanelIdentity();
|
||||
return;
|
||||
}
|
||||
|
||||
identifyInSegment(nextSupportUuid);
|
||||
setMixpanelDistinctId(nextSupportUuid);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,15 +200,23 @@ const lokiTransport: transportFunctionType<LokiTransportOptions> = 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<SettingsState>()(
|
||||
setPointsAddress: (address: string | null) =>
|
||||
set({ pointsAddress: address }),
|
||||
|
||||
supportUuidEnabled: true,
|
||||
setSupportUuidEnabled: (supportUuidEnabled: boolean) =>
|
||||
set({ supportUuidEnabled }),
|
||||
supportUuid: null,
|
||||
setSupportUuid: (supportUuid: string | null) => set({ supportUuid }),
|
||||
|
||||
|
||||
55
app/tests/src/components/support/SupportUuidRow.test.tsx
Normal file
55
app/tests/src/components/support/SupportUuidRow.test.tsx
Normal file
@@ -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) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<SupportUuidRow />);
|
||||
|
||||
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(<SupportUuidRow />);
|
||||
|
||||
expect(JSON.stringify(toJSON())).toContain('Show diagnostic ID');
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,7 @@ describe('navigation', () => {
|
||||
'Splash',
|
||||
'StarfallPushCode',
|
||||
'Support',
|
||||
'Troubleshooting',
|
||||
'WebView',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
),
|
||||
Text: ({ children, ...props }: any) => (
|
||||
<mock-rn-text {...props}>{children}</mock-rn-text>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(89_000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Proof Failed')).toBeNull();
|
||||
|
||||
provingState.currentState = 'proving';
|
||||
rerender(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(90_000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('primary-button').props.children).toBe(
|
||||
'Dismiss',
|
||||
);
|
||||
});
|
||||
|
||||
provingState.uuid = 'session-2';
|
||||
rerender(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(45_000);
|
||||
});
|
||||
|
||||
focused = false;
|
||||
rerender(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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(<ProofRequestStatusScreen />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -235,6 +235,7 @@ export interface ProvingState {
|
||||
circuitType: 'dsc' | 'disclose' | 'register',
|
||||
userConfirmed?: boolean,
|
||||
) => Promise<void>;
|
||||
cancel: (selfClient: SelfClient) => Promise<void>;
|
||||
parseIDDocument: (selfClient: SelfClient) => Promise<void>;
|
||||
startFetchingData: (selfClient: SelfClient) => Promise<void>;
|
||||
validatingDocument: (selfClient: SelfClient) => Promise<void>;
|
||||
@@ -393,6 +394,29 @@ export const getPostVerificationRoute = () => {
|
||||
export const useProvingStore = create<ProvingState>((set, get) => {
|
||||
let actor: AnyActorRef | null = null;
|
||||
|
||||
function resetProvingState(partial?: Partial<ProvingState>) {
|
||||
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<ProvingState>((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<ProvingState>((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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user