From 64dea90eb5ef147ce663134f60ac027e3e0f3030 Mon Sep 17 00:00:00 2001 From: Bryce McMath Date: Wed, 13 Aug 2025 08:29:11 -0700 Subject: [PATCH 01/17] feat: live call wip Signed-off-by: Bryce McMath --- app/android/app/src/main/AndroidManifest.xml | 7 + app/ios/AriesBifold.xcodeproj/project.pbxproj | 8 +- app/ios/AriesBifold/AppDelegate.mm | 4 + app/ios/Podfile.lock | 10 + app/package.json | 2 + .../bcsc-theme/api/hooks/useVideoCallApi.tsx | 176 +++++++++++++++++ .../VerificationMethodSelectionScreen.tsx | 30 ++- .../features/verify/VerifyIdentityStack.tsx | 2 + .../features/verify/live-call/AgentVideo.tsx | 13 ++ .../verify/live-call/BeforeYouCallScreen.tsx | 7 + .../verify/live-call/LiveCallScreen.tsx | 163 +++++++++++++++ .../features/verify/live-call/SelfieVideo.tsx | 14 ++ .../verify/live-call/StartCallScreen.tsx | 7 + .../verify/live-call/VerifyNotComplete.tsx | 7 + .../verify/live-call/hooks/useLiveCall.tsx | 0 .../verify/live-call/types/live-call.ts | 12 ++ .../verify/live-call/utils/connect.ts | 186 ++++++++++++++++++ app/src/bcsc-theme/types/navigators.ts | 2 + yarn.lock | 33 +++- 19 files changed, 669 insertions(+), 14 deletions(-) create mode 100644 app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/LiveCallScreen.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/SelfieVideo.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/StartCallScreen.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/VerifyNotComplete.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/hooks/useLiveCall.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/types/live-call.ts create mode 100644 app/src/bcsc-theme/features/verify/live-call/utils/connect.ts diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 24c61bced..b41de6f1f 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,12 @@ + + + + + + + diff --git a/app/ios/AriesBifold.xcodeproj/project.pbxproj b/app/ios/AriesBifold.xcodeproj/project.pbxproj index 7cbd6bc43..4b6907d9b 100644 --- a/app/ios/AriesBifold.xcodeproj/project.pbxproj +++ b/app/ios/AriesBifold.xcodeproj/project.pbxproj @@ -537,7 +537,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = AriesBifold/AriesBifold.entitlements; - CURRENT_PROJECT_VERSION = 444; + CURRENT_PROJECT_VERSION = 643; DEVELOPMENT_TEAM = L796QSLV3E; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -550,13 +550,13 @@ "$(inherited)", "$(SDKROOT)/usr/lib/swift", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 3.15.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.BCWallet; + PRODUCT_BUNDLE_IDENTIFIER = ca.bc.gov.iddev.servicescard; PRODUCT_NAME = BCWallet; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -589,7 +589,7 @@ "$(inherited)", "$(SDKROOT)/usr/lib/swift", ); - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 3.15.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/app/ios/AriesBifold/AppDelegate.mm b/app/ios/AriesBifold/AppDelegate.mm index 2403589d3..6fa01b30b 100644 --- a/app/ios/AriesBifold/AppDelegate.mm +++ b/app/ios/AriesBifold/AppDelegate.mm @@ -1,5 +1,6 @@ #import "AppDelegate.h" +#import #import #import #import @@ -11,6 +12,9 @@ @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // allows camera in background for video calling (WebRTC) + [WebRTCModuleOptions sharedInstance].enableMultitaskingCameraAccess = YES; + [FIRApp configure]; self.moduleName = @"BCWallet"; // You can add your custom initial props in the dictionary below. diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 9c5062c5e..2f0aeabae 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -153,6 +153,7 @@ PODS: - React - React-callinvoker - React-Core + - JitsiWebRTC (124.0.2) - libevent (2.1.12) - nanopb (2.30908.0): - nanopb/decode (= 2.30908.0) @@ -1070,6 +1071,9 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - react-native-webrtc (124.0.6): + - JitsiWebRTC (~> 124.0.0) + - React-Core - react-native-webview (13.10.7): - glog - RCT-Folly (= 2022.05.16.00) @@ -1349,6 +1353,7 @@ DEPENDENCIES: - react-native-splash-screen (from `../node_modules/react-native-splash-screen`) - react-native-tcp-socket (from `../node_modules/react-native-tcp-socket`) - react-native-video (from `../node_modules/react-native-video`) + - react-native-webrtc (from `../node_modules/react-native-webrtc`) - react-native-webview (from `../node_modules/react-native-webview`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -1404,6 +1409,7 @@ SPEC REPOS: - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities + - JitsiWebRTC - libevent - nanopb - PromisesObjC @@ -1498,6 +1504,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-tcp-socket" react-native-video: :path: "../node_modules/react-native-video" + react-native-webrtc: + :path: "../node_modules/react-native-webrtc" react-native-webview: :path: "../node_modules/react-native-webview" React-nativeconfig: @@ -1601,6 +1609,7 @@ SPEC CHECKSUMS: GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 hermes-engine: d992945b77c506e5164e6a9a77510c9d57472c59 indy-vdr: aada31078a9ed270dd618fadb4cf69bcdc333d68 + JitsiWebRTC: b47805ab5668be38e7ee60e2258f49badfe8e1d0 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 @@ -1637,6 +1646,7 @@ SPEC CHECKSUMS: react-native-splash-screen: 95994222cc95c236bd3cdc59fe45ed5f27969594 react-native-tcp-socket: ae8abcfebc071216302a09d9ed1e375d4e877484 react-native-video: d74d94fbaeee3c0d8f6570173289f43fe210066f + react-native-webrtc: 96fdff9e3a942ed88cafe01898da1c93fd628957 react-native-webview: f802f655c8446404bb0c134da9335a8cf667e8cb React-nativeconfig: 8fd29a35a3e4e8c37682d976667663d834ba6165 React-NativeModulesApple: 83d7077877f8eda8e1b6055b3f8f16f7db8463b5 diff --git a/app/package.json b/app/package.json index f33e9ebaf..145d49938 100644 --- a/app/package.json +++ b/app/package.json @@ -68,6 +68,7 @@ "@hyperledger/aries-askar-react-native": "0.2.3", "@hyperledger/indy-vdr-react-native": "0.2.2", "@hyperledger/indy-vdr-shared": "0.2.2", + "@pexip/infinity-api": "^19.1.2", "@react-native-async-storage/async-storage": "~1.22.3", "@react-native-clipboard/clipboard": "~1.16.3", "@react-native-community/netinfo": "~11.3.3", @@ -127,6 +128,7 @@ "react-native-vector-icons": "~10.0.3", "react-native-video": "~6.16.1", "react-native-vision-camera": "~4.3.2", + "react-native-webrtc": "~124.0.6", "react-native-webview": "~13.10.7", "reflect-metadata": "~0.1.14", "rxjs": "~7.8.2", diff --git a/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx b/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx new file mode 100644 index 000000000..15f3e4870 --- /dev/null +++ b/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx @@ -0,0 +1,176 @@ +import { useCallback, useMemo } from 'react' +import { useStore } from '@bifold/core' +import apiClient from '../client' +import { withAccount } from './withAccountGuard' +import { createEvidenceRequestJWT } from 'react-native-bcsc-core' +import { BCState } from '@/store' + +export interface VerificationPrompt { + id: number + prompt: string +} + +export interface VerificationPromptUploadPayload { + id: number + prompted_at: number // this provides the index/ order in which the prompt was given. 0 is the first prompt, 1 is the second prompt show ect. +} +export interface VerificationResponseData { + id: string + sha256: string + prompts: VerificationPrompt[] +} + +export interface SendVerificationPayload { + upload_uris: string[] + sha256: string +} + +export interface VerificationStatusResponseData { + id: string + status: 'pending' | 'verified' | 'cancelled' + status_message?: string + expires_in?: string + avg_turnaround_time_message?: string +} + +export interface VerificationPhotoUploadPayload { + label: string + content_type: string + content_length: number + date: number + sha256: string // hashed copy of the photo + filename?: string +} + +export interface VerificationVideoUploadPayload { + content_type: string + content_length: number + date: number // epoch timestamp in seconds + sha256: string // hashed copy of the video + duration: number // video duration in seconds + prompts: VerificationPromptUploadPayload[] + filename?: string +} + +export interface UploadEvidenceResponseData { + label: string + upload_uri: string +} + +export interface EvidenceImageSide { + image_side_name: 'FRONT_SIDE' | 'BACK_SIDE' + image_side_label: string + image_side_tip: string +} + +export interface EvidenceType { + evidence_type: string + has_photo: boolean + group: 'BRITISH COLUMBIA' | 'CANADA, OR OTHER LOCATION IN CANADA' | 'UNITED STATES' | 'OTHER COUNTRIES' + group_sort_order: number + sort_order: number + collection_order: 'FIRST' | 'SECOND' | 'BOTH' + document_reference_input_mask: string // a regex mask for ID document reference input, number only can indicate to use a number only keyboard + document_reference_label: string + document_reference_sample: string + image_sides: EvidenceImageSide[] + evidence_type_label: string +} +export interface EvidenceMetadataResponseData { + processes: { + process: 'IDIM L3 Remote Non-BCSC Identity Verification' | 'IDIM L3 Remote Non-photo BCSC Identity Verification' + evidence_types: EvidenceType[] + }[] +} +export interface EvidenceMetadataPayload { + type: string + number: string + images: VerificationPhotoUploadPayload[] + barcodes?: { + type: string + }[] +} + +const useEvidenceApi = () => { + const [store] = useStore() + + const _getDeviceCode = useCallback(() => { + const code = store.bcsc.deviceCode + if (!code) throw new Error('Device code is missing. Re install the app and try again.') + return code + }, [store.bcsc.deviceCode]) + + const getVerificationRequestStatus = useCallback( + async (verificationRequestId: string): Promise => { + return withAccount(async (account) => { + const token = await createEvidenceRequestJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.get( + `${apiClient.endpoints.evidence}/v1/verifications/${verificationRequestId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [_getDeviceCode] + ) + + const sendEvidenceMetadata = useCallback( + async (payload: EvidenceMetadataPayload): Promise => { + return withAccount(async (account) => { + const token = await createEvidenceRequestJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.post( + `${apiClient.endpoints.evidence}/v1/documents`, + payload, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [_getDeviceCode] + ) + + return useMemo( + () => ({ + createVerificationRequest, + uploadPhotoEvidenceMetadata, + uploadVideoEvidenceMetadata, + uploadPhotoEvidenceBinary, + uploadVideoEvidenceBinary, + sendVerificationRequest, + getVerificationRequestStatus, + cancelVerificationRequest, + getVerificationRequestPrompts, + createEmailVerification, + sendEmailVerificationCode, + sendEvidenceMetadata, + getEvidenceMetadata, + }), + [ + createVerificationRequest, + uploadPhotoEvidenceMetadata, + uploadVideoEvidenceMetadata, + uploadPhotoEvidenceBinary, + uploadVideoEvidenceBinary, + sendVerificationRequest, + getVerificationRequestStatus, + cancelVerificationRequest, + getVerificationRequestPrompts, + createEmailVerification, + sendEmailVerificationCode, + sendEvidenceMetadata, + getEvidenceMetadata, + ] + ) +} + +export default useEvidenceApi diff --git a/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx b/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx index c906f20bf..5bde7e640 100644 --- a/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx +++ b/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx @@ -15,7 +15,8 @@ type VerificationMethodSelectionScreenProps = { const VerificationMethodSelectionScreen = ({ navigation }: VerificationMethodSelectionScreenProps) => { const { ColorPalette, Spacing } = useTheme() const [, dispatch] = useStore() - const [loading, setLoading] = useState(false) + const [sendVideoLoading, setSendVideoLoading] = useState(false) + const [liveCallLoading, setLiveCallLoading] = useState(false) const { evidence } = useApi() const styles = StyleSheet.create({ @@ -27,7 +28,7 @@ const VerificationMethodSelectionScreen = ({ navigation }: VerificationMethodSel const handlePressSendVideo = async () => { try { - setLoading(true) + setSendVideoLoading(true) const { sha256, id, prompts } = await evidence.createVerificationRequest() dispatch({ type: BCDispatchAction.UPDATE_VERIFICATION_REQUEST, payload: [{ sha256, id }] }) dispatch({ type: BCDispatchAction.UPDATE_VIDEO_PROMPTS, payload: [prompts] }) @@ -36,7 +37,19 @@ const VerificationMethodSelectionScreen = ({ navigation }: VerificationMethodSel // TODO: Handle error, e.g., show an alert or log the error return } finally { - setLoading(false) + setSendVideoLoading(false) + } + } + + const handlePressLiveCall = async () => { + try { + setLiveCallLoading(true) + await new Promise((resolve) => setTimeout(resolve, 2000)) // Simulate loading + navigation.navigate(BCSCScreens.LiveCall) + } catch (error) { + // TODO: Handle error, e.g., show an alert or log the error + } finally { + setLiveCallLoading(false) } } @@ -48,8 +61,8 @@ const VerificationMethodSelectionScreen = ({ navigation }: VerificationMethodSel icon={'send'} onPress={handlePressSendVideo} style={{ marginBottom: Spacing.xxl }} - loading={loading} - disabled={loading} + loading={sendVideoLoading} + disabled={sendVideoLoading || liveCallLoading} /> null} + onPress={handlePressLiveCall} style={{ borderBottomWidth: 0 }} - disabled={loading} + loading={liveCallLoading} + disabled={liveCallLoading || sendVideoLoading} /> navigation.navigate(BCSCScreens.VerifyInPerson)} - disabled={loading} + disabled={liveCallLoading || sendVideoLoading} /> ) diff --git a/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx b/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx index 8b693edce..beb7f29dc 100644 --- a/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx +++ b/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx @@ -28,6 +28,7 @@ import EvidenceCaptureScreen from './non-photo/EvidenceCaptureScreen' import EvidenceIDCollectionScreen from './non-photo/EvidenceIDCollectionScreen' import EnterEmailScreen from './email/EnterEmailScreen' import EmailConfirmationScreen from './email/EmailConfirmationScreen' +import LiveCallScreen from './live-call/LiveCallScreen' import createHelpHeaderButton from '@/bcsc-theme/components/HelpHeaderButton' import { HelpCentreUrl } from '@/constants' import WebViewScreen from '../webview/WebViewScreen' @@ -159,6 +160,7 @@ const VerifyIdentityStack = () => { headerLeft: createWebviewHeaderBackButton(navigation), })} /> + ) } diff --git a/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx b/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx new file mode 100644 index 000000000..7a3ca7179 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx @@ -0,0 +1,13 @@ +import type { StyleProp, ViewStyle } from 'react-native' +import { MediaStream, RTCView } from 'react-native-webrtc' + +interface AgentVideoProps { + mediaStream: MediaStream | null + objectFit?: 'contain' | 'cover' + style?: StyleProp +} + +export const AgentVideo = ({ mediaStream, objectFit, style }: AgentVideoProps): JSX.Element => { + return +} +export default AgentVideo diff --git a/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx b/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx new file mode 100644 index 000000000..5ab976355 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx @@ -0,0 +1,7 @@ +type LiveCallScreenProps = { + navigation: any +} + +const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {} + +export default LiveCallScreen diff --git a/app/src/bcsc-theme/features/verify/live-call/LiveCallScreen.tsx b/app/src/bcsc-theme/features/verify/live-call/LiveCallScreen.tsx new file mode 100644 index 000000000..d9cf45f18 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/LiveCallScreen.tsx @@ -0,0 +1,163 @@ +import { StyleSheet, TouchableOpacity, useWindowDimensions, View, ViewStyle } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import AgentVideo from './AgentVideo' +import SelfieVideo from './SelfieVideo' +import { testIdWithKey, useTheme } from '@bifold/core' +import Icon from 'react-native-vector-icons/MaterialCommunityIcons' +import { useEffect, useMemo, useState } from 'react' +import { BannerSection } from '@bifold/core/src/components/views/Banner' +import { mediaDevices, MediaStream } from 'react-native-webrtc' + +type IconButtonProps = { + onPress: () => void + style?: ViewStyle + iconColor: string + backgroundColor: string + size: number + name: string + label: string +} + +const IconButton = ({ onPress, style = {}, iconColor, backgroundColor, size, name, label }: IconButtonProps) => { + const styles = StyleSheet.create({ + iconButton: { + width: size, + height: size, + borderRadius: size / 2, + justifyContent: 'center', + alignItems: 'center', + backgroundColor, + }, + }) + + return ( + + + + ) +} + +type LiveCallScreenProps = { + navigation: any +} + +const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => { + // const { agentMediaStream, selfieMediaStream } = useLiveCall() + const [selfieMediaStream, setSelfieMediaStream] = useState(null) + const { width } = useWindowDimensions() + const { ColorPalette, Spacing } = useTheme() + const iconSize = useMemo(() => width / 6, [width]) + const [onSpeaker, setOnSpeaker] = useState(false) + const [onMute, setOnMute] = useState(false) + + const styles = StyleSheet.create({ + agentVideo: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + flex: 1, + backgroundColor: 'transparent', + }, + container: { + flex: 1, + backgroundColor: 'transparent', + }, + controlsContainer: { + flexDirection: 'row', + justifyContent: 'space-evenly', + alignItems: 'center', + marginBottom: Spacing.md, + }, + selfieVideo: { + position: 'absolute', + bottom: Spacing.md, + right: 0, + width: width / 2, + aspectRatio: 1, + }, + }) + + useEffect(() => { + const asyncEffect = async () => { + const stream = await mediaDevices.getUserMedia({ + video: { + frameRate: 30, + facingMode: 'user', + }, + }) + setSelfieMediaStream(stream) + } + + asyncEffect() + }, []) + + const statusMessage = useMemo(() => { + if (onMute) { + return `Agent can't hear you when your microphone is off` + } + + return null + }, [onMute]) + + return ( + + + + + setOnSpeaker((prev) => !prev)} + size={iconSize} + name={'volume-high'} + backgroundColor={onSpeaker ? ColorPalette.grayscale.white : ColorPalette.notification.popupOverlay} + iconColor={onSpeaker ? ColorPalette.grayscale.black : ColorPalette.grayscale.white} + label={'Toggle speaker'} + /> + setOnMute((prev) => !prev)} + size={iconSize} + name={onMute ? 'microphone-off' : 'microphone'} + backgroundColor={onMute ? ColorPalette.notification.popupOverlay : ColorPalette.grayscale.white} + iconColor={onMute ? ColorPalette.grayscale.white : ColorPalette.grayscale.black} + label={'Toggle mute'} + /> + {}} + size={iconSize} + name={'information-variant'} + backgroundColor={ColorPalette.notification.popupOverlay} + iconColor={ColorPalette.grayscale.white} + label={'Help'} + /> + {}} + size={iconSize} + name={'phone-cancel'} + backgroundColor={ColorPalette.semantic.error} + iconColor={ColorPalette.grayscale.white} + label={'End call'} + /> + + {statusMessage ? ( + + ) : null} + + + + ) +} + +export default LiveCallScreen diff --git a/app/src/bcsc-theme/features/verify/live-call/SelfieVideo.tsx b/app/src/bcsc-theme/features/verify/live-call/SelfieVideo.tsx new file mode 100644 index 000000000..3b04b0c99 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/SelfieVideo.tsx @@ -0,0 +1,14 @@ +import type { StyleProp, ViewStyle } from 'react-native' +import { MediaStream, RTCView } from 'react-native-webrtc' + +interface SelfieVideoProps { + mediaStream: MediaStream | null + objectFit?: 'contain' | 'cover' + style?: StyleProp +} + +const SelfieView = ({ mediaStream, objectFit, style }: SelfieVideoProps): JSX.Element => { + return +} + +export default SelfieView diff --git a/app/src/bcsc-theme/features/verify/live-call/StartCallScreen.tsx b/app/src/bcsc-theme/features/verify/live-call/StartCallScreen.tsx new file mode 100644 index 000000000..5ab976355 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/StartCallScreen.tsx @@ -0,0 +1,7 @@ +type LiveCallScreenProps = { + navigation: any +} + +const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {} + +export default LiveCallScreen diff --git a/app/src/bcsc-theme/features/verify/live-call/VerifyNotComplete.tsx b/app/src/bcsc-theme/features/verify/live-call/VerifyNotComplete.tsx new file mode 100644 index 000000000..5ab976355 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/VerifyNotComplete.tsx @@ -0,0 +1,7 @@ +type LiveCallScreenProps = { + navigation: any +} + +const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {} + +export default LiveCallScreen diff --git a/app/src/bcsc-theme/features/verify/live-call/hooks/useLiveCall.tsx b/app/src/bcsc-theme/features/verify/live-call/hooks/useLiveCall.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/bcsc-theme/features/verify/live-call/types/live-call.ts b/app/src/bcsc-theme/features/verify/live-call/types/live-call.ts new file mode 100644 index 000000000..b5d61d0b0 --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/types/live-call.ts @@ -0,0 +1,12 @@ +export enum ConnectionState { + Disconnected, + Connecting, + Connected, +} + +export interface ConnectionRequest { + nodeUrl: string + conferenceAlias: string + displayName: string + pin?: string +} diff --git a/app/src/bcsc-theme/features/verify/live-call/utils/connect.ts b/app/src/bcsc-theme/features/verify/live-call/utils/connect.ts new file mode 100644 index 000000000..3745a858b --- /dev/null +++ b/app/src/bcsc-theme/features/verify/live-call/utils/connect.ts @@ -0,0 +1,186 @@ +import { MediaStream, RTCIceCandidate, RTCPeerConnection, mediaDevices } from 'react-native-webrtc' +import type { ConnectionRequest } from '../types/live-call' +import { callsWebrtcParticipant, newCandidate, requestToken, withPin, withToken } from '@pexip/infinity-api' +import { Alert } from 'react-native' +import { RTCOfferOptions } from 'react-native-webrtc/lib/typescript/RTCUtil' + +export const connect = async ( + req: ConnectionRequest & { onRemoteStream: (mediaStream: MediaStream) => void } +): Promise => { + let callUuid: string + + const localStream = await mediaDevices.getUserMedia({ + audio: true, + video: { + frameRate: 30, + facingMode: 'user', + }, + }) + + let response = await requestInfinityToken(req) + + if (response.status !== 200) { + Alert.alert('Cannot establish the connection. Check the PIN.') + throw new Error('Cannot authenticate') + } + + const participantUuid = response.data.result.participant_uuid + const token = response.data.result.token + + const peerConnection = await createPeerConnection(localStream) + peerConnection.addEventListener('connectionstatechange', (_event) => { + console.log('Received connectionstatechange') + console.log(peerConnection.connectionState) + }) + peerConnection.addEventListener('icecandidate', (event) => { + console.log('Received icecandidate') + const iceCandidate = event.candidate + if (iceCandidate != null) { + sendCandidate({ + nodeUrl: req.nodeUrl, + conferenceAlias: req.conferenceAlias, + participantUuid: participantUuid, + callUuid, + token, + iceCandidate, + }) + } + }) + peerConnection.addEventListener('icecandidateerror', (_event) => { + console.log('Received icecandidateerror') + }) + peerConnection.addEventListener('iceconnectionstatechange', (_event) => { + console.log('Received iceconnectionstatechange') + }) + peerConnection.addEventListener('icegatheringstatechange', (_event) => { + console.log('Received icegatheringstatechange') + }) + peerConnection.addEventListener('negotiationneeded', (_event) => { + console.log('Received negotiationneeded') + }) + peerConnection.addEventListener('signalingstatechange', (_event) => { + console.log('Received signalingstatechange') + }) + peerConnection.addEventListener('track', (event) => { + console.log('Received track') + req.onRemoteStream(event.streams[0]) + }) + const offer = await createOffer(peerConnection) + + response = await callToInfinity({ + nodeUrl: req.nodeUrl, + conferenceAlias: req.conferenceAlias, + participantUuid, + token, + offer, + }) + + if (response.status !== 200) { + Alert.alert('Cannot establish the connection. Check the PIN.') + throw new Error('Cannot authenticate') + } + + callUuid = response.data.result.call_uuid + console.log('Getting callUuid') + console.log(response.data.result) + + // Send the offer to infinity and get the answer + peerConnection.setLocalDescription(offer) + + peerConnection.setRemoteDescription({ + sdp: response.data.result.sdp, + type: 'answer', + }) + + return localStream +} + +const requestInfinityToken = async (request: ConnectionRequest): Promise => { + const { nodeUrl, conferenceAlias, displayName, pin } = request + + const response = await requestToken({ + fetcher: pin != null ? withPin(fetch, pin) : fetch, + body: { + display_name: displayName, + }, + params: { + conferenceAlias: conferenceAlias, + }, + host: nodeUrl, + }) + + return response +} + +const createPeerConnection = async (localStream: MediaStream) => { + const peerConstraints = { + iceServers: [ + { + urls: 'stun:stun.l.google.com:19302', + }, + ], + } + + const peerConnection = new RTCPeerConnection(peerConstraints) + localStream.getTracks().forEach((track) => { + peerConnection.addTrack(track) + }) + + return peerConnection +} + +const createOffer = async (peerConnection: RTCPeerConnection) => { + const sessionConstraints: RTCOfferOptions = { + offerToReceiveAudio: true, + offerToReceiveVideo: true, + voiceActivityDetection: true, + } + + return await peerConnection.createOffer(sessionConstraints) +} + +const callToInfinity = async (req: { + nodeUrl: string + conferenceAlias: string + participantUuid: string + token: string + offer: any +}) => { + return await callsWebrtcParticipant({ + fetcher: withToken(fetch, req.token), + body: { + call_type: 'WEBRTC', + sdp: req.offer.sdp, + media_type: 'video', + fecc_supported: false, + }, + params: { + conferenceAlias: req.conferenceAlias, + participantUuid: req.participantUuid, + }, + host: req.nodeUrl, + }) +} + +const sendCandidate = (req: { + nodeUrl: string + conferenceAlias: string + participantUuid: string + callUuid: string + token: string + iceCandidate: RTCIceCandidate +}) => { + newCandidate({ + fetcher: withToken(fetch, req.token), + body: { + candidate: req.iceCandidate.candidate, + mid: req.iceCandidate.sdpMid!, + }, + params: { + conferenceAlias: req.conferenceAlias, + participantUuid: req.participantUuid, + callUuid: req.callUuid, + }, + host: req.nodeUrl, + }) +} diff --git a/app/src/bcsc-theme/types/navigators.ts b/app/src/bcsc-theme/types/navigators.ts index 46e3cf0ce..18733f148 100644 --- a/app/src/bcsc-theme/types/navigators.ts +++ b/app/src/bcsc-theme/types/navigators.ts @@ -41,6 +41,7 @@ export enum BCSCScreens { EvidenceTypeList = 'EvidenceTypeList', EvidenceCapture = 'BCSCEvidenceCapture', EvidenceIDCollection = 'BCSCEvidenceIDCollection', + LiveCall = 'BCSCLiveCall', } export type BCSCTabStackParams = { @@ -86,4 +87,5 @@ export type BCSCVerifyIdentityStackParams = { [BCSCScreens.EvidenceTypeList]: undefined [BCSCScreens.EvidenceCapture]: { cardType: EvidenceType } [BCSCScreens.EvidenceIDCollection]: { cardType: EvidenceType } + [BCSCScreens.LiveCall]: undefined } diff --git a/yarn.lock b/yarn.lock index e896d6f79..0cdfaaa0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5921,6 +5921,13 @@ __metadata: languageName: node linkType: hard +"@pexip/infinity-api@npm:^19.1.2": + version: 19.1.2 + resolution: "@pexip/infinity-api@npm:19.1.2" + checksum: 10c0/1a2e47fd424e66ffbffdf364d451e46ed29647fd94ab175c41efcfcf8846528a49ac484055d894aaac789f8d137f0d09f0344500781170ae2dbb84736f06a3e3 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -10989,7 +10996,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:*, base64-js@npm:^1.0.2, base64-js@npm:^1.2.3, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": +"base64-js@npm:*, base64-js@npm:1.5.1, base64-js@npm:^1.0.2, base64-js@npm:^1.2.3, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -11087,6 +11094,7 @@ __metadata: "@hyperledger/aries-askar-react-native": "npm:0.2.3" "@hyperledger/indy-vdr-react-native": "npm:0.2.2" "@hyperledger/indy-vdr-shared": "npm:0.2.2" + "@pexip/infinity-api": "npm:^19.1.2" "@react-native-async-storage/async-storage": "npm:~1.22.3" "@react-native-clipboard/clipboard": "npm:~1.16.3" "@react-native-community/netinfo": "npm:~11.3.3" @@ -11191,6 +11199,7 @@ __metadata: react-native-vector-icons: "npm:~10.0.3" react-native-video: "npm:~6.16.1" react-native-vision-camera: "npm:~4.3.2" + react-native-webrtc: "npm:~124.0.6" react-native-webview: "npm:~13.10.7" react-test-renderer: "npm:~18.2.0" reflect-metadata: "npm:~0.1.14" @@ -13774,7 +13783,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -15848,6 +15857,13 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:6.0.2": + version: 6.0.2 + resolution: "event-target-shim@npm:6.0.2" + checksum: 10c0/e40b89effb1bd0fdd44dd198e3b61669c2bff89b680a8156c50480b5df8d4364208d1db09fbe823211005651558415691cb3ff72c22618eb154e55282b8b0481 + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0, event-target-shim@npm:^5.0.1": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -25500,6 +25516,19 @@ __metadata: languageName: node linkType: hard +"react-native-webrtc@npm:~124.0.6": + version: 124.0.6 + resolution: "react-native-webrtc@npm:124.0.6" + dependencies: + base64-js: "npm:1.5.1" + debug: "npm:4.3.4" + event-target-shim: "npm:6.0.2" + peerDependencies: + react-native: ">=0.60.0" + checksum: 10c0/303c2c1f371b9be5cb0f11285cf80c9bf6e7dfc88a760a4c97f9c29b1cddc8b4954ffd611e00835401980ad46b239c9617253552a97950098afb62c49bbdf9cb + languageName: node + linkType: hard + "react-native-webview@npm:~13.10.7": version: 13.10.7 resolution: "react-native-webview@npm:13.10.7" From 1be49b515584221fb5bc8b2e9bd47fc7eab11810 Mon Sep 17 00:00:00 2001 From: Bryce McMath Date: Fri, 15 Aug 2025 14:09:48 -0700 Subject: [PATCH 02/17] fix: correct import and theme issue Signed-off-by: Bryce McMath --- app/ios/AriesBifold/AppDelegate.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ios/AriesBifold/AppDelegate.mm b/app/ios/AriesBifold/AppDelegate.mm index 6fa01b30b..312711ae7 100644 --- a/app/ios/AriesBifold/AppDelegate.mm +++ b/app/ios/AriesBifold/AppDelegate.mm @@ -1,6 +1,6 @@ #import "AppDelegate.h" -#import +#import #import #import #import From 5b465fa00ed1a2c5f2bc2866082d5fc61b96074a Mon Sep 17 00:00:00 2001 From: Bryce McMath Date: Fri, 15 Aug 2025 14:55:55 -0700 Subject: [PATCH 03/17] chore(deps): did blah Signed-off-by: Bryce McMath --- app/test.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/test.md diff --git a/app/test.md b/app/test.md new file mode 100644 index 000000000..e69de29bb From fddf1bcec5b3d5f133b62695fdda91d7a534fd60 Mon Sep 17 00:00:00 2001 From: Bryce McMath Date: Tue, 19 Aug 2025 21:45:39 -0700 Subject: [PATCH 04/17] feat: live call wip Got video call functionality working Muting and unmuting audio tracks Stopping and starting video tracks Error handling for busy or closed calls Content screens Signed-off-by: Bryce McMath --- app/src/assets/img/mountains-circle.svg | 17 + app/src/bcsc-theme/api/client.ts | 5 +- .../bcsc-theme/api/hooks/useVideoCallApi.tsx | 300 +++++++++------ .../bcsc-theme/api/hooks/useVideoCallFlow.tsx | 291 +++++++++++++++ .../bcsc-theme/components/MaskedCamera.tsx | 4 +- .../PhotoInstructionsScreen.tsx | 7 +- .../{send-video => }/PhotoReviewScreen.tsx | 24 +- .../{send-video => }/TakePhotoScreen.tsx | 5 +- .../VerificationMethodSelectionScreen.tsx | 4 +- .../features/verify/VerifyIdentityStack.tsx | 33 +- .../features/verify/live-call/AgentVideo.tsx | 13 - .../verify/live-call/BeforeYouCallScreen.tsx | 218 ++++++++++- .../live-call/CallBusyOrClosedScreen.tsx | 165 +++++++++ .../verify/live-call/LiveCallScreen.tsx | 350 +++++++++++++----- .../features/verify/live-call/SelfieVideo.tsx | 14 - .../verify/live-call/StartCallScreen.tsx | 72 +++- .../verify/live-call/VerifyNotComplete.tsx | 85 ++++- .../verify/live-call/utils/errorHandler.ts | 151 ++++++++ app/src/bcsc-theme/types/navigators.ts | 14 +- app/src/localization/en/index.ts | 5 + .../main/java/com/bcsccore/BcscCoreModule.kt | 12 +- .../android/src/oldarch/BcscCoreSpec.kt | 2 +- packages/bcsc-core/ios/BcscCore.m | 2 +- packages/bcsc-core/ios/BcscCore.swift | 2 +- packages/bcsc-core/src/NativeBcscCore.ts | 2 +- packages/bcsc-core/src/index.ts | 4 +- 26 files changed, 1527 insertions(+), 274 deletions(-) create mode 100644 app/src/assets/img/mountains-circle.svg create mode 100644 app/src/bcsc-theme/api/hooks/useVideoCallFlow.tsx rename app/src/bcsc-theme/features/verify/{send-video => }/PhotoInstructionsScreen.tsx (89%) rename app/src/bcsc-theme/features/verify/{send-video => }/PhotoReviewScreen.tsx (78%) rename app/src/bcsc-theme/features/verify/{send-video => }/TakePhotoScreen.tsx (82%) delete mode 100644 app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/CallBusyOrClosedScreen.tsx delete mode 100644 app/src/bcsc-theme/features/verify/live-call/SelfieVideo.tsx create mode 100644 app/src/bcsc-theme/features/verify/live-call/utils/errorHandler.ts diff --git a/app/src/assets/img/mountains-circle.svg b/app/src/assets/img/mountains-circle.svg new file mode 100644 index 000000000..65d8edce9 --- /dev/null +++ b/app/src/assets/img/mountains-circle.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/bcsc-theme/api/client.ts b/app/src/bcsc-theme/api/client.ts index e5112ab94..f7af0513b 100644 --- a/app/src/bcsc-theme/api/client.ts +++ b/app/src/bcsc-theme/api/client.ts @@ -42,6 +42,7 @@ interface BCSCEndpoints { token: string credential: string evidence: string + video: string } class BCSCService { @@ -93,6 +94,7 @@ class BCSCService { token: `${this.baseURL}/device/token`, credential: `${this.baseURL}/credentials/v1/person`, evidence: `${this.baseURL}/evidence`, + video: `${this.baseURL}/video`, } // Add interceptors @@ -156,8 +158,9 @@ class BCSCService { savedServices: response.data['saved_services_endpoint'], token: response.data['token_endpoint'], credential: response.data['credential_endpoint'], - // TODO(bm): request backend team to add evidence endpoint to the response + // TODO(bm): request backend team to add evidence and video endpoints to the response evidence: `${this.baseURL}/evidence`, + video: `${this.baseURL}/video`, } } diff --git a/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx b/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx index 15f3e4870..e2209441e 100644 --- a/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx +++ b/app/src/bcsc-theme/api/hooks/useVideoCallApi.tsx @@ -2,96 +2,78 @@ import { useCallback, useMemo } from 'react' import { useStore } from '@bifold/core' import apiClient from '../client' import { withAccount } from './withAccountGuard' -import { createEvidenceRequestJWT } from 'react-native-bcsc-core' +import { createPreVerificationJWT } from 'react-native-bcsc-core' import { BCState } from '@/store' -export interface VerificationPrompt { - id: number - prompt: string -} - -export interface VerificationPromptUploadPayload { - id: number - prompted_at: number // this provides the index/ order in which the prompt was given. 0 is the first prompt, 1 is the second prompt show ect. -} -export interface VerificationResponseData { - id: string - sha256: string - prompts: VerificationPrompt[] -} - -export interface SendVerificationPayload { - upload_uris: string[] - sha256: string -} +type SessionStatusType = 'session_granted' | 'session_not_granted' | 'session_failed' | 'session_ended' +type CallStatusType = + | 'call_ringing' + | 'call_media_pending' + | 'call_in_call' + | 'call_ended' + | 'call_error' + | 'call_dropped' + | 'call_reconnected' -export interface VerificationStatusResponseData { - id: string - status: 'pending' | 'verified' | 'cancelled' - status_message?: string - expires_in?: string - avg_turnaround_time_message?: string +export interface VideoSession { + client_id: string + gateway_uri: string + session_id: string + session_token: string + destination: string + device_code: string + status: SessionStatusType + status_date: number // seconds from epoch + created_date: number // seconds from epoch } -export interface VerificationPhotoUploadPayload { - label: string - content_type: string - content_length: number - date: number - sha256: string // hashed copy of the photo - filename?: string +export interface VideoCall { + session_id: string + call_id: string + status: CallStatusType + status_date: number // seconds from epoch } -export interface VerificationVideoUploadPayload { - content_type: string - content_length: number - date: number // epoch timestamp in seconds - sha256: string // hashed copy of the video - duration: number // video duration in seconds - prompts: VerificationPromptUploadPayload[] - filename?: string +export interface VideoDestination { + destinationId: number + videoDestinationConfigId: number + destinationName: string + destinationAddress: string + maxActiveSessions: number + maxInactiveSeconds: number + destinationPriority: number + numberOfAgents: number + effectiveStartDate: number + videoDestinationConfig: { + videoDestinationConfigId: number + videoClientId: string + videoGatewayUrl: string + videoDestTruststore: string + truststorePassword: string + videoClientKeystore: string + keystorePassword: string + clientCertAlias: string + effectiveStartDate: number + effectiveEndDate: number | null + } } -export interface UploadEvidenceResponseData { - label: string - upload_uri: string -} +export type VideoDestinations = VideoDestination[] -export interface EvidenceImageSide { - image_side_name: 'FRONT_SIDE' | 'BACK_SIDE' - image_side_label: string - image_side_tip: string +export interface ServicePeriod { + start_day: string // e.g. "MONDAY" + end_day: string // e.g. "MONDAY" + start_time: string // e.g. "05:00" + end_time: string // e.g. "23:59" } -export interface EvidenceType { - evidence_type: string - has_photo: boolean - group: 'BRITISH COLUMBIA' | 'CANADA, OR OTHER LOCATION IN CANADA' | 'UNITED STATES' | 'OTHER COUNTRIES' - group_sort_order: number - sort_order: number - collection_order: 'FIRST' | 'SECOND' | 'BOTH' - document_reference_input_mask: string // a regex mask for ID document reference input, number only can indicate to use a number only keyboard - document_reference_label: string - document_reference_sample: string - image_sides: EvidenceImageSide[] - evidence_type_label: string -} -export interface EvidenceMetadataResponseData { - processes: { - process: 'IDIM L3 Remote Non-BCSC Identity Verification' | 'IDIM L3 Remote Non-photo BCSC Identity Verification' - evidence_types: EvidenceType[] - }[] -} -export interface EvidenceMetadataPayload { - type: string - number: string - images: VerificationPhotoUploadPayload[] - barcodes?: { - type: string - }[] +export interface ServiceHours { + time_zone: string + regular_service_periods: ServicePeriod[] + service_unavailable_periods: ServicePeriod[] } -const useEvidenceApi = () => { +const useVideoCallApi = () => { const [store] = useStore() const _getDeviceCode = useCallback(() => { @@ -100,12 +82,115 @@ const useEvidenceApi = () => { return code }, [store.bcsc.deviceCode]) - const getVerificationRequestStatus = useCallback( - async (verificationRequestId: string): Promise => { + const createVideoSession = useCallback( + async (): Promise => { + return withAccount(async (account) => { + const deviceCode = _getDeviceCode() + const body = { client_id: account.clientID, device_code: deviceCode } + const token = await createPreVerificationJWT(deviceCode, account.clientID) + const { data } = await apiClient.post( + `${apiClient.endpoints.video}/v1/sessions/`, + body, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [] + ) + + const updateVideoSessionStatus = useCallback( + async (sessionId: string, status: SessionStatusType): Promise => { + return withAccount(async (account) => { + const body = { status } + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.put( + `${apiClient.endpoints.video}/v1/sessions/${sessionId}`, + body, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [_getDeviceCode] + ) + + const createVideoCall = useCallback( + async (sessionId: string): Promise => { + return withAccount(async (account) => { + const body = { session_id: sessionId } + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.post( + `${apiClient.endpoints.video}/v1/sessions/${sessionId}/calls/`, + body, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [_getDeviceCode] + ) + + const updateVideoCallStatus = useCallback( + async (sessionId: string, callId: string, status: CallStatusType, clientCallId?: string): Promise => { + return withAccount(async (account) => { + const body = { status, ...(clientCallId && { client_call_id: clientCallId }) } + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.put( + `${apiClient.endpoints.video}/v1/sessions/${sessionId}/calls/${callId}`, + body, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + return data + }) + }, + [_getDeviceCode] + ) + + const endVideoSession = useCallback( + async (sessionId: string): Promise => { + return withAccount(async (account) => { + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + await apiClient.delete( + `${apiClient.endpoints.video}/v1/sessions/${sessionId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + skipBearerAuth: true, + } + ) + }) + }, + [_getDeviceCode] + ) + + const getVideoDestinations = useCallback( + async (): Promise => { return withAccount(async (account) => { - const token = await createEvidenceRequestJWT(_getDeviceCode(), account.clientID) - const { data } = await apiClient.get( - `${apiClient.endpoints.evidence}/v1/verifications/${verificationRequestId}`, + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.get( + `${apiClient.endpoints.video}/v1/destinations`, { headers: { Authorization: `Bearer ${token}`, @@ -119,13 +204,12 @@ const useEvidenceApi = () => { [_getDeviceCode] ) - const sendEvidenceMetadata = useCallback( - async (payload: EvidenceMetadataPayload): Promise => { + const getServiceHours = useCallback( + async (): Promise => { return withAccount(async (account) => { - const token = await createEvidenceRequestJWT(_getDeviceCode(), account.clientID) - const { data } = await apiClient.post( - `${apiClient.endpoints.evidence}/v1/documents`, - payload, + const token = await createPreVerificationJWT(_getDeviceCode(), account.clientID) + const { data } = await apiClient.get( + `${apiClient.endpoints.video}/video/v1/service_hours`, { headers: { Authorization: `Bearer ${token}`, @@ -141,36 +225,24 @@ const useEvidenceApi = () => { return useMemo( () => ({ - createVerificationRequest, - uploadPhotoEvidenceMetadata, - uploadVideoEvidenceMetadata, - uploadPhotoEvidenceBinary, - uploadVideoEvidenceBinary, - sendVerificationRequest, - getVerificationRequestStatus, - cancelVerificationRequest, - getVerificationRequestPrompts, - createEmailVerification, - sendEmailVerificationCode, - sendEvidenceMetadata, - getEvidenceMetadata, + createVideoSession, + updateVideoSessionStatus, + createVideoCall, + updateVideoCallStatus, + endVideoSession, + getVideoDestinations, + getServiceHours, }), [ - createVerificationRequest, - uploadPhotoEvidenceMetadata, - uploadVideoEvidenceMetadata, - uploadPhotoEvidenceBinary, - uploadVideoEvidenceBinary, - sendVerificationRequest, - getVerificationRequestStatus, - cancelVerificationRequest, - getVerificationRequestPrompts, - createEmailVerification, - sendEmailVerificationCode, - sendEvidenceMetadata, - getEvidenceMetadata, + createVideoSession, + updateVideoSessionStatus, + createVideoCall, + updateVideoCallStatus, + endVideoSession, + getVideoDestinations, + getServiceHours, ] ) } -export default useEvidenceApi +export default useVideoCallApi diff --git a/app/src/bcsc-theme/api/hooks/useVideoCallFlow.tsx b/app/src/bcsc-theme/api/hooks/useVideoCallFlow.tsx new file mode 100644 index 000000000..21a509b20 --- /dev/null +++ b/app/src/bcsc-theme/api/hooks/useVideoCallFlow.tsx @@ -0,0 +1,291 @@ +import { useNavigation } from '@react-navigation/native' +import { StackNavigationProp } from '@react-navigation/stack' +import { useCallback, useEffect, useRef, useState } from 'react' +import { MediaStream } from 'react-native-webrtc' +import { ConnectionRequest } from '../../features/verify/live-call/types/live-call' +import { connect } from '../../features/verify/live-call/utils/connect' +import { VideoCallErrorHandler, VideoCallError as ErrorHandlerError } from '../../features/verify/live-call/utils/errorHandler' +import useVideoCallApi, { VideoCall, VideoDestination, VideoSession } from './useVideoCallApi' + +export type VideoCallFlowState = + | 'idle' + | 'checking_availability' + | 'creating_session' + | 'connecting_webrtc' + | 'waiting_for_agent' + | 'in_call' + | 'call_ended' + | 'error' + +export interface VideoCallError { + type: 'service_unavailable' | 'connection_failed' | 'session_failed' | 'call_failed' | 'network_error' | 'permission_denied' + message: string + retryable: boolean + technicalDetails?: string +} + +export interface VideoCallFlowResult { + // State + flowState: VideoCallFlowState + session: VideoSession | null + call: VideoCall | null + error: VideoCallError | null + + // Media streams + localStream: MediaStream | null + remoteStream: MediaStream | null + + // Actions + startVideoCall: () => Promise + endCall: () => Promise + retryConnection: () => Promise + + // Loading states + isConnecting: boolean + isCreatingSession: boolean +} + +const useVideoCallFlow = (): VideoCallFlowResult => { + const [flowState, setFlowState] = useState('idle') + const [session, setSession] = useState(null) + const [call, setCall] = useState(null) + const [error, setError] = useState(null) + const [localStream, setLocalStream] = useState(null) + const [remoteStream, setRemoteStream] = useState(null) + + const navigation = useNavigation>() + const api = useVideoCallApi() + + // Refs for cleanup + const sessionRef = useRef(null) + const callRef = useRef(null) + const connectionRef = useRef(null) + + // Update refs when state changes + useEffect(() => { + sessionRef.current = session + }, [session]) + + useEffect(() => { + callRef.current = call + }, [call]) + + const cleanup = useCallback(async () => { + try { + // Clean up WebRTC connection + if (connectionRef.current) { + // The current connect implementation doesn't export a disconnect function + // We'll need to handle cleanup within the connection logic + connectionRef.current = null + } + + // End session if active + if (sessionRef.current && sessionRef.current.status !== 'session_ended') { + await api.endVideoSession(sessionRef.current.session_id) + } + } catch (error) { + console.warn('Cleanup error:', error) + } + + setLocalStream(null) + setRemoteStream(null) + }, [api]) + + const handleError = useCallback((error: VideoCallError) => { + VideoCallErrorHandler.logError(error, 'useVideoCallFlow') + setError(error) + setFlowState('error') + }, []) + + const checkServiceAvailability = useCallback(async (): Promise => { + try { + const destinations = await api.getVideoDestinations() + const serviceHours = await api.getServiceHours() + + // Find available destination with agents + const availableDestination = destinations.find((dest) => dest.numberOfAgents > 0) + + if (!availableDestination) { + handleError(VideoCallErrorHandler.errors.serviceUnavailable()) + return null + } + + // TODO: Add service hours validation based on timezone + // For now, assume service is available if agents are present + + return availableDestination + } catch (error) { + handleError(VideoCallErrorHandler.errors.networkUnavailable()) + return null + } + }, [api, handleError]) + + const createSession = useCallback(async (): Promise => { + try { + const newSession = await api.createVideoSession() + setSession(newSession) + return newSession + } catch (error) { + handleError(VideoCallErrorHandler.errors.sessionCreationFailed(error?.toString())) + return null + } + }, [api, handleError]) + + const establishWebRTCConnection = useCallback( + async (session: VideoSession): Promise => { + try { + // Parse the gateway URI to extract connection details + const gatewayUrl = new URL(session.gateway_uri) + + const connectionRequest: ConnectionRequest & { onRemoteStream: (mediaStream: MediaStream) => void } = { + nodeUrl: gatewayUrl.origin, + conferenceAlias: session.destination, + displayName: 'Verification Client', + pin: session.session_token, // Using session token as PIN for Pexip + onRemoteStream: (stream: MediaStream) => { + setRemoteStream(stream) + setFlowState('in_call') + }, + } + + const result = await connect(connectionRequest) + connectionRef.current = result + + setFlowState('waiting_for_agent') + return true + } catch (error) { + handleError(VideoCallErrorHandler.errors.webRTCFailed(error?.toString())) + return false + } + }, + [handleError] + ) + + const createCall = useCallback( + async (sessionId: string): Promise => { + try { + const newCall = await api.createVideoCall(sessionId) + setCall(newCall) + + // Update call status to indicate we're ready + await api.updateVideoCallStatus(sessionId, newCall.call_id, 'call_media_pending') + + return newCall + } catch (error) { + handleError(VideoCallErrorHandler.errors.callCreationFailed(error?.toString())) + return null + } + }, + [api, handleError] + ) + + const startVideoCall = useCallback(async () => { + setError(null) + + try { + // Step 1: Check service availability + setFlowState('checking_availability') + const destination = await checkServiceAvailability() + if (!destination) return + + // Step 2: Create session + setFlowState('creating_session') + const newSession = await createSession() + if (!newSession) return + + // Step 3: Establish WebRTC connection + setFlowState('connecting_webrtc') + const connected = await establishWebRTCConnection(newSession) + if (!connected) return + + // Step 4: Create call + const newCall = await createCall(newSession.session_id) + if (!newCall) return + + // Success - now waiting for agent + setFlowState('waiting_for_agent') + } catch (error) { + handleError(VideoCallErrorHandler.errors.unknownError(error?.toString())) + } + }, [checkServiceAvailability, createSession, establishWebRTCConnection, createCall, handleError]) + + const endCall = useCallback(async () => { + try { + if (call && session) { + // Update call status to ended + await api.updateVideoCallStatus(session.session_id, call.call_id, 'call_ended') + + // Update session status + await api.updateVideoSessionStatus(session.session_id, 'session_ended') + } + + await cleanup() + + setFlowState('call_ended') + setSession(null) + setCall(null) + + // Navigate back to verification options or previous screen + navigation.goBack() + } catch (error) { + console.warn('Error ending call:', error) + // Still perform cleanup even if API calls fail + await cleanup() + setFlowState('call_ended') + navigation.goBack() + } + }, [api, call, session, cleanup, navigation]) + + const retryConnection = useCallback(async () => { + await cleanup() + setError(null) + setSession(null) + setCall(null) + await startVideoCall() + }, [cleanup, startVideoCall]) + + // Cleanup on unmount + useEffect(() => { + return () => { + cleanup() + } + }, [cleanup]) + + // Handle call status updates (polling or push notifications would be better) + useEffect(() => { + if (call && flowState === 'waiting_for_agent') { + const pollInterval = setInterval(async () => { + try { + // In a real implementation, this would be handled by WebSocket or push notifications + // For now, we'll assume the call status is updated through the WebRTC connection events + + // When agent joins, the WebRTC connection will trigger onRemoteStream + if (remoteStream) { + setFlowState('in_call') + clearInterval(pollInterval) + } + } catch (error) { + console.warn('Error polling call status:', error) + } + }, 2000) + + return () => clearInterval(pollInterval) + } + }, [call, flowState, remoteStream]) + + return { + flowState, + session, + call, + error, + localStream, + remoteStream, + startVideoCall, + endCall, + retryConnection, + isConnecting: flowState === 'connecting_webrtc', + isCreatingSession: flowState === 'creating_session' || flowState === 'checking_availability', + } +} + +export default useVideoCallFlow diff --git a/app/src/bcsc-theme/components/MaskedCamera.tsx b/app/src/bcsc-theme/components/MaskedCamera.tsx index ab055a3d8..c5aed87de 100644 --- a/app/src/bcsc-theme/components/MaskedCamera.tsx +++ b/app/src/bcsc-theme/components/MaskedCamera.tsx @@ -157,10 +157,10 @@ const MaskedCamera = ({ /> - + {cameraLabel} - + {cameraInstructions} diff --git a/app/src/bcsc-theme/features/verify/send-video/PhotoInstructionsScreen.tsx b/app/src/bcsc-theme/features/verify/PhotoInstructionsScreen.tsx similarity index 89% rename from app/src/bcsc-theme/features/verify/send-video/PhotoInstructionsScreen.tsx rename to app/src/bcsc-theme/features/verify/PhotoInstructionsScreen.tsx index 8f95a5421..cacb62afd 100644 --- a/app/src/bcsc-theme/features/verify/send-video/PhotoInstructionsScreen.tsx +++ b/app/src/bcsc-theme/features/verify/PhotoInstructionsScreen.tsx @@ -4,14 +4,17 @@ import SelfieImage from '@assets/img/selfie_example.png' import { Image, StyleSheet, View } from 'react-native' import { BCSCScreens, BCSCVerifyIdentityStackParams } from '@/bcsc-theme/types/navigators' import { StackNavigationProp } from '@react-navigation/stack' +import { RouteProp } from '@react-navigation/native' const SELFIE_IMAGE = Image.resolveAssetSource(SelfieImage).uri type PhotoInstructionsScreenProps = { navigation: StackNavigationProp + route: RouteProp } -const PhotoInstructionsScreen = ({ navigation }: PhotoInstructionsScreenProps) => { +const PhotoInstructionsScreen = ({ navigation, route }: PhotoInstructionsScreenProps) => { + const { forLiveCall } = route.params const { ColorPalette, Spacing } = useTheme() const styles = StyleSheet.create({ @@ -69,7 +72,7 @@ const PhotoInstructionsScreen = ({ navigation }: PhotoInstructionsScreenProps) = buttonType={ButtonType.Primary} title={'Take Photo of Face'} onPress={() => { - navigation.navigate(BCSCScreens.TakePhoto, { deviceSide: 'front', cameraInstructions: '', cameraLabel: '' }) + navigation.navigate(BCSCScreens.TakePhoto, { deviceSide: 'front', cameraInstructions: '', cameraLabel: '', forLiveCall }) }} testID={'TakePhotoButton'} accessibilityLabel={'Take Photo of Face'} diff --git a/app/src/bcsc-theme/features/verify/send-video/PhotoReviewScreen.tsx b/app/src/bcsc-theme/features/verify/PhotoReviewScreen.tsx similarity index 78% rename from app/src/bcsc-theme/features/verify/send-video/PhotoReviewScreen.tsx rename to app/src/bcsc-theme/features/verify/PhotoReviewScreen.tsx index 0be320e14..c53eef4a7 100644 --- a/app/src/bcsc-theme/features/verify/send-video/PhotoReviewScreen.tsx +++ b/app/src/bcsc-theme/features/verify/PhotoReviewScreen.tsx @@ -3,24 +3,20 @@ import { BCDispatchAction, BCState } from '@/store' import { BCSCScreens, BCSCVerifyIdentityStackParams } from '@bcsc-theme/types/navigators' import { getPhotoMetadata } from '@bcsc-theme/utils/file-info' import { TOKENS, useServices, useStore, useTheme } from '@bifold/core' -import { CommonActions } from '@react-navigation/native' +import { CommonActions, RouteProp } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' type PhotoReviewScreenProps = { navigation: StackNavigationProp - route: { - params: { - photoPath: string - } - } + route: RouteProp } const PhotoReviewScreen = ({ navigation, route }: PhotoReviewScreenProps) => { const { ColorPalette, Spacing } = useTheme() const [, dispatch] = useStore() - const { photoPath } = route.params + const { photoPath, forLiveCall } = route.params const [logger] = useServices([TOKENS.UTIL_LOGGER]) if (!photoPath) { @@ -55,6 +51,20 @@ const PhotoReviewScreen = ({ navigation, route }: PhotoReviewScreenProps) => { dispatch({ type: BCDispatchAction.SAVE_PHOTO, payload: [{ photoPath, photoMetadata }] }) + console.log('forLiveCall: ', forLiveCall) + if (forLiveCall) { + navigation.dispatch(CommonActions.reset({ + index: 3, + routes: [ + { name: BCSCScreens.SetupSteps }, + { name: BCSCScreens.VerificationMethodSelection }, + { name: BCSCScreens.PhotoInstructions, params: { forLiveCall: true } }, + { name: BCSCScreens.StartCall } + ] + })) + return + } + navigation.dispatch( CommonActions.reset({ index: 2, diff --git a/app/src/bcsc-theme/features/verify/send-video/TakePhotoScreen.tsx b/app/src/bcsc-theme/features/verify/TakePhotoScreen.tsx similarity index 82% rename from app/src/bcsc-theme/features/verify/send-video/TakePhotoScreen.tsx rename to app/src/bcsc-theme/features/verify/TakePhotoScreen.tsx index 9249d41f7..a9ab34c54 100644 --- a/app/src/bcsc-theme/features/verify/send-video/TakePhotoScreen.tsx +++ b/app/src/bcsc-theme/features/verify/TakePhotoScreen.tsx @@ -6,12 +6,14 @@ import { StyleSheet } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import MaskedCamera from '@/bcsc-theme/components/MaskedCamera' import CircularMask from '@/bcsc-theme/components/CircularMask' +import { RouteProp } from '@react-navigation/native' type PhotoInstructionsScreenProps = { navigation: StackNavigationProp + route: RouteProp } -const TakePhotoScreen = ({ navigation }: PhotoInstructionsScreenProps) => { +const TakePhotoScreen = ({ navigation, route }: PhotoInstructionsScreenProps) => { const styles = StyleSheet.create({ pageContainer: { flex: 1, @@ -23,6 +25,7 @@ const TakePhotoScreen = ({ navigation }: PhotoInstructionsScreenProps) => { // Navigate to photo review screen with the photo data navigation.navigate(BCSCScreens.PhotoReview, { photoPath: path, + forLiveCall: route.params.forLiveCall, }) } diff --git a/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx b/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx index 5bde7e640..438b691d5 100644 --- a/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx +++ b/app/src/bcsc-theme/features/verify/VerificationMethodSelectionScreen.tsx @@ -44,8 +44,8 @@ const VerificationMethodSelectionScreen = ({ navigation }: VerificationMethodSel const handlePressLiveCall = async () => { try { setLiveCallLoading(true) - await new Promise((resolve) => setTimeout(resolve, 2000)) // Simulate loading - navigation.navigate(BCSCScreens.LiveCall) + await new Promise((resolve) => setTimeout(resolve, 1000)) // Simulate loading + navigation.navigate(BCSCScreens.BeforeYouCall) } catch (error) { // TODO: Handle error, e.g., show an alert or log the error } finally { diff --git a/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx b/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx index beb7f29dc..eb271c893 100644 --- a/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx +++ b/app/src/bcsc-theme/features/verify/VerifyIdentityStack.tsx @@ -12,9 +12,9 @@ import VerifyInPersonScreen from './in-person/VerifyInPersonScreen' import MismatchedSerialScreen from './MismatchedSerialScreen' import VerificationSuccessScreen from './VerificationSuccessScreen' import InformationRequiredScreen from './send-video/InformationRequiredScreen' -import PhotoInstructionsScreen from './send-video/PhotoInstructionsScreen' -import TakePhotoScreen from './send-video/TakePhotoScreen' -import PhotoReviewScreen from './send-video/PhotoReviewScreen' +import PhotoInstructionsScreen from './PhotoInstructionsScreen' +import TakePhotoScreen from './TakePhotoScreen' +import PhotoReviewScreen from './PhotoReviewScreen' import TakeVideoScreen from './send-video/TakeVideoScreen' import VideoInstructionsScreen from './send-video/VideoInstructionsScreen' import VideoReviewScreen from './send-video/VideoReviewScreen' @@ -33,6 +33,10 @@ import createHelpHeaderButton from '@/bcsc-theme/components/HelpHeaderButton' import { HelpCentreUrl } from '@/constants' import WebViewScreen from '../webview/WebViewScreen' import { createWebviewHeaderBackButton } from '@/bcsc-theme/components/WebViewBackButton' +import StartCallScreen from './live-call/StartCallScreen' +import BeforeYouCallScreen from './live-call/BeforeYouCallScreen' +import VerifyNotCompleteScreen from './live-call/VerifyNotComplete' +import CallBusyOrClosedScreen from './live-call/CallBusyOrClosedScreen' const VerifyIdentityStack = () => { const Stack = createStackNavigator() @@ -160,7 +164,30 @@ const VerifyIdentityStack = () => { headerLeft: createWebviewHeaderBackButton(navigation), })} /> + + + + ) } diff --git a/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx b/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx deleted file mode 100644 index 7a3ca7179..000000000 --- a/app/src/bcsc-theme/features/verify/live-call/AgentVideo.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { StyleProp, ViewStyle } from 'react-native' -import { MediaStream, RTCView } from 'react-native-webrtc' - -interface AgentVideoProps { - mediaStream: MediaStream | null - objectFit?: 'contain' | 'cover' - style?: StyleProp -} - -export const AgentVideo = ({ mediaStream, objectFit, style }: AgentVideoProps): JSX.Element => { - return -} -export default AgentVideo diff --git a/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx b/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx index 5ab976355..f0e0504da 100644 --- a/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx +++ b/app/src/bcsc-theme/features/verify/live-call/BeforeYouCallScreen.tsx @@ -1,7 +1,217 @@ -type LiveCallScreenProps = { - navigation: any +import useVideoCallApi from '@/bcsc-theme/api/hooks/useVideoCallApi' +import { BCSCScreens, BCSCVerifyIdentityStackParams } from '@bcsc-theme/types/navigators' +import { + Button, + ButtonType, + testIdWithKey, + ThemedText, + useTheme +} from '@bifold/core' +import { useNetInfo } from '@react-native-community/netinfo' +import { StackNavigationProp } from '@react-navigation/stack' +import { useEffect, useState } from 'react' +import { Alert, ScrollView, StyleSheet, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' + +type BeforeYouCallScreenProps = { + navigation: StackNavigationProp } -const LiveCallScreen = ({ navigation }: LiveCallScreenProps) => {} +const BeforeYouCallScreen = ({ navigation }: BeforeYouCallScreenProps) => { + const { ColorPalette, Spacing } = useTheme() + const { isWifiEnabled } = useNetInfo() + const [isCheckingService, setIsCheckingService] = useState(false) + const [serviceStatus, setServiceStatus] = useState<{ + available: boolean + message: string + hoursText: string + }>({ available: true, message: '', hoursText: 'Monday to Friday\n7:30am - 5:00pm Pacific Time' }) + + const videoCallApi = useVideoCallApi() + + useEffect(() => { + checkServiceAvailability() + }, []) + + const checkServiceAvailability = async () => { + try { + setIsCheckingService(true) + + // Check destinations and service hours + const [destinations, serviceHours] = await Promise.all([ + videoCallApi.getVideoDestinations(), + videoCallApi.getServiceHours(), + ]) + + // Check if any agents are available + const availableDestination = destinations.find((dest) => dest.numberOfAgents > 0) + + if (!availableDestination) { + setServiceStatus({ + available: false, + message: 'Video calling service is currently unavailable. No agents are online.', + hoursText: formatServiceHours(serviceHours), + }) + } else { + // Check if within service hours + const isWithinServiceHours = checkIfWithinServiceHours(serviceHours) + + if (!isWithinServiceHours) { + setServiceStatus({ + available: false, + message: 'Video calling service is outside of operating hours.', + hoursText: formatServiceHours(serviceHours), + }) + } else { + setServiceStatus({ + available: true, + message: `${availableDestination.numberOfAgents} agent${ + availableDestination.numberOfAgents > 1 ? 's' : '' + } available`, + hoursText: formatServiceHours(serviceHours), + }) + } + } + } catch (error) { + console.warn('Error checking service availability:', error) + setServiceStatus({ + available: false, + message: 'Unable to check service availability. Please try again later.', + hoursText: 'Monday to Friday\n7:30am - 5:00pm Pacific Time', + }) + } finally { + setIsCheckingService(false) + } + } + + const formatServiceHours = (serviceHours: any): string => { + if (!serviceHours?.regular_service_periods?.length) { + return 'Monday to Friday\n7:30am - 5:00pm Pacific Time' + } + + // Format service hours from API response + // This is a simplified version - you might want more sophisticated formatting + const periods = serviceHours.regular_service_periods + const timezone = serviceHours.time_zone || 'Pacific Time' + + return periods + .map( + (period: any) => + `${period.start_day} to ${period.end_day}\n${period.start_time} - ${period.end_time} ${timezone}` + ) + .join('\n') + } + + const checkIfWithinServiceHours = (serviceHours: any): boolean => { + // Simplified check - in production you'd want proper timezone handling + const now = new Date() + const currentHour = now.getHours() + const currentDay = now.getDay() // 0 = Sunday, 1 = Monday, etc. -export default LiveCallScreen + // Basic check for Monday-Friday, 7:30 AM - 5:00 PM + if (currentDay === 0 || currentDay === 6) return false // Weekend + if (currentHour < 7 || currentHour >= 17) return false // Outside hours + + return true + } + + const styles = StyleSheet.create({ + pageContainer: { + flex: 1, + justifyContent: 'space-between', + backgroundColor: ColorPalette.brand.primaryBackground, + padding: Spacing.md, + }, + contentContainer: { + flexGrow: 1, + }, + controlsContainer: { + gap: Spacing.sm, + marginTop: Spacing.md, + }, + }) + + const onPressContinue = async () => { + if (!serviceStatus.available) { + Alert.alert( + 'Service Unavailable', + 'Video calling service is currently unavailable. Please try again during service hours.', + [{ text: 'OK' }] + ) + return + } + + navigation.navigate(BCSCScreens.TakePhoto, { + forLiveCall: true, + deviceSide: 'front', + cameraInstructions: '', + cameraLabel: '', + }) + } + + const onPressAssistance = () => { + // TODO (bm) + } + + return ( + + + + Before you call + + Wi-Fi Recommended + + {isWifiEnabled ? '' : `The app detected you're on a cellular network. `}Standard data charges apply for calls + over a cellular network. + + + + Service Status + + + {isCheckingService ? 'Checking service availability...' : serviceStatus.message} + + + + Find a Private Place to Talk + + Make sure you'll be the only person in the video. + + + Hours of Service + + {serviceStatus.hoursText} + + Contact Centre Privacy + + {`During a video call, Service BC will ask for and collect personal information. The personal information you will provide is collected for the purpose of verification of your BC Services Card. This information is collected under the authority of Section 26(c) and 26(e) of the Freedom of Information and Protection of Privacy Act (FIPPA).`} + {`If you have further questions about privacy, please contact Chief Privacy Officer, 100 - 722 Johnson Street, Victoria, BC, V8W 1N1, or by phone\n250-405-3726`} + + + + + {`If you are having issues with audio or video, try out the following tips. If you're still having trouble, call us.`} + + + + /> - {`If you are having issues with audio or video, try out the following tips. If you're still having trouble, call us.`} + > + + + {`If you are having issues with audio or video, try out the following tips. If you're still having trouble, call us.`} - {`If you are having issues with audio or video, try out the following tips. If you're still having trouble, call us.`} + + {t('Unified.VideoCall.VerifyNotComplete.TroubleshootingTips')} +