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:
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {Logger} from '#/logger'
|
||||
|
||||
export const logger = Logger.create(Logger.Context.Geolocation)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Geolocation = {
|
||||
countryCode: string | undefined
|
||||
regionCode: string | undefined
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user