chore: add redirect.self.xyz to app link domains (#415)

* chore: add redirect.self.xyz to app link domains

* chore: add redirect.self.xyz to android manifest

* add self in the universal link path

* set gestureEnabled false on Launch screen

* improve ProveScreen UX

* improve ProveScreen UX

* implement deeplinking in mobile app and sdk

* start app listener in ViewFinder

* support WS connexion with complex qrcodes

* remove /self path from android deeplink support

---------

Co-authored-by: turboblitz <florent.tavernier@gmail.com>
This commit is contained in:
turnoffthiscomputer
2025-03-21 16:55:11 -04:00
committed by GitHub
parent f501bf47a4
commit 36a29c9a21
17 changed files with 321 additions and 89 deletions

View File

@@ -26,12 +26,14 @@ import ConfirmBelongingScreen from './screens/Onboarding/ConfirmBelongingScreen'
import LoadingScreen from './screens/Onboarding/LoadingScreen';
import PassportCameraScreen from './screens/Onboarding/PassportCameraScreen';
import PassportCameraTrouble from './screens/Onboarding/PassportCameraTrouble';
import PassportDataNotFound from './screens/Onboarding/PassportDataNotFound';
import PassportNFCScanScreen from './screens/Onboarding/PassportNFCScanScreen';
import PassportNFCTrouble from './screens/Onboarding/PassportNFCTrouble';
import PassportOnboardingScreen from './screens/Onboarding/PassportOnboardingScreen';
import UnsupportedPassportScreen from './screens/Onboarding/UnsupportedPassport';
import ProofRequestStatusScreen from './screens/ProveFlow/ProofRequestStatusScreen';
import ProveScreen from './screens/ProveFlow/ProveScreen';
import QRCodeTroubleScreen from './screens/ProveFlow/QRCodeTrouble';
import QRCodeViewFinderScreen from './screens/ProveFlow/ViewFinder';
import CloudBackupScreen from './screens/Settings/CloudBackupScreen';
import DevSettingsScreen from './screens/Settings/DevSettingsScreen';
@@ -40,6 +42,7 @@ import PassportDataInfoScreen from './screens/Settings/PassportDataInfoScreen';
import ShowRecoveryPhraseScreen from './screens/Settings/ShowRecoveryPhraseScreen';
import SettingsScreen from './screens/SettingsScreen';
import SplashScreen from './screens/SplashScreen';
import { ProofProvider } from './stores/proofProvider';
import analytics from './utils/analytics';
import { black, slate300, white } from './utils/colors';
@@ -68,6 +71,7 @@ const AppNavigation = createNativeStackNavigator({
screen: LaunchScreen,
options: {
headerShown: false,
gestureEnabled: false,
},
},
Modal: {
@@ -179,6 +183,23 @@ const AppNavigation = createNativeStackNavigator({
// presentation: 'modal',
},
},
QRCodeTrouble: {
screen: QRCodeTroubleScreen,
options: {
headerShown: false,
animation: 'slide_from_bottom',
presentation: 'modal',
},
},
PassportDataNotFound: {
screen: PassportDataNotFound,
options: {
headerShown: false,
gestureEnabled: false,
animation: 'slide_from_bottom',
// presentation: 'modal',
},
},
ProveScreen: {
screen: ProveScreen,
options: {
@@ -330,7 +351,9 @@ const NavigationWithTracking = () => {
return (
<GestureHandlerRootView>
<Navigation ref={navigationRef} onStateChange={trackScreen} />
<ProofProvider>
<Navigation ref={navigationRef} onStateChange={trackScreen} />
</ProofProvider>
</GestureHandlerRootView>
);
};

View File

@@ -11,7 +11,7 @@ import { PrimaryButton } from './PrimaryButton';
type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
const ACTION_TIMER = 1000; // time in ms
const ACTION_TIMER = 600; // time in ms
//slate400 to slate800 but in rgb
const COLORS: RGBA[] = ['rgba(30, 41, 59, 0.3)', 'rgba(30, 41, 59, 1)'];
export function HeldPrimaryButton({
@@ -33,12 +33,13 @@ export function HeldPrimaryButton({
};
const onPressOut = () => {
setHasTriggered(false);
Animated.timing(animation, {
toValue: 0,
duration: ACTION_TIMER,
useNativeDriver: true,
}).start();
if (!hasTriggered) {
Animated.timing(animation, {
toValue: 0,
duration: ACTION_TIMER,
useNativeDriver: true,
}).start();
}
};
const getButtonSize = (e: LayoutChangeEvent) => {

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { PrimaryButton } from '../../components/buttons/PrimaryButton';
import Description from '../../components/typography/Description';
import { Title } from '../../components/typography/Title';
import useHapticNavigation from '../../hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '../../layouts/ExpandableBottomLayout';
import { black, slate200, white } from '../../utils/colors';
const PassportDataNotFound: React.FC = () => {
const onPress = useHapticNavigation('Launch');
return (
<ExpandableBottomLayout.Layout backgroundColor={black}>
<ExpandableBottomLayout.TopSection backgroundColor={black}>
<Title textAlign="center" style={{ color: white }}>
Are you new here?
</Title>
<Description mt={8} textAlign="center" style={{ color: slate200 }}>
It seems like you need to go through the registration flow first.
</Description>
</ExpandableBottomLayout.TopSection>
<ExpandableBottomLayout.BottomSection
gap={20}
height={150}
backgroundColor={white}
>
<PrimaryButton onPress={onPress}>Go to Registration</PrimaryButton>
</ExpandableBottomLayout.BottomSection>
</ExpandableBottomLayout.Layout>
);
};
export default PassportDataNotFound;

View File

@@ -83,12 +83,21 @@ const ProveScreen: React.FC = () => {
return (selectedApp?.disclosures as SelfAppDisclosureConfig) || [];
}, [selectedApp?.disclosures]);
// Format the base64 image string correctly
// Format the logo source based on whether it's a URL or base64 string
const logoSource = useMemo(() => {
if (!selectedApp?.logoBase64) {
return null;
}
// Ensure the base64 string has the correct data URI prefix
// Check if the logo is already a URL
if (
selectedApp.logoBase64.startsWith('http://') ||
selectedApp.logoBase64.startsWith('https://')
) {
return { uri: selectedApp.logoBase64 };
}
// Otherwise handle as base64 as before
const base64String = selectedApp.logoBase64.startsWith('data:image')
? selectedApp.logoBase64
: `data:image/png;base64,${selectedApp.logoBase64}`;
@@ -122,11 +131,14 @@ const ProveScreen: React.FC = () => {
timeToNavigateToStatusScreen = setTimeout(() => {
navigate('ProofRequestStatusScreen');
}, 1000);
}, 200);
if (!passportData || !secret) {
console.log('No passport data or secret');
globalSetDisclosureStatus?.(ProofStatusEnum.ERROR);
setTimeout(() => {
navigate('PassportDataNotFound');
}, 3000);
return;
}
@@ -186,6 +198,7 @@ const ProveScreen: React.FC = () => {
contentSize.height - paddingToBottom;
if (isCloseToBottom && !hasScrolledToBottom) {
setHasScrolledToBottom(true);
buttonTap();
}
},
[hasScrolledToBottom, isContentShorterThanScrollView],
@@ -221,7 +234,13 @@ const ProveScreen: React.FC = () => {
) : (
<YStack alignItems="center" justifyContent="center">
{logoSource && (
<Image mb={20} source={logoSource} width={100} height={100} />
<Image
mb={20}
source={logoSource}
width={100}
height={100}
objectFit="contain"
/>
)}
<BodyText fontSize={12} color={slate300} mb={20}>
{url}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import Tips, { TipProps } from '../../components/Tips';
import { Caption } from '../../components/typography/Caption';
import useHapticNavigation from '../../hooks/useHapticNavigation';
import SimpleScrolledTitleLayout from '../../layouts/SimpleScrolledTitleLayout';
import { slate500 } from '../../utils/colors';
const tips: TipProps[] = [
{
title: 'Ensure Valid QR Code',
body: "Make sure you're scanning a QR code from a supported Self partner application.",
},
{
title: 'Try Different Distances',
body: 'If scanning fails, try moving your device closer to the QR code or increase the size of the QR code on the screen.',
},
{
title: 'Proper Lighting',
body: "Ensure there's adequate lighting in the room. QR codes need good contrast to be read properly.",
},
{
title: 'Hold Steady',
body: 'Keep your device steady while scanning to prevent blurry images that might not scan correctly.',
},
{
title: 'Clean Lens',
body: 'Make sure your camera lens is clean and free of smudges or debris that could interfere with scanning.',
},
];
const tipsDeeplink: TipProps[] = [
{
title: 'Coming from another app/website?',
body: 'Please contact the support, a telegram group is available in the options menu.',
},
];
const QRCodeTrouble: React.FC = () => {
const go = useHapticNavigation('Home', { action: 'cancel' });
return (
<SimpleScrolledTitleLayout
title="Having trouble scanning the QR code?"
onDismiss={go}
>
<Caption size="large" color={slate500}>
Here are some tips to help you successfully scan the QR code:
</Caption>
<Tips items={tips} />
<Tips items={tipsDeeplink} />
</SimpleScrolledTitleLayout>
);
};
export default QRCodeTrouble;

View File

@@ -49,6 +49,8 @@ const QRCodeViewFinderScreen: React.FC<QRCodeViewFinderScreenProps> = ({}) => {
const { setSelectedApp, cleanSelfApp } = useProofInfo();
const [doneScanningQR, setDoneScanningQR] = useState(false);
const { startAppListener } = useApp();
const navigateToProveScreen = useHapticNavigation('ProveScreen');
const onCancelPress = useHapticNavigation('Home');
// This resets to the default state when we navigate back to this screen
useFocusEffect(
@@ -64,26 +66,31 @@ const QRCodeViewFinderScreen: React.FC<QRCodeViewFinderScreenProps> = ({}) => {
}
if (error) {
console.error(error);
navigation.navigate('QRCodeTrouble');
} else {
setDoneScanningQR(true);
const encodedData = parseUrlParams(uri!);
const sessionId = encodedData.get('sessionId');
if (!sessionId) {
console.error('No sessionId found in QR code');
const selfApp = encodedData.get('selfApp');
if (selfApp) {
const selfAppJson = JSON.parse(selfApp);
setSelectedApp(selfAppJson);
startAppListener(selfAppJson.sessionId, setSelectedApp);
setTimeout(() => {
navigateToProveScreen();
}, 100);
} else if (sessionId) {
cleanSelfApp();
startAppListener(sessionId, setSelectedApp);
setTimeout(() => {
navigateToProveScreen();
}, 100);
} else {
console.error('No sessionId or selfApp found in QR code');
setDoneScanningQR(false); // Reset to allow another scan attempt
navigation.navigate('QRCodeTrouble');
return;
}
// TODO (_): cleaning here makes sense, clean app should set the disclosure states to default too
// Clean up first
cleanSelfApp();
// Start the app listener and wait a moment for the connection
startAppListener(sessionId, setSelectedApp);
// Small delay to ensure the websocket connection is established
setTimeout(() => {
navigation.navigate('ProveScreen');
}, 100);
}
},
[
@@ -92,9 +99,9 @@ const QRCodeViewFinderScreen: React.FC<QRCodeViewFinderScreenProps> = ({}) => {
startAppListener,
cleanSelfApp,
setSelectedApp,
navigateToProveScreen,
],
);
const onCancelPress = useHapticNavigation('Home', { action: 'cancel' });
const shouldRenderCamera = !connectionModalVisible && !doneScanningQR;

View File

@@ -8,6 +8,8 @@ import React, {
} from 'react';
import { SelfApp } from '../../../common/src/utils/appType';
import { navigationRef } from '../Navigation';
import { useApp } from '../stores/appProvider';
import { setupUniversalLinkListener } from '../utils/qrCodeNew';
import { usePassport } from './passportDataProvider';
@@ -73,6 +75,8 @@ export function ProofProvider({ children }: PropsWithChildren<{}>) {
defaults.selectedApp,
);
const { startAppListener } = useApp();
const setSelectedApp = useCallback((app: SelfApp) => {
if (!app || Object.keys(app).length === 0) {
return;
@@ -92,6 +96,24 @@ export function ProofProvider({ children }: PropsWithChildren<{}>) {
setDisclosureStatus(ProofStatusEnum.PENDING);
}, []);
const handleNavigateToProveScreen = useCallback(() => {
if (navigationRef.isReady()) {
navigationRef.navigate('ProveScreen');
} else {
console.log("Navigation not ready yet, couldn't navigate to ProveScreen");
}
}, []);
const handleNavigateToQRCodeTrouble = useCallback(() => {
if (navigationRef.isReady()) {
navigationRef.navigate('QRCodeTrouble');
} else {
console.log(
"Navigation not ready yet, couldn't navigate to QRCodeTrouble",
);
}
}, []);
useEffect(() => {
globalSetRegistrationStatus = setRegistrationStatus;
globalSetDisclosureStatus = setDisclosureStatus;
@@ -103,12 +125,24 @@ export function ProofProvider({ children }: PropsWithChildren<{}>) {
useEffect(() => {
if (passportData && secret) {
const universalLinkCleanup = setupUniversalLinkListener(setSelectedApp);
const universalLinkCleanup = setupUniversalLinkListener(
setSelectedApp,
cleanSelfApp,
startAppListener,
handleNavigateToProveScreen,
handleNavigateToQRCodeTrouble,
);
return () => {
universalLinkCleanup();
};
}
}, [passportData, secret, setSelectedApp]);
}, [
setSelectedApp,
cleanSelfApp,
startAppListener,
handleNavigateToProveScreen,
handleNavigateToQRCodeTrouble,
]);
const publicApi: IProofContext = useMemo(
() => ({

View File

@@ -1,60 +1,101 @@
import { Linking } from 'react-native';
import { decode } from 'msgpack-lite';
import { inflate } from 'pako';
import queryString from 'query-string';
import { SelfApp } from '../../../common/src/utils/appType';
export default async function handleQRCodeScan(
result: string,
setApp: (app: SelfApp) => void,
) {
/**
* Decodes a URL-encoded string.
* @param {string} encodedUrl
* @returns {string}
*/
const decodeUrl = (encodedUrl: string): string => {
try {
const decodedResult = atob(result);
const uint8Array = new Uint8Array(
decodedResult.split('').map(char => char.charCodeAt(0)),
);
const decompressedData = inflate(uint8Array);
const unpackedData = decode(decompressedData);
const openPassportApp: SelfApp = unpackedData;
setApp(openPassportApp);
console.log('✅', {
message: 'QR code scanned',
customData: {
type: 'success',
},
});
return decodeURIComponent(encodedUrl);
} catch (error) {
console.error('Error parsing QR code result:', error);
console.log('Try again', {
message: 'Error reading QR code: ' + (error as Error).message,
customData: {
type: 'error',
},
});
}
}
const handleUniversalLink = (url: string, setApp: (app: SelfApp) => void) => {
const encodedData = new URL(url).searchParams.get('data');
console.log('Encoded data:', encodedData);
if (encodedData) {
handleQRCodeScan(encodedData, setApp);
} else {
console.error('No data found in the Universal Link');
console.error('Error decoding URL:', error);
return encodedUrl;
}
};
export const setupUniversalLinkListener = (setApp: (app: SelfApp) => void) => {
const handleQRCodeData = (
uri: string,
setApp: (app: SelfApp) => void,
cleanSelfApp: () => void,
startAppListener: (sessionId: string, setApp: (app: SelfApp) => void) => void,
onNavigationNeeded?: () => void,
onErrorCallback?: () => void,
) => {
const decodedUri = decodeUrl(uri);
const encodedData = queryString.parseUrl(decodedUri).query;
const sessionId = encodedData.sessionId;
const selfAppStr = encodedData.selfApp as string | undefined;
if (selfAppStr) {
try {
const selfAppJson = JSON.parse(selfAppStr);
setApp(selfAppJson);
if (onNavigationNeeded) {
setTimeout(() => {
onNavigationNeeded();
}, 100);
}
return;
} catch (error) {
console.error('Error parsing selfApp:', error);
if (onErrorCallback) {
onErrorCallback();
}
}
}
if (sessionId && typeof sessionId === 'string') {
cleanSelfApp();
startAppListener(sessionId, setApp);
if (onNavigationNeeded) {
setTimeout(() => {
onNavigationNeeded();
}, 100);
}
} else {
console.error('No sessionId or selfApp found in the data');
if (onErrorCallback) {
onErrorCallback();
}
}
};
export const setupUniversalLinkListener = (
setApp: (app: SelfApp) => void,
cleanSelfApp: () => void,
startAppListener: (sessionId: string, setApp: (app: SelfApp) => void) => void,
onNavigationNeeded?: () => void,
onErrorCallback?: () => void,
) => {
Linking.getInitialURL().then(url => {
if (url) {
handleUniversalLink(url, setApp);
handleQRCodeData(
url,
setApp,
cleanSelfApp,
startAppListener,
onNavigationNeeded,
onErrorCallback,
);
}
});
const linkingEventListener = Linking.addEventListener('url', ({ url }) => {
handleUniversalLink(url, setApp);
handleQRCodeData(
url,
setApp,
cleanSelfApp,
startAppListener,
onNavigationNeeded,
onErrorCallback,
);
});
return () => {