import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, AppState, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StatusBar, StyleSheet, Text, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { Icon } from '../../../components/Icon'; import LinearGradient from 'react-native-linear-gradient'; import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated'; import { Button, InfoBanner, Input } from '../../../components'; import { useAppDispatch, useAppSelector } from '../../../app/hooks'; import { useTheme } from '../../../theme'; import { useApiErrorToast } from '../../../hooks/useApiErrorToast'; import { isValidPhone } from '../../../utils/validation'; import { simCard, type SimInfo, type SimSubscription } from '../../../services/device/simCard'; import { BrandLogo } from '../components/BrandLogo'; import { SimChooser } from '../components/SimChooser'; import { authActions } from '../store/authSlice'; import { selectLastProfile, selectRegistration } from '../store/authSelectors'; import { rememberRegisteredProfile, startRegistration } from '../store/authThunk'; import type { AuthStackParamList } from '../../../navigation/types'; type Props = Readonly>; /** * Three short marks of what this flow is, in the shape a bank's onboarding * screen usually carries them. * * Each one is literally true of this app, which is the only reason it is * allowed on the screen: the MPIN really is RSA-OAEP encrypted before it leaves * the handset, the number really is read off the SIM when the phone will share * it, and the profile really is bound to this device. Nothing here claims a * certification, an audit, or a regulator — those would be invented, and an * invented security claim on a bank's sign-up screen is a straightforward lie * to a customer. */ const TRUST_MARKS = [ { icon: 'shield-checkmark' as const, label: 'Secure' }, { icon: 'flash' as const, label: 'Seamless' }, { icon: 'phone-portrait' as const, label: 'Trusted device' }, ]; export default function RegisterScreen({ navigation }: Props) { const dispatch = useAppDispatch(); const theme = useTheme(); const showApiError = useApiErrorToast(); const registration = useAppSelector(selectRegistration); const lastProfile = useAppSelector(selectLastProfile); const [sim, setSim] = useState(null); /** Which SIM's code request is in flight, so only that row spins. */ const [sendingId, setSendingId] = useState(null); const [detecting, setDetecting] = useState(true); const [submitted, setSubmitted] = useState(false); /** Bumped to re-read the SIMs. Never prompts — see the mount effect. */ const [attempt, setAttempt] = useState(0); /** * Android has stopped showing the permission dialog for this app. Asking * again would return instantly and change nothing, so the only honest next * step is app settings. */ const [permissionBlocked, setPermissionBlocked] = useState(false); /** * The typed-number fallback. * * Reading the SIM covers the common case, but a carrier that never wrote the * MSISDN onto the card, a denied permission and a phone with no SIM in it * each ended the flow outright — the screen said to contact the bank, * because there was nowhere to put a number. It can be typed now, so none of * those three is a dead end. */ const [manualOpen, setManualOpen] = useState(false); const [manualNumber, setManualNumber] = useState(''); const isLoading = registration.status === 'loading'; const sims = sim?.sims ?? []; // A line whose number the carrier never provisioned cannot be tapped — there // is nothing to send from it. Typing the number is the way past that. const tappable = sims.filter((entry) => entry.phoneNumber); /** * Reads on mount, and never prompts here. * * This used to call a `detect()` that raised the system permission dialog the * instant the screen appeared — before the customer had read a word of what * the screen was for, which is precisely how an app trains people to refuse. * The prompt now belongs to a button the customer presses knowing what it * will do. */ useEffect(() => { let cancelled = false; setDetecting(true); void simCard.read().then((info) => { if (cancelled) { return; } setSim(info); setDetecting(false); }); return () => { cancelled = true; }; }, [attempt]); // Coming back from app settings is the one case where the answer changes // while this screen is mounted but nothing here was touched. Re-reading on // foreground is what makes the settings round-trip actually finish. useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') { setAttempt((value) => value + 1); } }); return () => subscription.remove(); }, []); useEffect(() => { if (!submitted) { return; } if (registration.status === 'succeeded' && registration.otpSent) { setSubmitted(false); setSendingId(null); navigation.navigate('VerifyOtp'); return; } if (registration.status === 'failed' && registration.error) { setSubmitted(false); setSendingId(null); showApiError(registration.error); // Registration is already complete for this number — what the customer // wants is sign-in, not another OTP. if ( registration.error.code === 'ALREADY_REGISTERED' || registration.error.code === 'MPIN_ALREADY_SET' ) { // Recorded before the reset clears the number, and before the navigate // below: sign-in reads the stored profile to tell a device that has // completed registration from one that never has, and without it this // number would be sent straight back here for another OTP. void dispatch( rememberRegisteredProfile({ orgId: registration.orgId, mobileNumber: registration.mobileNumber, }), ); dispatch(authActions.resetRegistration()); navigation.navigate('Login'); } } }, [dispatch, navigation, registration, showApiError, submitted]); /** One tap sends the code — there is no separate submit step any more. */ const handleSelectSim = (chosen: SimSubscription) => { const number = chosen.phoneNumber; if (!number || isLoading) { return; } setSendingId(chosen.subscriptionId); setSubmitted(true); // orgId is fixed for this build and comes from config, so there is no field // for it. The chosen SIM is not sent either: the API takes a number alone. void dispatch(startRegistration({ mobileNumber: number })); }; // Ten digits or nothing — the same contract the SIM read normalises to, so a // typed number and a read one reach the API in one shape. const manualDigits = manualNumber.replace(/D/g, ''); /** Silent until there is something to be wrong about, as sign-in is. */ const manualError = (() => { if (manualDigits.length === 0 || isValidPhone(manualDigits)) { return undefined; } return 'Enter the 10-digit number your bank has on file.'; })(); const handleManualChange = (value: string) => { setManualNumber(value.replace(/D/g, '')); }; const handleManualSubmit = () => { if (!isValidPhone(manualDigits) || isLoading) { return; } // Left null on purpose: no SIM row is sending, so none of them should spin. setSendingId(null); setSubmitted(true); void dispatch(startRegistration({ mobileNumber: manualDigits })); }; /** * Why there is nothing to tap, and what the customer can do about it. Null * whenever at least one line carries a readable number. */ const blocker = (() => { if (detecting || !sim || tappable.length > 0) { return null; } if (!sim.hasPermission) { return { icon: 'lock-closed-outline' as const, title: 'Allow phone access', text: 'Your number is read straight from the SIM, so you never have to type it. Nothing else on this phone is read.', action: 'Allow phone access', kind: 'permission' as const, }; } /* * The slots listed but no number came back, and the reason is a permission * this app has not been given — not the carrier. * * This case had no branch at all before, so it fell through to the carrier * message below and told the customer to visit a branch. On Android 13 and * up it is the likeliest failure of the three, because READ_PHONE_NUMBERS * is a separate grant from READ_PHONE_STATE. */ if (!sim.canReadNumbers) { return { icon: 'key-outline' as const, title: 'One more permission needed', text: 'This phone keeps the SIM number behind its own permission. Allow it and your number is filled in for you.', action: 'Allow and continue', kind: 'permission' as const, }; } if (!sim.hasSim) { return { icon: 'phone-portrait-outline' as const, title: 'No SIM found', text: 'Put the SIM whose number your bank has into this phone, then check again.', action: 'Check again', kind: 'retry' as const, }; } // The ordinary case: a SIM is present, the app may read numbers, and the // carrier still never wrote one onto the SIM — so there is nothing to read // and nowhere to send a code. return { icon: 'information-circle-outline' as const, title: 'Your carrier does not share this number', text: sims.length > 1 ? 'Neither line lets apps read its number, so there is nothing to tap. Type the number in and the code still comes through.' : 'This SIM does not let apps read its number, so there is nothing to tap. Type the number in and the code still comes through.', action: 'Check again', kind: 'retry' as const, }; })(); /** True once Android has stopped offering the dialog for a permission ask. */ const needsSettings = blocker?.kind === 'permission' && permissionBlocked; const handleBlockerAction = useCallback(async () => { if (blocker?.kind !== 'permission') { setAttempt((value) => value + 1); return; } if (permissionBlocked) { // The AppState listener above re-reads when the customer comes back. void Linking.openSettings(); return; } const { outcome, info } = await simCard.requestAndRead(); setSim(info); if (outcome === 'blocked') { setPermissionBlocked(true); } }, [blocker?.kind, permissionBlocked]); const numberError = registration.error?.fieldErrors?.mobileNumber; const manualLink = ( setManualOpen(true)} hitSlop={6}> Number not showing?{' '} Type it instead ); /* A solid surface, not the translucent `onBrandSurface` the blocker sits on. The field inside carries the design system's light-context colours — a dark-grey label over half-transparent white over navy is not readable — so the card brings its own light surface, exactly as the InfoBanner does. */ const manualCard = ( Enter your mobile number The number your bank holds for you. We'll text a one-time code to it.