Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [WLEO-218] Add Remote Presentation Cross-Device to Example App #178

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading