From 193c02bb5a4d29a648a20c8d5f9295dfa132cb19 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Fri, 13 Sep 2024 17:37:49 +0200 Subject: [PATCH 01/12] Trigger EAS build from main (#53) When merging to main, trigger a preview build. Co-authored-by: Pete Edwards --- .github/workflows/cd.yml | 25 +++++++++++++++++++++++++ .github/workflows/ci.yml | 15 +++++++-------- 2 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/cd.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..cd33776 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,25 @@ +name: Inrupt Data Wallet CD + +on: + push: + branches: [ main ] + +env: + CI: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + - run: npm ci + - run: npm install -g eas-cli + - run: eas build --platform all --profile preview --message "SNAPSHOT build" + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EAS_PROJECT_ID: ${{ secrets.EAS_PROJECT_ID }} + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e72ecf8..2f75dab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Wallet Frontend Application CI +name: Inrupt Data Wallet CI on: pull_request: {} @@ -15,22 +15,21 @@ jobs: lint: uses: inrupt/typescript-sdk-tools/.github/workflows/reusable-lint.yml@v3 - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ ubuntu-latest, windows-latest ] - node-version: [ "20.x" ] + unit-test: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm - run: npm ci - run: npm run test:snapshot sonarqube: name: run sonarqube if: ${{ github.actor != 'dependabot[bot]' }} - needs: [build] + needs: [unit-test] runs-on: ubuntu-latest steps: - name: Checking out From 91183ce895e19f76a6e307ef894339eab3b8a081 Mon Sep 17 00:00:00 2001 From: Quan Vo <45813912+quanvo298Wizeline@users.noreply.github.com> Date: Mon, 16 Sep 2024 18:30:06 +0700 Subject: [PATCH 02/12] Add Notification when Permission does not grant (#57) Co-authored-by: Pete Edwards --- app/scan-qr.tsx | 64 +++++++++++++++++++++++++++++----------- components/PopupMenu.tsx | 20 ++++++++++++- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/app/scan-qr.tsx b/app/scan-qr.tsx index 1f89e64..98026b0 100644 --- a/app/scan-qr.tsx +++ b/app/scan-qr.tsx @@ -13,14 +13,22 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { View, StyleSheet, Dimensions, TouchableOpacity } from "react-native"; +import { + View, + StyleSheet, + Dimensions, + Alert, + TouchableOpacity, +} from "react-native"; import { useNavigation, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { useError } from "@/hooks/useError"; -import { ThemedText } from "@/components/ThemedText"; import type { BarcodeScanningResult } from "expo-camera"; -import { Camera, CameraView } from "expo-camera"; +import { CameraView, Camera } from "expo-camera"; import { isAccessPromptQR, isDownloadQR } from "@/types/accessPrompt"; +import * as Linking from "expo-linking"; +import { PermissionStatus } from "expo-image-picker"; +import { ThemedText } from "@/components/ThemedText"; const { width } = Dimensions.get("window"); @@ -34,19 +42,38 @@ export default function Logout() { const { goBack } = useNavigation(); const { showErrorMsg } = useError(); const { replace, navigate } = useRouter(); - const [scanned, setScanned] = useState(false); + const [showScanned, setShowScanned] = useState(false); + const handleDeniedPermissions = () => { + Alert.alert( + "Permission Denied", + "Camera access is required. Please enable it in your device settings.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => Linking.openSettings(), // This will open the app settings page + }, + ], + { cancelable: false } + ); + }; + useEffect(() => { const getCameraPermissions = async () => { - await Camera.requestCameraPermissionsAsync(); + const { status } = await Camera.requestCameraPermissionsAsync(); + if (status !== PermissionStatus.GRANTED) { + handleDeniedPermissions(); + return; + } + setShowScanned(true); }; - getCameraPermissions().catch(() => - // eslint-disable-next-line no-console - console.log("Don't have camera permission") - ); + getCameraPermissions().catch(() => { + console.log("Don't have camera permission"); + }); }, []); const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => { - setScanned(true); + setShowScanned(false); try { const resourceInfo = JSON.parse(data); if (isAccessPromptQR(resourceInfo)) { @@ -95,15 +122,16 @@ export default function Logout() { - + {showScanned && ( + + )} - Scan a QR code diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 12766bc..5dd8112 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -14,7 +14,7 @@ // limitations under the License. // import React, { useRef } from "react"; -import { Dimensions, StyleSheet, TouchableOpacity } from "react-native"; +import { Dimensions, StyleSheet, TouchableOpacity, Alert } from "react-native"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; import { PermissionStatus } from "expo-image-picker"; @@ -26,6 +26,7 @@ import { faImage } from "@fortawesome/free-solid-svg-icons/faImage"; import { faFile } from "@fortawesome/free-solid-svg-icons/faFile"; import { faCamera } from "@fortawesome/free-solid-svg-icons/faCamera"; import { faQrcode } from "@fortawesome/free-solid-svg-icons/faQrcode"; +import * as Linking from "expo-linking"; import { ThemedText } from "./ThemedText"; const { width } = Dimensions.get("window"); @@ -80,9 +81,25 @@ const PopupMenu: React.FC = ({ break; } + const handleDeniedPermissions = (note: string) => { + Alert.alert( + "Permission Denied", + `${note}. Please enable it in your device settings.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Open Settings", + onPress: () => Linking.openSettings(), // This will open the app settings page + }, + ], + { cancelable: false } + ); + }; + const takePicture = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + handleDeniedPermissions("Camera access is required to take photos"); return; } const result = await ImagePicker.launchCameraAsync({ @@ -111,6 +128,7 @@ const PopupMenu: React.FC = ({ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + handleDeniedPermissions("Image Library access is required"); return; } const result = await ImagePicker.launchImageLibraryAsync(); From d0defde19b27612154831b37117a5914b3508481 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Mon, 16 Sep 2024 14:30:13 +0200 Subject: [PATCH 03/12] Replace CD workflow with EAS GH integration (#61) --- .github/workflows/cd.yml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/cd.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index cd33776..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Inrupt Data Wallet CD - -on: - push: - branches: [ main ] - -env: - CI: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - cache: npm - - run: npm ci - - run: npm install -g eas-cli - - run: eas build --platform all --profile preview --message "SNAPSHOT build" - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - EAS_PROJECT_ID: ${{ secrets.EAS_PROJECT_ID }} - From f028e460b3f0306a0b37168400ed8c3be48bf4b7 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Mon, 16 Sep 2024 15:14:49 +0200 Subject: [PATCH 04/12] Add missing field for EAS Github integration (#62) --- eas.json | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/eas.json b/eas.json index 79a0447..193cc5d 100644 --- a/eas.json +++ b/eas.json @@ -1,6 +1,6 @@ { "cli": { - "version": "12.3.0", + "version": "^12.3.0", "appVersionSource": "remote", "requireCommit": true }, @@ -18,6 +18,12 @@ "env": { "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" + }, + "android": { + "image": "latest" + }, + "ios": { + "image": "latest" } }, "preview-ios": { @@ -26,6 +32,12 @@ "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" }, + "android": { + "image": "latest" + }, + "ios": { + "image": "latest" + }, "credentialsSource": "local" }, "production": { @@ -33,6 +45,12 @@ "env": { "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" + }, + "android": { + "image": "latest" + }, + "ios": { + "image": "latest" } } }, From 0db0c45db0419e43c3da8ea76bb83ecf14867571 Mon Sep 17 00:00:00 2001 From: Kyra Assaad <11729241+KyraAssaad@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:21:16 -0400 Subject: [PATCH 05/12] Remove iOS specific build profile (#63) Since we now store the Apple credentials in Expo itself, we don't need a separate build profile that uses local credential storage. --- eas.json | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/eas.json b/eas.json index 193cc5d..3ffa063 100644 --- a/eas.json +++ b/eas.json @@ -26,20 +26,6 @@ "image": "latest" } }, - "preview-ios": { - "distribution": "internal", - "env": { - "EXPO_PUBLIC_LOGIN_URL" : "https://datawallet.inrupt.com/oauth2/authorization/wallet-app", - "EXPO_PUBLIC_WALLET_API": "https://datawallet.inrupt.com" - }, - "android": { - "image": "latest" - }, - "ios": { - "image": "latest" - }, - "credentialsSource": "local" - }, "production": { "autoIncrement": true, "env": { From 65e5538b4761c4ead5da8e9190ad7cb8108a23b9 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Mon, 16 Sep 2024 16:58:57 +0200 Subject: [PATCH 06/12] Align slug with project name (#64) --- app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.config.ts b/app.config.ts index d987a95..69eda2b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -51,7 +51,7 @@ if (process.env.EAS_BUILD === "true") { const baseConfig: ExpoConfig = { name: "inrupt-data-wallet", - slug: "inrupt-data-wallet-frontend", + slug: "inrupt-data-wallet", version: "1.0.0", orientation: "portrait", icon: "./assets/images/logo.png", From 575e403caf0774a33059f56eb9cb8085f408a814 Mon Sep 17 00:00:00 2001 From: Quan Vo <45813912+quanvo298Wizeline@users.noreply.github.com> Date: Tue, 17 Sep 2024 21:23:50 +0700 Subject: [PATCH 07/12] Fix/wallet 500 download as copy (#60) * Use Blob data to download a copy * Show error if file can not upload to wallet --- api/apiRequest.ts | 7 ++- api/files.ts | 10 ++++ app/access-prompt/confirmed.tsx | 2 +- components/PopupMenu.tsx | 7 +++ components/error/ErrorPopup.tsx | 6 +-- components/files/BottomModal.tsx | 80 +++++++++++++++++++++++--------- components/files/FileList.tsx | 2 +- 7 files changed, 85 insertions(+), 29 deletions(-) diff --git a/api/apiRequest.ts b/api/apiRequest.ts index 5987eb1..d75cd55 100644 --- a/api/apiRequest.ts +++ b/api/apiRequest.ts @@ -23,7 +23,8 @@ export const makeApiRequest = async ( endpoint: string, method: string = "GET", body: unknown = null, - contentType: string | null = "application/json" + contentType: string | null = "application/json", + isBlob: boolean = false ): Promise => { const session = await SecureStore.getItemAsync(SESSION_KEY); if (!session) return {} as T; @@ -61,6 +62,10 @@ export const makeApiRequest = async ( ); } + if (isBlob) { + return (await response.blob()) as unknown as T; + } + const responseType = response.headers.get("content-type"); if ( responseType?.includes("application/json") || diff --git a/api/files.ts b/api/files.ts index ab12076..176e565 100644 --- a/api/files.ts +++ b/api/files.ts @@ -99,3 +99,13 @@ export const getFile = async (fileId: string): Promise => { "GET" ); }; + +export const downloadFile = async (fileId: string): Promise => { + return makeApiRequest( + `wallet/${encodeURIComponent(fileId)}?raw=true`, + "GET", + null, + null, + true + ); +}; diff --git a/app/access-prompt/confirmed.tsx b/app/access-prompt/confirmed.tsx index 7410bd2..6bd092f 100644 --- a/app/access-prompt/confirmed.tsx +++ b/app/access-prompt/confirmed.tsx @@ -83,7 +83,7 @@ const Page: React.FC = () => { router.replace("/")} + onPress={() => router.replace("/requests")} customStyle={styles.button} /> diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 5dd8112..085d2ef 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -26,6 +26,7 @@ import { faImage } from "@fortawesome/free-solid-svg-icons/faImage"; import { faFile } from "@fortawesome/free-solid-svg-icons/faFile"; import { faCamera } from "@fortawesome/free-solid-svg-icons/faCamera"; import { faQrcode } from "@fortawesome/free-solid-svg-icons/faQrcode"; +import { useError } from "@/hooks/useError"; import * as Linking from "expo-linking"; import { ThemedText } from "./ThemedText"; @@ -52,13 +53,19 @@ const PopupMenu: React.FC = ({ }) => { const router = useRouter(); const menuRef = useRef(null); + const { showErrorMsg } = useError(); const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: postFile, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["files"] }); }, + onError: (error) => { + console.debug("A non-HTTP error occurred.", error); + showErrorMsg("Unable to save the file into your Wallet."); + }, mutationKey: ["filesMutation"], }); if (!visible) return null; diff --git a/components/error/ErrorPopup.tsx b/components/error/ErrorPopup.tsx index 81a8f2a..6cb59ee 100644 --- a/components/error/ErrorPopup.tsx +++ b/components/error/ErrorPopup.tsx @@ -41,8 +41,8 @@ const styles = StyleSheet.create({ errorPopup: { position: "absolute", bottom: 80, - left: 24, - right: 24, + left: 16, + right: 16, backgroundColor: "white", justifyContent: "space-between", alignItems: "center", @@ -61,7 +61,7 @@ const styles = StyleSheet.create({ }, closeView: { alignItems: "center", - paddingLeft: 24, + paddingLeft: 8, paddingRight: 16, }, }); diff --git a/components/files/BottomModal.tsx b/components/files/BottomModal.tsx index fd15907..9c6dafe 100644 --- a/components/files/BottomModal.tsx +++ b/components/files/BottomModal.tsx @@ -13,13 +13,18 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import React, { useState } from "react"; -import { View, StyleSheet, TouchableOpacity } from "react-native"; +import React, { useCallback, useEffect, useState } from "react"; +import { + View, + StyleSheet, + TouchableOpacity, + ActivityIndicator, +} from "react-native"; import { BottomSheetView } from "@gorhom/bottom-sheet"; import { FontAwesome6 } from "@expo/vector-icons"; import { Colors } from "@/constants/Colors"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { deleteFile, getFile } from "@/api/files"; +import { deleteFile, downloadFile } from "@/api/files"; import * as Sharing from "expo-sharing"; import * as FileSystem from "expo-file-system"; import QRCode from "react-native-qrcode-svg"; @@ -43,6 +48,7 @@ const BottomModal: React.FC = ({ onChangeSnapPoint, }) => { const [modalVisible, setModalVisible] = useState(false); + const [fileDownload, setFileDownload] = useState(null); const { data: userInfo } = useQuery({ queryKey: ["userInfo"], staleTime: Infinity, @@ -53,27 +59,46 @@ const BottomModal: React.FC = ({ onSuccess: () => queryClient.refetchQueries({ queryKey: ["files"] }), mutationKey: ["filesMutation"], }); - const { data } = useQuery({ - queryKey: ["file", file?.fileName], - queryFn: () => getFile(file?.fileName as string), - }); const queryClient = useQueryClient(); - const onFileShare = async (fileName: string) => { - if (!data) return; - const fr = new FileReader(); - fr.onload = async () => { - const fileUri = `${FileSystem.documentDirectory}/${fileName}`; - if (typeof fr.result !== "string") { - throw new Error("An error happened while reading the file."); - } - await FileSystem.writeAsStringAsync(fileUri, fr.result?.split(",")[1], { - encoding: FileSystem.EncodingType.Base64, + const onFileShare = useCallback( + async (fileName: string) => { + const data = await queryClient.fetchQuery({ + queryKey: ["downloadFile", fileName], + queryFn: () => downloadFile(fileName), }); - await Sharing.shareAsync(fileUri); - }; - fr.readAsDataURL(data); - }; + + setFileDownload(null); + + if (!data) return; + const fr = new FileReader(); + fr.onload = async () => { + const fileUri = new URL(fileName, FileSystem.documentDirectory!); + if (typeof fr.result !== "string") { + throw new Error("An error occurred while reading the file."); + } + await FileSystem.writeAsStringAsync( + fileUri.toString(), + fr.result.split(",")[1], + { + encoding: FileSystem.EncodingType.Base64, + } + ); + await Sharing.shareAsync(fileUri.toString()); + }; + fr.readAsDataURL(data); + }, + [queryClient] + ); + + useEffect(() => { + if (fileDownload) { + onFileShare(fileDownload).catch(() => + console.log("Error while sharing data") + ); + } + }, [fileDownload, onFileShare]); + const onDeleteFile = async (fileName: string) => { deleteMutation .mutateAsync(fileName) @@ -108,6 +133,8 @@ const BottomModal: React.FC = ({ )} {formatResourceName( @@ -139,12 +166,19 @@ const BottomModal: React.FC = ({ file && onFileShare(file?.fileName)} + onPress={() => file && setFileDownload(file?.fileName)} > - + Download a copy + {Boolean(fileDownload) && ( + + )} Date: Tue, 17 Sep 2024 22:08:56 +0100 Subject: [PATCH 08/12] Add support section to README and other updates (#55) * Add support section to README and reorganize * Fix installation instruction --------- Co-authored-by: Nicolas Ayral Seydoux --- README.md | 201 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 125 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index a0fe459..2a0edcc 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,50 @@ -# Data Wallet Front-end Application +# Data Wallet Application -This project produces a front-end react native application for use with the Inrupt Data Wallet. +This project produces a front-end react native application for use with the Inrupt Data Wallet service. This README provides information on: -* [Setup](#setup) +* [Setup and configuration](#setup-and-configuration) * [Prerequisites](#prerequisites) * [Install dependencies](#install-dependencies) * [Configure build environment](#configure-build-environment) - * [Create keystore](#create-keystore) -* [Running](#running) - * [Configure test environment](#configure-test-environment) - * [Test native versions](#test-native-versions) - * [iOS app](#ios-app) - * [Android app](#android-app) + * [Create keystore for Android](#create-keystore-for-android) + * [Make the keystore available to CI](#make-the-keystore-available-to-ci) +* [Running the application locally](#running-the-application-locally) + * [On a device with Expo Go](#on-a-device-with-expo-go) + * [On an Android emulator](#on-an-android-emulator) + * [On an iOS simulator](#on-an-ios-simulator) +* [Build the app on EAS](#build-the-app-on-eas) +* [Testing with Detox](#testing-with-detox) + * [On Android](#on-android) + * [On iOS](#on-ios) * [UI overview](#ui-overview) * [Home](#home) * [Profile](#profile) * [Requests](#requests) * [Access](#access) +* [Issues & Help](#issues--help) + * [Bugs and Feature Requests](#bugs-and-feature-requests) + * [Documentation](#documentation) + * [Changelog](#changelog) + * [License](#license) - -## Build the app locally +## Setup and configuration ### Prerequisites Ensure that you have the following dependencies installed and configured: -- Xcode -- iOS simulators + +- [Expo Go](https://expo.dev/go) - app to be installed on a real device +- [Xcode](https://apps.apple.com/us/app/xcode/id497799835) +- [iOS simulators](https://developer.apple.com/documentation/safari-developer-tools/installing-xcode-and-simulators) - [Android emulators](https://developer.android.com/studio/install) ### Install dependencies -First, install any react native dependencies. +First, install all the project dependencies. ```bash -npm install +npm ci ``` ### Configure build environment @@ -47,7 +57,29 @@ EXPO_PUBLIC_LOGIN_URL= EXPO_PUBLIC_WALLET_API= ``` -#### Create keystore +Automated testing also requires access credentials for the Data Wallet: +```bash +TEST_ACCOUNT_USERNAME= +TEST_ACCOUNT_PASSWORD= +``` + +For testing with iOS simulators, you will need: +```bash +IOS_BINARY_PATH= +TEST_IOS_SIM= +``` + +For testing with Android emulators, you will need: +```bash +ANDROID_BINARY_PATH= +ANDROID_TEST_BINARY_PATH= +TEST_ANDROID_EMU= +``` +Ensure that the ``TEST_ANDROID_EMU`` configuration aligns with a device emulator in Android Studio. +For example: ``TEST_ANDROID_EMU=Android_34``. If the emulated device is called "Test Emulator 33", +the EMU name will be ``Test_Emulator_33``. + +#### Create keystore for Android The Android app will need to be signed for it to be installed. See: https://reactnative.dev/docs/signed-apk-android for more information on this. @@ -55,7 +87,8 @@ for more information on this. __WARNING:__ The following is only to be used for development. In production the keystore should be generated and configured in a more secure manner. -To generate a keystore with a key and cert run the following from the root of the project: +To generate a keystore with a key and cert run the following from the root of the project (this requires a Java JDK +installation): ```bash keytool -genkeypair -v -storetype PKCS12 \ @@ -73,59 +106,46 @@ KEYSTORE_PASSWORD= #### Make the keystore available to CI In order to make the keystore available to CI, it has to be present in the repository secret. -To - Encrypting the keystore with a GPG key to get a Base64 representation: `gpg -c --armor wallet.keystore` -- Create Github repository secrets: +- Create GitHub repository secrets: - ENCRYPTED_KEYSTORE with the Base64-encoded encrypted keystore - KEYSTORE_DECRYPTION_KEY with the GPG key - KEYSTORE_PASSWORD with the keystore password - In CI, decrypt the keystore back: `gpg -d --passphrase "..." --batch wallet.keystore.asc > wallet.keystore` -## Build the app on EAS - -Both the Android and the iOS versions of the app can be built using the Expo Application Service (EAS). To do so, follow -the instructions from the [EAS documentation](https://docs.expo.dev/build/setup/). - -Note that the project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. -In order to trigger an EAS build, please add the appropriate project ID in your environment, e.g. - -``` -EAS_PROJECT_ID="..." eas build --platform android --local --profile preview -``` - -## Running the application - -If you are going to run the application in an emulator or simulator, you need to build the development version using -one of the following: - ```bash - npm run android - npm run ios - ``` +## Running the application locally Start the application: - ```bash - npx expo start - ``` +```bash +npx expo start +``` In the output, you'll find options to open the app in a - -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo. +- [Development build](https://docs.expo.dev/develop/development-builds/introduction/) - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo -Note: When running on the android emulator, there is a special loopback IP, 10.0.2.2, which points to the host machine 127.0.0.1. You can use it if the emulator complains about cleartext communication to the local network IP of the host. +### On a device with Expo Go + +Press ``s`` to switch to the Expo Go environment. This will display a QR code which you will need to scan from your +device's Expo Go application. -## Running the UI-based tests +The Wallet application will then build and install into your device for you to test & debug. -### Configure test environment +### On an Android emulator -The tests require access credentials for a Pod which will be used by this instance of the wallet. -Make a copy of the provided `.env.sample` named `.env`, and replace placeholders with actual values -specific to your setup. +For Android, ensure that a virtual device has been added to the Android emulator by opening the new Device Manager with +the following actions: +* Go to the Android Studio Welcome screen +* Select **More Actions > Virtual Device Manager.** -### Running the tests on iOS +```bash +npm run android +``` +Note: When running on the android emulator, there is a special loopback IP, 10.0.2.2, which points to the host machine 127.0.0.1. You can use it if the emulator complains about cleartext communication to the local network IP of the host. +### On an iOS simulator To build the iOS wallet app in an iOS simulator, just run the following command: @@ -135,40 +155,26 @@ npm run ios This will install the necessary CocoaPods and compile the application. Upon completion, the iOS simulator should be open and the wallet app running. -If you want to generate the iOS binary as well, run the following commands. Note that this process may take around 30 minutes to complete. +## Build the app on EAS -```bash -npx expo run:ios -xcodebuild -workspace ios/inruptwalletfrontend.xcworkspace -scheme inruptwalletfrontend -configuration Release -sdk iphonesimulator -derivedDataPath ios/build -``` +Both the Android and the iOS versions of the app can be built using the Expo Application Service (EAS). To do so, follow +the instructions from the [EAS documentation](https://docs.expo.dev/build/setup/). -After completion, the iOS binary should be located at: +Note that the project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. +In order to trigger an EAS build, please add the appropriate project ID in your environment, e.g. -```bash -inrupt-data-wallet/ios/build/Build/Products/inruptwalletfrontend.app ``` - -You can share the .app file with others who need to run the Detox tests without building the iOS app locally. - -Execute the command below to start Detox test on iOS. -```bash -npx detox test --configuration=ios.sim.release +EAS_PROJECT_ID="..." eas build --platform android --local --profile preview ``` -### Running the tests on Android +## Testing with Detox -Ensure that a virtual device has been added to the Android emulator. - -First, you'll need to generate the app metadata with the following command: - -```bash -npx expo prebuild --platform android -``` +### On Android Run the following command to build the Android app. This process may take up to 30 minutes to complete. ```bash -npx detox build --configuration android.emu.release +npx detox build --configuration=android.emu.release ``` For local development (a back-end server running on localhost or at an alternative location), use @@ -178,7 +184,7 @@ development server (`npx expo start`). Once built, run the detox tests for the relevant configuration: ```bash -npx detox test --configuration android.emu.release +npx detox test --configuration=android.emu.release ``` After completion, the binary apps will be located in: @@ -188,6 +194,27 @@ After completion, the binary apps will be located in: You can share the .apk files with others who need to run the Detox tests without building the Android app locally. +### On iOS + +If you want to generate the iOS binary, run the following commands. Note that this process may take around 30 minutes to complete. + +```bash +xcodebuild -workspace ios/inruptwalletfrontend.xcworkspace -scheme inruptwalletfrontend -configuration Release -sdk iphonesimulator -derivedDataPath ios/build +``` + +After completion, the iOS binary should be located at: + +```bash +inrupt-data-wallet/ios/build/Build/Products/inruptwalletfrontend.app +``` + +You can share the .app file with others who need to run the Detox tests without building the iOS app locally. + +Execute the command below to start Detox test on iOS. +```bash +npx detox test --configuration=ios.sim.release +``` + ## UI overview Upon execution, the application prompts the user to log in. After successful authentication, the wallet app presents various views, located in the `app/(tabs)` directory: @@ -219,3 +246,25 @@ From here, the user can view all the requests along with their details (requesto This page shows what access has been granted to other agents. For each authorized agent, there is a list of the wallet resources to which they have access. Users also have the option to revoke access to each resource from this page. + +## Issues & Help + +### Bugs and Feature Requests + +- For public feedback, bug reports, and feature requests please file an issue + via [Github](https://github.com/inrupt/inrupt-data-wallet/issues/). +- For non-public feedback or support inquiries please use the + [Inrupt Service Desk](https://inrupt.atlassian.net/servicedesk). + +### Documentation + +- [Inrupt Data Wallet](https://docs.inrupt.com/data-wallet/) +- [Homepage](https://docs.inrupt.com/) + +### Changelog + +See [the release notes](https://github.com/inrupt/inrupt-data-wallet/releases). + +### License + +Apache 2.0 © [Inrupt](https://inrupt.com) From e25ab0872e03ed4d0622c81b46a6c4e398032963 Mon Sep 17 00:00:00 2001 From: Zwifi Date: Thu, 19 Sep 2024 10:23:41 +0200 Subject: [PATCH 09/12] Clarify the EAS instructions (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clarify the EAS instructions * Add instructions to run on own EAS² * Apply feedback to PR #55 * Link to Podspaces * Make prerequisites more specific --- README.md | 61 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2a0edcc..4fee8b6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Data Wallet Application +# Inrupt Data Wallet -This project produces a front-end react native application for use with the Inrupt Data Wallet service. +This project produces a mobile React Native application for use with the Inrupt Data Wallet service. This README provides information on: * [Setup and configuration](#setup-and-configuration) @@ -32,11 +32,29 @@ This README provides information on: ### Prerequisites +In order to log into the Wallet, and for it to be able to persist data, you will need a [Podspaces Account](https://start.inrupt.com). Ensure that you have the following dependencies installed and configured: -- [Expo Go](https://expo.dev/go) - app to be installed on a real device +##### On the mobile device + +- [Expo Go](https://expo.dev/go) - app to be installed on a real device (iOS and Android supported) + +##### On the dev machine (iOS) + +If you are only running the app in Expo Go: +- [NodeJS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + +If you are building the app locally, you'll also need: - [Xcode](https://apps.apple.com/us/app/xcode/id497799835) - [iOS simulators](https://developer.apple.com/documentation/safari-developer-tools/installing-xcode-and-simulators) + +#### On the dev machine (Android) + +If you are only running the app in Expo Go: +- [NodeJS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) + +If you are building the app locally, you'll also need: +- A Java JDK - [Android emulators](https://developer.android.com/studio/install) ### Install dependencies @@ -103,16 +121,6 @@ KEYSTORE_PATH=/inrupt-data-wallet/android/app/wallet.keystore KEYSTORE_PASSWORD= ``` -#### Make the keystore available to CI - -In order to make the keystore available to CI, it has to be present in the repository secret. -- Encrypting the keystore with a GPG key to get a Base64 representation: `gpg -c --armor wallet.keystore` -- Create GitHub repository secrets: - - ENCRYPTED_KEYSTORE with the Base64-encoded encrypted keystore - - KEYSTORE_DECRYPTION_KEY with the GPG key - - KEYSTORE_PASSWORD with the keystore password -- In CI, decrypt the keystore back: `gpg -d --passphrase "..." --batch wallet.keystore.asc > wallet.keystore` - ## Running the application locally Start the application: @@ -129,8 +137,8 @@ In the output, you'll find options to open the app in a ### On a device with Expo Go -Press ``s`` to switch to the Expo Go environment. This will display a QR code which you will need to scan from your -device's Expo Go application. +After expo has started, make sure it targets the Expo Go environment (as opposed to "development build"). +This will display a QR code which you will need to scan from your device's Expo Go application. The Wallet application will then build and install into your device for you to test & debug. @@ -145,9 +153,10 @@ the following actions: npm run android ``` Note: When running on the android emulator, there is a special loopback IP, 10.0.2.2, which points to the host machine 127.0.0.1. You can use it if the emulator complains about cleartext communication to the local network IP of the host. + ### On an iOS simulator -To build the iOS wallet app in an iOS simulator, just run the following command: +To build the iOS wallet app in an iOS simulator, run the following command: ```bash npm run ios @@ -155,13 +164,23 @@ npm run ios This will install the necessary CocoaPods and compile the application. Upon completion, the iOS simulator should be open and the wallet app running. -## Build the app on EAS +## Build the app on the Expo Application Service (EAS) + +**Prerequisite**: All the following instructions are only applicable if you have an EAS account. By default, +the project is configured to be built on an Inrupt EAS project. In order to build a custom version in your +own EAS account, you'll need to create a new EAS project, and make sure that EAS project and the data (e.g. +`name`, `slug`, `project-id`...) in your copy of `app.config.ts` are aligned. + +Both the Android and the iOS versions of the app can be built using EAS. To do so, follow the instructions +from the [EAS documentation](https://docs.expo.dev/build/setup/) to setup your environment. -Both the Android and the iOS versions of the app can be built using the Expo Application Service (EAS). To do so, follow -the instructions from the [EAS documentation](https://docs.expo.dev/build/setup/). +By default, the EAS CLI will trigger a build on the EAS infrastructure. It is also possible to +[run a build locally](https://docs.expo.dev/build-reference/local-builds/), which results in the built binary +being available on the developer machine. This requires all the environment setup for being able to build +the app locally, similar to `npx expo run:`. -Note that the project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. -In order to trigger an EAS build, please add the appropriate project ID in your environment, e.g. +The project ID is not hard-coded in the `app.config.ts`, but provided as an environment variable instead. +In order to trigger an EAS build, please add the appropriate project ID in your environment variables, e.g. ``` EAS_PROJECT_ID="..." eas build --platform android --local --profile preview From 129b215b2d9bd3a12f7f026b377e871cb08426ac Mon Sep 17 00:00:00 2001 From: Quan Vo <45813912+quanvo298Wizeline@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:04:59 +0700 Subject: [PATCH 10/12] WALLET-486: Fix timeout sending user back to login screen (#66) * The issue occurred because the useQuery hook was running a fetch request before the WebView was ready after the app moved from inactive to active. As a result, cookies were not available for the fetch request, causing a 401 error. I moved the useQuery API call to a useEffect hook to ensure that all components are rendered before the fetch occurs. * Show ActivityIndicator while login page is loading in Webview * Make sure logout trigger in useEffect --------- Co-authored-by: Pete Edwards --- api/apiRequest.ts | 2 +- app/(tabs)/_layout.tsx | 69 ++----------------------- app/(tabs)/accesses/index.test.tsx | 6 ++- app/(tabs)/accesses/index.tsx | 10 ++++ app/(tabs)/home/index.test.tsx | 6 ++- app/(tabs)/home/index.tsx | 11 +++- app/(tabs)/profile.test.tsx | 6 ++- app/(tabs)/profile.tsx | 13 ++++- app/(tabs)/requests/index.test.tsx | 6 ++- app/(tabs)/requests/index.tsx | 9 ++++ app/_layout.tsx | 57 +++++++++++---------- app/index.tsx | 71 +++++++++++++++++++++++++- app/login.tsx | 33 +++++++----- app/scan-qr.tsx | 2 +- components/PopupMenu.tsx | 7 ++- components/login/LoginWebViewModal.tsx | 43 +++++++++++----- hooks/loadingContext.tsx | 52 ------------------- hooks/useInruptLogin.tsx | 48 ++++++++++++----- 18 files changed, 254 insertions(+), 197 deletions(-) delete mode 100644 hooks/loadingContext.tsx diff --git a/api/apiRequest.ts b/api/apiRequest.ts index d75cd55..8667ab7 100644 --- a/api/apiRequest.ts +++ b/api/apiRequest.ts @@ -50,7 +50,7 @@ export const makeApiRequest = async ( ); if (response.status === 401) { - router.replace("/login?logout=true"); + router.replace(`/login?logout=${Date.now()}`); throw new Error(`Unauthorized: ${response.status}`); } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 9fae3cb..d1826f7 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -14,9 +14,9 @@ // limitations under the License. // -import { Redirect, Tabs } from "expo-router"; +import { Tabs } from "expo-router"; import type { MutableRefObject } from "react"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import HouseOutLine from "@/assets/images/house-outline.svg"; import AccessOutLine from "@/assets/images/access-outline.svg"; import AccessSolid from "@/assets/images/access-solid.svg"; @@ -25,64 +25,19 @@ import { FontAwesome6 } from "@expo/vector-icons"; import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"; import { faBell } from "@fortawesome/free-solid-svg-icons/faBell"; import { faUser } from "@fortawesome/free-solid-svg-icons/faUser"; -import { useSession } from "@/hooks/session"; -import { - Dimensions, - StyleSheet, - TouchableOpacity, - View, - Image, -} from "react-native"; -// import { Image } from "expo-image"; +import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native"; import PopupMenu from "@/components/PopupMenu"; import { ThemedText } from "@/components/ThemedText"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { getUserInfo, signup } from "@/api/user"; -import type { UserInfo } from "@/constants/user"; const { width, height } = Dimensions.get("window"); export default function TabLayout() { - const [isShowSplash, setIsShowSplash] = useState(true); - - const { session, signOut } = useSession(); - const { - isFetching, - data: userInfo, - refetch, - } = useQuery({ - queryKey: ["userInfo"], - queryFn: getUserInfo, - enabled: !!session, - }); - - const signupMutation = useMutation({ - mutationFn: signup, - onSuccess: async () => { - setIsShowSplash(false); - await refetch(); - }, - onError: () => { - setIsShowSplash(true); - }, - }); - const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const [positionType, setPositionType] = useState< "topMiddle" | "bottomLeft" | "bottomMiddle" >("bottomMiddle"); - useEffect(() => { - if (userInfo && userInfo.signupRequired && session) { - signupMutation.mutateAsync().catch((error) => { - console.error("Error while signup", error); - signOut(); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userInfo, session]); - const tabBarAddButtonRef = useRef(null); const rightHeaderAddButtonRef = useRef(null); const toggleMenu = ( @@ -120,24 +75,6 @@ export default function TabLayout() { } }; - if (!session) { - return ; - } - - if (isFetching || (userInfo!.signupRequired && isShowSplash)) { - return ( - - {!isFetching && ( - - )} - - ); - } - return ( <> { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: AccessGrant[] ): ReturnType { @@ -33,7 +37,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/accesses/index.tsx b/app/(tabs)/accesses/index.tsx index 9efc417..e75e988 100644 --- a/app/(tabs)/accesses/index.tsx +++ b/app/(tabs)/accesses/index.tsx @@ -19,6 +19,7 @@ import { StyleSheet, View } from "react-native"; import WebIdAccessGrantList from "@/components/accessGrants/WebIdAccessGrantList"; import useRefreshOnFocus from "@/hooks/useRefreshOnFocus"; import type { AccessGrantGroup } from "@/types/accessGrant"; +import { useEffect } from "react"; export default function AccessGrantScreen() { const { @@ -29,7 +30,16 @@ export default function AccessGrantScreen() { } = useQuery({ queryKey: ["accessGrants"], queryFn: getAccessGrants, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.log(err)); + return () => { + console.debug("Unmount AccessGrantScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); return ( diff --git a/app/(tabs)/home/index.test.tsx b/app/(tabs)/home/index.test.tsx index c09a879..6bf349f 100644 --- a/app/(tabs)/home/index.test.tsx +++ b/app/(tabs)/home/index.test.tsx @@ -41,6 +41,10 @@ jest.mock("expo-router", () => { }; }); +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: WalletFile[] ): ReturnType { @@ -48,7 +52,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx index 3058e9c..68f11a9 100644 --- a/app/(tabs)/home/index.tsx +++ b/app/(tabs)/home/index.tsx @@ -16,7 +16,7 @@ import CustomButton from "@/components/Button"; import PopupMenu from "@/components/PopupMenu"; import { ThemedText } from "@/components/ThemedText"; -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { View, StyleSheet, TouchableOpacity } from "react-native"; import { useQuery } from "@tanstack/react-query"; import { fetchFiles } from "@/api/files"; @@ -29,7 +29,16 @@ const HomeScreen = () => { const { data, isLoading, isFetching, refetch } = useQuery({ queryKey: ["files"], queryFn: fetchFiles, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount HomeScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); const [menuVisible, setMenuVisible] = useState(false); diff --git a/app/(tabs)/profile.test.tsx b/app/(tabs)/profile.test.tsx index 477392e..db5bd7f 100644 --- a/app/(tabs)/profile.test.tsx +++ b/app/(tabs)/profile.test.tsx @@ -28,12 +28,16 @@ import ProfileScreen from "./profile"; const { useSession } = SessionHooks; +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: {}, status: "success" }); +}); + function mockUseQuery(data: UserInfo): ReturnType { return { data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 948cffb..cb89b25 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -28,13 +28,22 @@ import { BottomSheetModal, BottomSheetView } from "@gorhom/bottom-sheet"; import type { UserInfo } from "@/constants/user"; import AccessSolid from "@/assets/images/access-solid.svg"; import { formatResourceName } from "@/utils/fileUtils"; +import { getUserInfo } from "@/api/user"; export default function Profile() { - const { data: userInfo } = useQuery({ + const { data: userInfo, refetch } = useQuery({ queryKey: ["userInfo"], + queryFn: getUserInfo, enabled: false, }); + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount Profile"); + }; + }, [refetch]); + const bottomSheetModalRef = useRef(null); const navigation = useNavigation(); @@ -115,7 +124,7 @@ export default function Profile() { style={styles.logoutContainer} onPress={() => { bottomSheetModalRef.current?.close(); - router.navigate("/login?logout=true"); + router.navigate(`/login?logout=${Date.now()}`); }} > diff --git a/app/(tabs)/requests/index.test.tsx b/app/(tabs)/requests/index.test.tsx index 0c9683a..e1b6755 100644 --- a/app/(tabs)/requests/index.test.tsx +++ b/app/(tabs)/requests/index.test.tsx @@ -26,6 +26,10 @@ import type { AccessRequest } from "@/types/accessRequest"; import { AccessRequestMode } from "@/types/enums"; import RequestsScreen from "./index"; +const mockRefetch = jest.fn().mockImplementation(() => { + return Promise.resolve({ data: [], status: "success" }); +}); + function mockUseQuery( data: AccessRequest[] ): ReturnType { @@ -33,7 +37,7 @@ function mockUseQuery( data, isLoading: false, isFetching: false, - refetch: jest.fn["refetch"]>(), + refetch: mockRefetch, } as unknown as ReturnType; } diff --git a/app/(tabs)/requests/index.tsx b/app/(tabs)/requests/index.tsx index d018857..1d2dd3c 100644 --- a/app/(tabs)/requests/index.tsx +++ b/app/(tabs)/requests/index.tsx @@ -32,7 +32,16 @@ export default function AccessRequestScreen() { } = useQuery({ queryKey: ["accessRequests"], queryFn: getAccessRequests, + enabled: false, }); + + useEffect(() => { + refetch().catch((err) => console.error(err)); + return () => { + console.debug("Unmount AccessRequestScreen"); + }; + }, [refetch]); + useRefreshOnFocus(refetch); const navigation = useNavigation("/(tabs)"); diff --git a/app/_layout.tsx b/app/_layout.tsx index 1e5f2aa..f961eae 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -18,14 +18,13 @@ import { DefaultTheme, ThemeProvider } from "@react-navigation/native"; import { useFonts } from "expo-font"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { QueryClientProvider, QueryClient, focusManager, } from "@tanstack/react-query"; -import { SessionProvider } from "@/hooks/session"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import type { AppStateStatus } from "react-native"; @@ -40,9 +39,13 @@ SplashScreen.preventAutoHideAsync(); const queryClient = new QueryClient(); export default function RootLayout() { + const [appStateStatus, setAppStateStatus] = + useState("active"); + function onAppStateChange(status: AppStateStatus) { if (Platform.OS !== "web") { focusManager.setFocused(status === "active"); + setAppStateStatus(status); } } useEffect(() => { @@ -80,34 +83,32 @@ export default function RootLayout() { - - - - + + + + - - - - - - + /> + + + diff --git a/app/index.tsx b/app/index.tsx index d357d6d..8885960 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -13,11 +13,80 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import React from "react"; +import React, { useEffect, useState } from "react"; import { Redirect } from "expo-router"; +import { useLoginWebView } from "@/hooks/useInruptLogin"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { UserInfo } from "@/constants/user"; +import { getUserInfo, signup } from "@/api/user"; +import { Dimensions, Image, StyleSheet, View } from "react-native"; + +const { width, height } = Dimensions.get("window"); const HomeScreen = () => { + const [isShowSplash, setIsShowSplash] = useState(true); + const { isLoggedIn } = useLoginWebView(); + const loggedIn = isLoggedIn(); + + const { + isFetching, + data: userInfo, + refetch, + } = useQuery({ + queryKey: ["userInfo"], + queryFn: getUserInfo, + enabled: loggedIn, + }); + + const signupMutation = useMutation({ + mutationFn: signup, + onSuccess: async () => { + setIsShowSplash(false); + await refetch(); + }, + onError: () => { + setIsShowSplash(true); + }, + }); + + useEffect(() => { + if (userInfo && userInfo.signupRequired && loggedIn) { + signupMutation.mutateAsync().catch((error) => { + console.error("Error while signup", error); + }); + } + }, [userInfo, loggedIn, signupMutation]); + + if (!loggedIn) { + return ; + } + + if (isFetching || !userInfo || (userInfo.signupRequired && isShowSplash)) { + return ( + + {!isFetching && ( + + )} + + ); + } + return ; }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + image: { + width, + height, + resizeMode: "cover", + }, +}); + export default HomeScreen; diff --git a/app/login.tsx b/app/login.tsx index f8592c5..6482d22 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -22,27 +22,21 @@ import { clearWebViewIOSCache } from "react-native-webview-ios-cache-clear"; import Logo from "@/assets/images/future_co.svg"; import { useLoginWebView } from "@/hooks/useInruptLogin"; import { useLocalSearchParams } from "expo-router"; -import { useSession } from "@/hooks/session"; const isRunningInExpoGo = Constants.appOwnership === "expo"; const LoginScreen = () => { const { showLoginPage, requestLogout } = useLoginWebView(); const { logout } = useLocalSearchParams(); - const { session } = useSession(); - useEffect(() => { - if (session && logout) { - requestLogout(); - } - }, [logout, session, requestLogout]); - - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-var-requires,global-require - const RCTNetworking = require("react-native/Libraries/Network/RCTNetworking"); - RCTNetworking.default.clearCookies((result: never) => { - console.log("clearCookies", result); - }); + const clearCookies = () => { + import("react-native/Libraries/Network/RCTNetworking") + .then((RCTNetworking) => + RCTNetworking.default.clearCookies((result: never) => { + console.log("RCTNetworking:: clearCookies", result); + }) + ) + .catch((error) => console.log("Failed to clear cookies", error)); if (!isRunningInExpoGo) { clearWebViewIOSCache(); @@ -54,6 +48,17 @@ const LoginScreen = () => { ) .catch((error) => console.log("Failed to clear cookies", error)); } + }; + + useEffect(() => { + if (logout) { + clearCookies(); + requestLogout(); + } + }, [logout, requestLogout]); + + useEffect(() => { + clearCookies(); }, []); const handleLoginPress = () => { diff --git a/app/scan-qr.tsx b/app/scan-qr.tsx index 98026b0..b8fba33 100644 --- a/app/scan-qr.tsx +++ b/app/scan-qr.tsx @@ -38,7 +38,7 @@ class UnrecognisedQrCodeError extends Error { } } -export default function Logout() { +export default function ScanQr() { const { goBack } = useNavigation(); const { showErrorMsg } = useError(); const { replace, navigate } = useRouter(); diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 085d2ef..658f61b 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -106,13 +106,17 @@ const PopupMenu: React.FC = ({ const takePicture = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + onClose(); handleDeniedPermissions("Camera access is required to take photos"); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, }); - if (result.canceled || result.assets.length === 0) return; + if (result.canceled || result.assets.length === 0) { + onClose(); + return; + } const selectedPhoto = result.assets[0]; mutation.mutate({ uri: selectedPhoto.uri, @@ -135,6 +139,7 @@ const PopupMenu: React.FC = ({ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== PermissionStatus.GRANTED) { + onClose(); handleDeniedPermissions("Image Library access is required"); return; } diff --git a/components/login/LoginWebViewModal.tsx b/components/login/LoginWebViewModal.tsx index 6574167..50e6099 100644 --- a/components/login/LoginWebViewModal.tsx +++ b/components/login/LoginWebViewModal.tsx @@ -20,6 +20,8 @@ import { Text, StyleSheet, Modal, + ActivityIndicator, + View, } from "react-native"; import { WebView, type WebViewNavigation } from "react-native-webview"; import { DEFAULT_LOGIN_URL, DEFAULT_WALLET_API } from "@/constants/defaults"; @@ -28,23 +30,24 @@ interface LoginWebViewModalProps { onClose: () => void; onLoginSuccess: () => void; onLogoutSuccess: () => void; - requestMode: "login" | "logout" | "blank"; + requestMode: "login" | "logout" | "loginSuccess" | "blank"; } const LoginWebViewModal: React.FC = ({ - onClose = () => null, - onLoginSuccess = () => null, - onLogoutSuccess = () => null, + onClose, + onLoginSuccess, + onLogoutSuccess, requestMode = "blank", }) => { const BASE_URL = process.env.EXPO_PUBLIC_WALLET_API ?? DEFAULT_LOGIN_URL; const LOGIN_URL = process.env.EXPO_PUBLIC_LOGIN_URL ?? DEFAULT_WALLET_API; const LOGOUT_URL = `${BASE_URL}/logout`; + const LOGIN_SUCCESS_URL = `${BASE_URL}/login/success`; const webViewRef = useRef(null); const handleNavigationStateChange = ({ url }: WebViewNavigation) => { - if (url.includes("/login/success")) { + if (requestMode === "login" && url.includes("/login/success")) { onLoginSuccess(); } }; @@ -56,20 +59,22 @@ const LoginWebViewModal: React.FC = ({ }; const handleCloseModal = () => { - if (!webViewRef.current) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - webViewRef.current?.clearCache(true); + if (webViewRef.current) { + webViewRef.current!.clearCache(true); } onClose(); }; - if (requestMode === "logout") { + const isLogoutOrLoginSuccess = + requestMode === "logout" || requestMode === "loginSuccess"; + + if (isLogoutOrLoginSuccess) { + const sourceUri = requestMode === "logout" ? LOGOUT_URL : LOGIN_SUCCESS_URL; return ( = ({ domStorageEnabled={false} sharedCookiesEnabled thirdPartyCookiesEnabled - onLoadEnd={handleLoadEnd} + startInLoadingState={true} + renderLoading={() => ( + + + + )} /> Close diff --git a/hooks/loadingContext.tsx b/hooks/loadingContext.tsx deleted file mode 100644 index f32fa57..0000000 --- a/hooks/loadingContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// -// Copyright Inrupt Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -import type { ReactNode } from "react"; -import React, { createContext, useContext, useState } from "react"; - -// Define the context type -interface LoadingContextType { - isLoading: boolean; - setLoading: (loading: boolean) => void; -} - -// Create the context -const LoadingContext = createContext(undefined); - -// Create a provider component -export const LoadingProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const [isLoading, setIsLoading] = useState(false); - - const setLoading = (loading: boolean) => { - setIsLoading(loading); - }; - - return ( - - {children} - - ); -}; - -// Custom hook to use the loading context -export const useLoading = () => { - const context = useContext(LoadingContext); - if (!context) { - throw new Error("useLoading must be used within a LoadingProvider"); - } - return context; -}; diff --git a/hooks/useInruptLogin.tsx b/hooks/useInruptLogin.tsx index 64bf874..44bc75f 100644 --- a/hooks/useInruptLogin.tsx +++ b/hooks/useInruptLogin.tsx @@ -15,37 +15,47 @@ // import React, { createContext, useCallback, useState } from "react"; import LoginWebViewModal from "@/components/login/LoginWebViewModal"; -import { useSession } from "@/hooks/session"; import { router } from "expo-router"; +import type { AppStateStatus } from "react-native"; +import { useStorageState } from "@/hooks/useStorageState"; +import { SESSION_KEY } from "@/api/apiRequest"; +import { useQueryClient } from "@tanstack/react-query"; const LoginWebViewContext = createContext<{ showLoginPage: () => void; requestLogout: () => void; + isLoggedIn: () => boolean; + isActiveScreen: () => boolean; }>({ showLoginPage: () => null, requestLogout: () => null, + isLoggedIn: () => false, + isActiveScreen: () => false, }); -export const LoginWebViewProvider = ({ children }: React.PropsWithChildren) => { +export const LoginWebViewProvider = ({ + appStateStatus, + children, +}: React.PropsWithChildren & { appStateStatus: AppStateStatus }) => { const [modalRequestMode, setModalRequestMode] = useState< - "login" | "logout" | "blank" + "login" | "logout" | "loginSuccess" | "blank" >("blank"); - const { signIn, signOut } = useSession(); - const handleLoginSuccess = () => { - signIn(); - closeModal(); - router.replace("/home"); - }; + const [session, setSession] = useStorageState(SESSION_KEY); + const queryClient = useQueryClient(); - const handleLogoutSuccess = () => { - signOut(); - closeModal(); - router.replace("/login"); + const handleLoginSuccess = async () => { + setSession("true"); + setModalRequestMode("loginSuccess"); + router.replace("/"); }; - const closeModal = () => { + const handleLogoutSuccess = () => { + setSession(null); setModalRequestMode("blank"); + queryClient.removeQueries(); + queryClient.clear(); + router.replace("/login"); }; const showLoginPage = useCallback(() => { @@ -56,11 +66,21 @@ export const LoginWebViewProvider = ({ children }: React.PropsWithChildren) => { setModalRequestMode("logout"); }, []); + const isLoggedIn = useCallback(() => { + return !!session; + }, [session]); + + const isActiveScreen = useCallback(() => { + return appStateStatus === "active"; + }, [appStateStatus]); + return ( {children} From 9c0ce3ce2b3925778af7f46f0fb93426c2eeb870 Mon Sep 17 00:00:00 2001 From: Quan Vo <45813912+quanvo298Wizeline@users.noreply.github.com> Date: Thu, 19 Sep 2024 22:17:55 +0700 Subject: [PATCH 11/12] WALLET-501: Fix filename encoding on upload (#67) Encode FileName before upload --------- Co-authored-by: Nicolas Ayral Seydoux --- api/files.ts | 4 +++- components/files/FileList.tsx | 7 ++++++- utils/fileUtils.ts | 18 +++++++++--------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/api/files.ts b/api/files.ts index 176e565..80a7528 100644 --- a/api/files.ts +++ b/api/files.ts @@ -17,6 +17,7 @@ import mime from "mime"; import FormData from "form-data"; import type { WalletFile } from "@/types/WalletFile"; import { handleErrorResponse } from "@inrupt/solid-client-errors"; +import { utf8EncodeResourceName } from "@/utils/fileUtils"; import { makeApiRequest } from "./apiRequest"; interface FileObject { @@ -55,13 +56,14 @@ export const postFile = async (fileMetadata: FileObject): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const formData: any = new FormData(); formData.append("file", { - name: fileMetadata.name, + name: utf8EncodeResourceName(fileMetadata.name), type: fileMetadata.type || mime.getType(fileMetadata.name) || "application/octet-stream", uri: fileMetadata.uri, }); + formData.append("fileName", fileMetadata.name); let response: Response; try { response = await fetch( diff --git a/components/files/FileList.tsx b/components/files/FileList.tsx index 63924d5..1d4099e 100644 --- a/components/files/FileList.tsx +++ b/components/files/FileList.tsx @@ -90,7 +90,12 @@ const FileList: React.FC = ({ - onPressDetailedFile(item)}> + { + event.stopPropagation(); + onPressDetailedFile(item); + }} + > diff --git a/utils/fileUtils.ts b/utils/fileUtils.ts index 13bf314..b29d863 100644 --- a/utils/fileUtils.ts +++ b/utils/fileUtils.ts @@ -30,11 +30,6 @@ export const isImageFile = (filename: string): boolean => { return extension ? imageExtensions.includes(extension) : false; }; -export const getFileContainerByResource = (resource: string) => { - const match = resource.match(/\/([^/]+)\/?$/); - return match ? match[1] : ""; -}; - export const isDisplayDetailedPage = (file: WalletFile) => { return isImageFile(file.fileName) || file.isRDFResource; }; @@ -87,10 +82,15 @@ export const formatResourceName = ( return lastPart; }; -export const removeFileExtension = (resource: string) => { - const parts = resource.split("/"); - const lastPart = parts[parts.length - 1]; - return lastPart.split(".").slice(0, -1).join("."); +export const utf8EncodeResourceName = (input: string) => { + // encodeURIComponent() does not encode !'()*, so we manually do. This is + // required because these characters are allowed in resource names but not + // supported unencoded by the backend. For more details, see + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_content-disposition_and_link_headers + return encodeURIComponent(input).replace( + /[!'()*]/g, + (char) => `%${char.charCodeAt(0).toString(16)}` + ); }; export const isWriteMode = (modes: AccessRequestMode[]) => From 4765d041463b004f63f1b27a8db445a6dc43f1c5 Mon Sep 17 00:00:00 2001 From: Quan Vo <45813912+quanvo298Wizeline@users.noreply.github.com> Date: Fri, 20 Sep 2024 18:53:59 +0700 Subject: [PATCH 12/12] Fix/wallet 540 redirect to requests tab (#68) * Refresh file list after upload and delete * Fix layout * Fix layout * Lint fix --- app/(tabs)/_layout.tsx | 27 ++++++++++++++++--- app/(tabs)/home/index.tsx | 1 + components/PopupMenu.tsx | 10 +++---- .../accessRequests/AccessRequestList.tsx | 3 +++ components/files/FileList.tsx | 3 +++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index d1826f7..e9e93d6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -25,9 +25,17 @@ import { FontAwesome6 } from "@expo/vector-icons"; import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"; import { faBell } from "@fortawesome/free-solid-svg-icons/faBell"; import { faUser } from "@fortawesome/free-solid-svg-icons/faUser"; -import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native"; +import { + Dimensions, + StyleSheet, + TouchableOpacity, + View, + Platform, +} from "react-native"; import PopupMenu from "@/components/PopupMenu"; import { ThemedText } from "@/components/ThemedText"; +import { fetchFiles } from "@/api/files"; +import { useQueryClient } from "@tanstack/react-query"; const { width, height } = Dimensions.get("window"); @@ -37,7 +45,7 @@ export default function TabLayout() { const [positionType, setPositionType] = useState< "topMiddle" | "bottomLeft" | "bottomMiddle" >("bottomMiddle"); - + const queryClient = useQueryClient(); const tabBarAddButtonRef = useRef(null); const rightHeaderAddButtonRef = useRef(null); const toggleMenu = ( @@ -145,8 +153,13 @@ export default function TabLayout() { ref={tabBarAddButtonRef} onPress={() => toggleMenu("topMiddle", tabBarAddButtonRef)} > - - + + Add @@ -183,6 +196,12 @@ export default function TabLayout() { toggleMenu("topMiddle", rightHeaderAddButtonRef)} + onUploadSuccess={async () => { + await queryClient.fetchQuery({ + queryKey: ["files"], + queryFn: fetchFiles, + }); + }} position={menuPosition} positionType={positionType} /> diff --git a/app/(tabs)/home/index.tsx b/app/(tabs)/home/index.tsx index 68f11a9..43a394a 100644 --- a/app/(tabs)/home/index.tsx +++ b/app/(tabs)/home/index.tsx @@ -110,6 +110,7 @@ const HomeScreen = () => { setMenuVisible(false)} + onUploadSuccess={refetch} position={menuPosition} positionType={positionType} /> diff --git a/components/PopupMenu.tsx b/components/PopupMenu.tsx index 658f61b..0b5cdc8 100644 --- a/components/PopupMenu.tsx +++ b/components/PopupMenu.tsx @@ -18,7 +18,7 @@ import { Dimensions, StyleSheet, TouchableOpacity, Alert } from "react-native"; import * as DocumentPicker from "expo-document-picker"; import * as ImagePicker from "expo-image-picker"; import { PermissionStatus } from "expo-image-picker"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { postFile } from "@/api/files"; import { useRouter } from "expo-router"; import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"; @@ -34,6 +34,7 @@ const { width } = Dimensions.get("window"); type PopupMenuProps = { visible: boolean; onClose: () => void; + onUploadSuccess: () => void; position: { x: number | undefined; y: number | undefined }; positionType: "topMiddle" | "bottomLeft" | "bottomMiddle"; }; @@ -50,18 +51,15 @@ const PopupMenu: React.FC = ({ onClose, position, positionType, + onUploadSuccess, }) => { const router = useRouter(); const menuRef = useRef(null); const { showErrorMsg } = useError(); - const queryClient = useQueryClient(); - const mutation = useMutation({ mutationFn: postFile, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["files"] }); - }, + onSuccess: onUploadSuccess, onError: (error) => { console.debug("A non-HTTP error occurred.", error); showErrorMsg("Unable to save the file into your Wallet."); diff --git a/components/accessRequests/AccessRequestList.tsx b/components/accessRequests/AccessRequestList.tsx index ac12d48..a6ff140 100644 --- a/components/accessRequests/AccessRequestList.tsx +++ b/components/accessRequests/AccessRequestList.tsx @@ -43,6 +43,9 @@ const AccessRequestList: React.FC = ({ {data && data.length === 0 ? ( No active requests + + Refresh to check for new requests + ) : ( = ({ enablePanDownToClose > { + if (props.onRefresh) props.onRefresh(); + }} file={selectedFile} onCloseModal={() => bottomSheetModalRef.current?.close()} onChangeSnapPoint={(snapHeight: number) =>