Skip to content

Commit

Permalink
feat: [WLEO-218] Add Remote Presentation Cross-Device to Example App (#…
Browse files Browse the repository at this point in the history
…178)

Co-authored-by: LazyAfternoons <[email protected]>
  • Loading branch information
manuraf and LazyAfternoons authored Jan 28, 2025
1 parent 0774386 commit 631c9cc
Show file tree
Hide file tree
Showing 8 changed files with 372 additions and 3 deletions.
4 changes: 2 additions & 2 deletions example/ios/Podfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions example/src/navigator/MainStackNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import SettingsScreen from "../screens/SettingsScreen";
import { WalletInstanceScreen } from "../screens/WalletInstanceScreen";
import { setDebugVisibility } from "../store/reducers/debug";
import { useAppDispatch } from "../store/utils";
import { PresentationScreen } from "../screens/PresentationScreen";
import { QrScannerScreen } from "../screens/QrScannerScreen";

/**
* MainStackNav parameters list for each defined screen.
Expand All @@ -28,6 +30,8 @@ export type MainStackNavParamList = {
authUrl: string;
redirectUri: string;
};
Presentations: undefined;
QrScanner: undefined;
};

const Stack = createNativeStackNavigator<MainStackNavParamList>();
Expand Down Expand Up @@ -88,6 +92,16 @@ export const MainStackNavigator = () => {
component={SettingsScreen}
options={{ title: "Settings" }}
/>
<Stack.Screen
name="Presentations"
component={PresentationScreen}
options={{ title: "Presentation" }}
/>
<Stack.Screen
name="QrScanner"
component={QrScannerScreen}
options={{ title: "Scan QR" }}
/>
</Stack.Group>
</Stack.Navigator>
</NavigationContainer>
Expand Down
13 changes: 12 additions & 1 deletion example/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { selectHasInstanceKeyTag } from "../store/reducers/instance";

import { useAppSelector } from "../store/utils";
import { selectSesssionId } from "../store/reducers/sesssion";
import { selectPid } from "../store/reducers/pid";

type ModuleSummaryProps = ComponentProps<typeof ModuleSummary>;

Expand All @@ -24,6 +25,7 @@ const HomeScreen = () => {
const navigation = useNavigation();
const hasIntegrityKeyTag = useAppSelector(selectHasInstanceKeyTag);
const session = useAppSelector(selectSesssionId);
const pid = useAppSelector(selectPid);

useDebugInfo({
session,
Expand Down Expand Up @@ -70,8 +72,17 @@ const HomeScreen = () => {
icon: "chevronRight",
onPress: () => navigation.navigate("Settings"),
},
{
label: "Presentations",
description: "Present credential",
icon: "chevronRight",
onPress: () =>
pid
? navigation.navigate("Presentations")
: Alert.alert("Obtain a PID first"),
},
],
[hasIntegrityKeyTag, navigation]
[hasIntegrityKeyTag, navigation, pid]
);

return (
Expand Down
63 changes: 63 additions & 0 deletions example/src/screens/PresentationScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useMemo } from "react";
import { useNavigation } from "@react-navigation/native";
import { useAppSelector } from "../store/utils";
import TestScenario, {
type TestScenarioProp,
} from "../components/TestScenario";
import { selectPresentationAsyncStatus } from "../store/reducers/presentation";

import { useDebugInfo } from "../hooks/useDebugInfo";
import { IOVisualCostants, VSpacer } from "@pagopa/io-app-design-system";
import { FlatList } from "react-native";

export const PresentationScreen = () => {
const navigation = useNavigation();
const presentationState = useAppSelector(selectPresentationAsyncStatus);

useDebugInfo({
presentationState,
});

const scenarios: Array<TestScenarioProp> = useMemo(
() => [
{
title: "PID Remote Cross-Device",
onPress: () => navigation.navigate("QrScanner"),
isLoading: presentationState.isLoading,
hasError: presentationState.hasError,
isDone: presentationState.isDone,
icon: "device",
},
],
[
navigation,
presentationState.hasError,
presentationState.isDone,
presentationState.isLoading,
]
);

return (
<FlatList
contentContainerStyle={{
margin: IOVisualCostants.appMarginDefault,
}}
data={scenarios}
keyExtractor={(item, index) => `${item.title}-${index}`}
renderItem={({ item }) => (
<>
<TestScenario
onPress={item.onPress}
title={item.title}
isLoading={item.isLoading}
hasError={item.hasError}
isDone={item.isDone}
icon={item.icon}
isPresent={item.isPresent}
/>
<VSpacer />
</>
)}
/>
);
};
73 changes: 73 additions & 0 deletions example/src/screens/QrScannerScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from "react";
import { Text, View, Alert, StyleSheet } from "react-native";
import {
Camera,
useCameraDevice,
useCodeScanner,
} from "react-native-vision-camera";
import { useAppDispatch } from "../store/utils";
import { useNavigation } from "@react-navigation/native";
// Thunk or action you want to dispatch
import { remoteCrossDevicePresentationThunk } from "../thunks/presentation";

export const QrScannerScreen = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation();
const [hasPermission, setHasPermission] = useState(false);

const device = useCameraDevice("back");

// 3. Ask for camera permission on mount
useEffect(() => {
(async () => {
const cameraPermission = await Camera.requestCameraPermission();
if (cameraPermission.toString() === "granted") {
setHasPermission(true);
} else {
Alert.alert("Error", "Camera permission not granted!");
}
})();
}, []);

const codeScanner = useCodeScanner({
codeTypes: ["qr", "ean-13"],
onCodeScanned: (codes) => {
dispatch(
remoteCrossDevicePresentationThunk({ qrcode: codes[0]?.value || "" })
);

navigation.goBack();
},
});

if (!device) {
return (
<View>
<Text>Camera not available!</Text>
</View>
);
}

return (
<View>
{hasPermission ? (
<Camera
style={style.camera}
device={device}
isActive={true} // optionally disable camera after scanning
codeScanner={codeScanner}
audio={false}
/>
) : (
<Text>Camera permission not granted!</Text>
)}
</View>
);
};

const style = StyleSheet.create({
camera: {
width: 500,
height: 500,
},
});
97 changes: 97 additions & 0 deletions example/src/store/reducers/presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createSlice } from "@reduxjs/toolkit";

import { remoteCrossDevicePresentationThunk as remoteCrossDevicePresentationThunk } from "../../thunks/presentation";
import { persistReducer, type PersistConfig } from "redux-persist";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { asyncStatusInitial } from "../utils";
import type { RootState, AsyncStatus } from "../types";
import { sessionReset } from "./sesssion";

/**
* State type definition for the presentation slice
* the state contains:
* - async state: isLoading, isDone, hasError as defined in {@link AsyncStatus}
*/
type PresentationState = {
redirectUri: string | undefined;
asyncStatus: AsyncStatus;
};

