Age Assurance V2 (#9479)

* Age Assurance V2

* Tighten up test

* Add todos for sdk migration

* Align RQ versions

* Use useEffect for side effect

* Improve effects, memoize

* Standarize on birthdate

* Copy feedback

* Copilot

* Add support link

* Reove double ..

* Cleanup

* Remove redirect dialog

* Cleanup todos, add comments

* Update splash in main template too

* Mock some stuff

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Exhaustive checks

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Small fix to bday handling

* Add comment

* onboarding style tweak

sneaking this in sorry!

* rm unreachable breaks

* Put useIntentHandler back on web

* Remove misleading success set

* Align on birthdate

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2025-12-04 15:20:00 -06:00
committed by GitHub
parent 7735183af4
commit c4aef9f668
91 changed files with 3016 additions and 1799 deletions
+12
View File
@@ -0,0 +1,12 @@
import {BAPP_CONFIG_URL} from '#/env'
import {type Geolocation} from '#/geolocation/types'
export const GEOLOCATION_SERVICE_URL = `${BAPP_CONFIG_URL}/geolocation`
/**
* Default geolocation config.
*/
export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
countryCode: undefined,
regionCode: undefined,
}
+19
View File
@@ -0,0 +1,19 @@
import * as aaDebug from '#/ageAssurance/debug'
import {IS_DEV} from '#/env'
import {type Geolocation} from '#/geolocation/types'
const localEnabled = false
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
export const geolocation: Geolocation = aaDebug.geolocation ?? {
countryCode: 'AU',
regionCode: undefined,
}
export const deviceGeolocation: Geolocation = aaDebug.deviceGeolocation ?? {
countryCode: 'AU',
regionCode: undefined,
}
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 2000)) // simulate network
return data
}
+144
View File
@@ -0,0 +1,144 @@
import {useCallback, useEffect, useRef} from 'react'
import * as Location from 'expo-location'
import {createPermissionHook} from 'expo-modules-core'
import {isNative} from '#/platform/detection'
import * as debug from '#/geolocation/debug'
import {logger} from '#/geolocation/logger'
import {type Geolocation} from '#/geolocation/types'
import {normalizeDeviceLocation} from '#/geolocation/util'
import {device} from '#/storage'
/**
* Location.useForegroundPermissions on web just errors if the
* navigator.permissions API is not available. We need to catch and ignore it,
* since it's effectively denied.
*
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
*/
const useForegroundPermissions = createPermissionHook({
getMethod: () =>
Location.getForegroundPermissionsAsync().catch(error => {
logger.debug(
'useForegroundPermission: error getting location permissions',
{safeMessage: error},
)
return {
status: Location.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 0,
}
}),
requestMethod: () =>
Location.requestForegroundPermissionsAsync().catch(error => {
logger.debug(
'useForegroundPermission: error requesting location permissions',
{safeMessage: error},
)
return {
status: Location.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 0,
}
}),
})
export async function getDeviceGeolocation(): Promise<Geolocation> {
if (debug.enabled) return debug.resolve(debug.deviceGeolocation)
try {
const geocode = await Location.getCurrentPositionAsync()
const locations = await Location.reverseGeocodeAsync({
latitude: geocode.coords.latitude,
longitude: geocode.coords.longitude,
})
const location = locations.at(0)
const normalized = location ? normalizeDeviceLocation(location) : undefined
return {
countryCode: normalized?.countryCode ?? undefined,
regionCode: normalized?.regionCode ?? undefined,
}
} catch (e) {
logger.error('getDeviceGeolocation: failed', {safeMessage: e})
return {
countryCode: undefined,
regionCode: undefined,
}
}
}
export function useRequestDeviceGeolocation(): () => Promise<
| {
granted: true
location: Geolocation | undefined
}
| {
granted: false
}
> {
return useCallback(async () => {
const status = await Location.requestForegroundPermissionsAsync()
if (status.granted) {
return {
granted: true,
location: await getDeviceGeolocation(),
}
} else {
return {
granted: false,
}
}
}, [])
}
/**
* Hook to get and sync the device geolocation from the device GPS and store it
* using device storage. If permissions are not granted, it will clear any cached
* storage value.
*/
export function useSyncDeviceGeolocationOnStartup(
sync: (location: Geolocation | undefined) => void,
) {
const synced = useRef(false)
const [status] = useForegroundPermissions()
useEffect(() => {
if (!isNative) return
async function get() {
// no need to set this more than once per session
if (synced.current) return
logger.debug('useSyncDeviceGeolocationOnStartup: checking perms')
if (status?.granted) {
const location = await getDeviceGeolocation()
if (location) {
logger.debug('useSyncDeviceGeolocationOnStartup: got location')
sync(location)
synced.current = true
}
} else {
const hasCachedValue = device.get(['deviceGeolocation']) !== undefined
/**
* If we have a cached value, but user has revoked permissions,
* quietly (will take effect lazily) clear this out.
*/
if (hasCachedValue) {
logger.debug(
'useSyncDeviceGeolocationOnStartup: clearing cached location, perms revoked',
)
device.set(['deviceGeolocation'], undefined)
}
}
}
get().catch(e => {
logger.error(
'useSyncDeviceGeolocationOnStartup: failed to get location',
{
safeMessage: e,
},
)
})
}, [status, sync])
}
+65
View File
@@ -0,0 +1,65 @@
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
} from 'react'
import {useSyncDeviceGeolocationOnStartup} from '#/geolocation/device'
import {useGeolocationServiceResponse} from '#/geolocation/service'
import {type Geolocation} from '#/geolocation/types'
import {mergeGeolocations} from '#/geolocation/util'
import {device, useStorage} from '#/storage'
export {useRequestDeviceGeolocation} from '#/geolocation/device'
export {resolve} from '#/geolocation/service'
export * from '#/geolocation/types'
const GeolocationContext = createContext<Geolocation>({
countryCode: undefined,
regionCode: undefined,
})
const DeviceGeolocationAPIContext = createContext<{
setDeviceGeolocation(deviceGeolocation: Geolocation): void
}>({
setDeviceGeolocation: () => {},
})
export function useGeolocation() {
return useContext(GeolocationContext)
}
export function useDeviceGeolocationApi() {
return useContext(DeviceGeolocationAPIContext)
}
export function Provider({children}: {children: ReactNode}) {
const geolocationService = useGeolocationServiceResponse()
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
'deviceGeolocation',
])
const geolocation = useMemo(() => {
return mergeGeolocations(deviceGeolocation, geolocationService)
}, [deviceGeolocation, geolocationService])
useEffect(() => {
/**
* Save this for out-of-band-reads during future cold starts of the app.
* Needs to be available for the data prefetching we do on boot.
*/
device.set(['mergedGeolocation'], geolocation)
}, [geolocation])
useSyncDeviceGeolocationOnStartup(setDeviceGeolocation)
return (
<GeolocationContext.Provider value={geolocation}>
<DeviceGeolocationAPIContext.Provider
value={useMemo(() => ({setDeviceGeolocation}), [setDeviceGeolocation])}>
{children}
</DeviceGeolocationAPIContext.Provider>
</GeolocationContext.Provider>
)
}
+3
View File
@@ -0,0 +1,3 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.Geolocation)
+136
View File
@@ -0,0 +1,136 @@
import {useEffect, useState} from 'react'
import EventEmitter from 'eventemitter3'
import {networkRetry} from '#/lib/async/retry'
import {
FALLBACK_GEOLOCATION_SERVICE_RESPONSE,
GEOLOCATION_SERVICE_URL,
} from '#/geolocation/const'
import * as debug from '#/geolocation/debug'
import {logger} from '#/geolocation/logger'
import {type Geolocation} from '#/geolocation/types'
import {device} from '#/storage'
const events = new EventEmitter()
const EVENT = 'geolocation-service-response-updated'
const emitGeolocationServiceResponseUpdate = (data: Geolocation) => {
events.emit(EVENT, data)
}
const onGeolocationServiceResponseUpdate = (
listener: (data: Geolocation) => void,
) => {
events.on(EVENT, listener)
return () => {
events.off(EVENT, listener)
}
}
async function fetchGeolocationServiceData(
url: string,
): Promise<Geolocation | undefined> {
if (debug.enabled) return debug.resolve(debug.geolocation)
const res = await fetch(url)
if (!res.ok) {
throw new Error(`fetchGeolocationServiceData failed ${res.status}`)
}
return res.json() as Promise<Geolocation>
}
/**
* Local promise used within this file only.
*/
let geolocationServicePromise: Promise<{success: boolean}> | undefined
/**
* Begin the process of resolving geolocation config. This is called right away
* at app start, and the promise is awaited later before proceeding with app
* startup.
*/
export async function resolve() {
if (geolocationServicePromise) {
const cached = device.get(['geolocationServiceResponse'])
if (cached) {
logger.debug(`resolve(): using cache`)
} else {
logger.debug(`resolve(): no cache`)
const {success} = await geolocationServicePromise
if (success) {
logger.debug(`resolve(): resolved`)
} else {
logger.info(`resolve(): failed`)
}
}
} else {
logger.debug(`resolve(): initiating`)
/**
* THIS PROMISE SHOULD NEVER `reject()`! We want the app to proceed with
* startup, even if geolocation resolution fails.
*/
geolocationServicePromise = new Promise(async resolve => {
let success = false
function cacheResponseOrThrow(response: Geolocation | undefined) {
if (response) {
device.set(['geolocationServiceResponse'], response)
emitGeolocationServiceResponseUpdate(response)
} else {
// endpoint should throw on all failures, this is insurance
throw new Error(`fetchGeolocationServiceData returned no data`)
}
}
try {
// Try once, fail fast
const config = await fetchGeolocationServiceData(
GEOLOCATION_SERVICE_URL,
)
cacheResponseOrThrow(config)
success = true
} catch (e: any) {
logger.debug(
`resolve(): fetchGeolocationServiceData failed initial request`,
{
safeMessage: e.message,
},
)
// retry 3 times, but don't await, proceed with default
networkRetry(3, () =>
fetchGeolocationServiceData(GEOLOCATION_SERVICE_URL),
)
.then(config => {
cacheResponseOrThrow(config)
})
.catch((e: any) => {
// complete fail closed
logger.debug(
`resolve(): fetchGeolocationServiceData failed retries`,
{
safeMessage: e.message,
},
)
})
} finally {
resolve({success})
}
})
}
}
export function useGeolocationServiceResponse() {
const [config, setConfig] = useState(() => {
const initial =
device.get(['geolocationServiceResponse']) ||
FALLBACK_GEOLOCATION_SERVICE_RESPONSE
return initial
})
useEffect(() => {
return onGeolocationServiceResponseUpdate(config => {
setConfig(config!)
})
}, [])
return config
}
+4
View File
@@ -0,0 +1,4 @@
export type Geolocation = {
countryCode: string | undefined
regionCode: string | undefined
}
+113
View File
@@ -0,0 +1,113 @@
import {type LocationGeocodedAddress} from 'expo-location'
import {logger} from '#/geolocation/logger'
import {type Geolocation} from '#/geolocation/types'
/**
* Maps full US region names to their short codes.
*
* Context: in some cases, like on Android, we get the full region name instead
* of the short code. We may need to expand this in the future to other
* countries, hence the prefix.
*/
export const USRegionNameToRegionCode: {
[regionName: string]: string
} = {
Alabama: 'AL',
Alaska: 'AK',
Arizona: 'AZ',
Arkansas: 'AR',
California: 'CA',
Colorado: 'CO',
Connecticut: 'CT',
Delaware: 'DE',
Florida: 'FL',
Georgia: 'GA',
Hawaii: 'HI',
Idaho: 'ID',
Illinois: 'IL',
Indiana: 'IN',
Iowa: 'IA',
Kansas: 'KS',
Kentucky: 'KY',
Louisiana: 'LA',
Maine: 'ME',
Maryland: 'MD',
Massachusetts: 'MA',
Michigan: 'MI',
Minnesota: 'MN',
Mississippi: 'MS',
Missouri: 'MO',
Montana: 'MT',
Nebraska: 'NE',
Nevada: 'NV',
['New Hampshire']: 'NH',
['New Jersey']: 'NJ',
['New Mexico']: 'NM',
['New York']: 'NY',
['North Carolina']: 'NC',
['North Dakota']: 'ND',
Ohio: 'OH',
Oklahoma: 'OK',
Oregon: 'OR',
Pennsylvania: 'PA',
['Rhode Island']: 'RI',
['South Carolina']: 'SC',
['South Dakota']: 'SD',
Tennessee: 'TN',
Texas: 'TX',
Utah: 'UT',
Vermont: 'VT',
Virginia: 'VA',
Washington: 'WA',
['West Virginia']: 'WV',
Wisconsin: 'WI',
Wyoming: 'WY',
}
/**
* Normalizes a `LocationGeocodedAddress` into a `Geolocation`.
*
* We don't want or care about the full location data, so we trim it down and
* normalize certain fields, like region, into the format we need.
*/
export function normalizeDeviceLocation(
location: LocationGeocodedAddress,
): Geolocation {
let {isoCountryCode, region} = location
if (region) {
if (isoCountryCode === 'US') {
region = USRegionNameToRegionCode[region] ?? region
}
}
return {
countryCode: isoCountryCode ?? undefined,
regionCode: region ?? undefined,
}
}
/**
* Combines precise location data with the geolocation config fetched from the
* IP service, with preference to the precise data.
*/
export function mergeGeolocations(
device?: Geolocation,
geolocationService?: Geolocation,
): Geolocation {
let geolocation: Geolocation = {
countryCode: geolocationService?.countryCode ?? undefined,
regionCode: geolocationService?.regionCode ?? undefined,
}
// prefer GPS
if (device?.countryCode) {
geolocation = device
}
logger.debug('merged geolocation data', {
device,
service: geolocationService,
merged: geolocation,
})
return geolocation
}