// Initial state for the presentation slice
const initialState: PresentationState = {
redirectUri: undefined,
asyncStatus: asyncStatusInitial,
};

/**
* Redux slice for the presentation state which contains the key tag used to register the wallet presentation.
*/
const presentationSlice = createSlice({
name: "presentation",
initialState,
reducers: {
presentationReset: () => initialState,
},
extraReducers: (builder) => {
// Dispatched when is created. Sets the key tag in the state and its state to isDone while resetting isLoading and hasError.
builder.addCase(
remoteCrossDevicePresentationThunk.fulfilled,
(state, action) => {
state.redirectUri = action.payload.result.redirect_uri;
state.asyncStatus.isDone = true;
state.asyncStatus.isLoading = initialState.asyncStatus.isLoading;
state.asyncStatus.hasError = initialState.asyncStatus.hasError;
}
);

// Dispatched when is pending. Sets the state to isLoading and resets isDone and hasError.
builder.addCase(remoteCrossDevicePresentationThunk.pending, (state) => {
state.asyncStatus.isDone = false;
state.asyncStatus.isLoading = true;
state.asyncStatus.hasError = initialState.asyncStatus.hasError;
});

// Dispatched when is rejected. Sets the state to hasError and resets isLoading and isDone.
builder.addCase(
remoteCrossDevicePresentationThunk.rejected,
(state, action) => {
state.asyncStatus.isDone = initialState.asyncStatus.isDone;
state.asyncStatus.isLoading = initialState.asyncStatus.isLoading;
state.asyncStatus.hasError = { status: true, error: action.error };
}
);

// Reset the attestation state when the session is reset.
builder.addCase(sessionReset, () => initialState);
},
});

/**
* Exports the actions for the presentation slice.
*/
export const { presentationReset } = presentationSlice.actions;

/**
* Configuration for the presentation slice to be persisted in the Redux store.
* Only the keyTag is persisted to avoid regenerating the wallet presentation at each app launch.
*/
const persistConfig: PersistConfig<PresentationState> = {
key: "presentation",
storage: AsyncStorage,
};

/**
* Persisted reducer for the presentation slice.
*/
export const presentationReducer = persistReducer(
persistConfig,
presentationSlice.reducer
);

/**
* Selects the presentation state from the root state.
* @param state - The root state of the Redux store
* @returns The presentation state as {@link AsyncStatus}
*/
export const selectPresentationAsyncStatus = (state: RootState) =>
state.presentation.asyncStatus;
2 changes: 2 additions & 0 deletions example/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { attestationReducer } from "./reducers/attestation";
import { credentialReducer } from "./reducers/credential";
import { debugSlice } from "./reducers/debug";
import { pidReducer } from "./reducers/pid";
import { presentationReducer } from "./reducers/presentation";

/**
* Redux store configuration.
Expand All @@ -26,6 +27,7 @@ export const store = configureStore({
attestation: attestationReducer,
credential: credentialReducer,
pid: pidReducer,
presentation: presentationReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
Expand Down
Loading

0 comments on commit 631c9cc

Please sign in to comment.