diff options
author | Eric Bailey <git@esb.lol> | 2025-09-04 11:07:12 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-04 11:07:12 -0500 |
commit | f8ae0540a062e6346baf9fbf0481f769fb23a120 (patch) | |
tree | fc888e55258169e8e7246a1c099aab78fc7d9c99 /src/state | |
parent | 625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (diff) | |
download | voidsky-f8ae0540a062e6346baf9fbf0481f769fb23a120.tar.zst |
Provide geo-gated users optional GPS fallback for precise location data (#8973)
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/ageAssurance/index.tsx | 4 | ||||
-rw-r--r-- | src/state/ageAssurance/useInitAgeAssurance.ts | 4 | ||||
-rw-r--r-- | src/state/ageAssurance/useIsAgeAssuranceEnabled.ts | 4 | ||||
-rw-r--r-- | src/state/geolocation.tsx | 226 | ||||
-rw-r--r-- | src/state/geolocation/config.ts | 143 | ||||
-rw-r--r-- | src/state/geolocation/const.ts | 30 | ||||
-rw-r--r-- | src/state/geolocation/events.ts | 19 | ||||
-rw-r--r-- | src/state/geolocation/index.tsx | 153 | ||||
-rw-r--r-- | src/state/geolocation/logger.ts | 3 | ||||
-rw-r--r-- | src/state/geolocation/types.ts | 9 | ||||
-rw-r--r-- | src/state/geolocation/useRequestDeviceLocation.ts | 43 | ||||
-rw-r--r-- | src/state/geolocation/useSyncedDeviceGeolocation.ts | 58 | ||||
-rw-r--r-- | src/state/geolocation/util.ts | 180 |
13 files changed, 644 insertions, 232 deletions
diff --git a/src/state/ageAssurance/index.tsx b/src/state/ageAssurance/index.tsx index 23133046a..e85672b7c 100644 --- a/src/state/ageAssurance/index.tsx +++ b/src/state/ageAssurance/index.tsx @@ -11,7 +11,7 @@ import { } from '#/state/ageAssurance/types' import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' import {logger} from '#/state/ageAssurance/util' -import {useGeolocation} from '#/state/geolocation' +import {useGeolocationStatus} from '#/state/geolocation' import {useAgent} from '#/state/session' export const createAgeAssuranceQueryKey = (did: string) => @@ -43,7 +43,7 @@ AgeAssuranceAPIContext.displayName = 'AgeAssuranceAPIContext' */ export function Provider({children}: {children: React.ReactNode}) { const agent = useAgent() - const {geolocation} = useGeolocation() + const {status: geolocation} = useGeolocationStatus() const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() const getAndRegisterPushToken = useGetAndRegisterPushToken() const [refetchWhilePending, setRefetchWhilePending] = useState(false) diff --git a/src/state/ageAssurance/useInitAgeAssurance.ts b/src/state/ageAssurance/useInitAgeAssurance.ts index 8776dd29c..c8aaf70a3 100644 --- a/src/state/ageAssurance/useInitAgeAssurance.ts +++ b/src/state/ageAssurance/useInitAgeAssurance.ts @@ -14,7 +14,7 @@ import { import {isNetworkError} from '#/lib/hooks/useCleanError' import {logger} from '#/logger' import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' -import {useGeolocation} from '#/state/geolocation' +import {useGeolocationStatus} from '#/state/geolocation' import {useAgent} from '#/state/session' let APPVIEW = PUBLIC_APPVIEW @@ -36,7 +36,7 @@ let APPVIEW_DID = PUBLIC_APPVIEW_DID export function useInitAgeAssurance() { const qc = useQueryClient() const agent = useAgent() - const {geolocation} = useGeolocation() + const {status: geolocation} = useGeolocationStatus() return useMutation({ async mutationFn( props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>, diff --git a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts index b020e3c57..6e85edd0b 100644 --- a/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts +++ b/src/state/ageAssurance/useIsAgeAssuranceEnabled.ts @@ -1,9 +1,9 @@ import {useMemo} from 'react' -import {useGeolocation} from '#/state/geolocation' +import {useGeolocationStatus} from '#/state/geolocation' export function useIsAgeAssuranceEnabled() { - const {geolocation} = useGeolocation() + const {status: geolocation} = useGeolocationStatus() return useMemo(() => { return !!geolocation?.isAgeRestrictedGeo diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx deleted file mode 100644 index c4d8cb946..000000000 --- a/src/state/geolocation.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React from 'react' -import EventEmitter from 'eventemitter3' - -import {networkRetry} from '#/lib/async/retry' -import {logger} from '#/logger' -import {type Device, device} from '#/storage' - -const IPCC_URL = `https://bsky.app/ipcc` -const BAPP_CONFIG_URL = `https://ip.bsky.app/config` - -const events = new EventEmitter() -const EVENT = 'geolocation-updated' -const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { - events.emit(EVENT, geolocation) -} -const onGeolocationUpdate = ( - listener: (geolocation: Device['geolocation']) => void, -) => { - events.on(EVENT, listener) - return () => { - events.off(EVENT, listener) - } -} - -/** - * Default geolocation value. IF undefined, we fail closed and apply all - * additional mod authorities. - */ -export const DEFAULT_GEOLOCATION: Device['geolocation'] = { - countryCode: undefined, - isAgeBlockedGeo: undefined, - isAgeRestrictedGeo: false, -} - -function sanitizeGeolocation( - geolocation: Device['geolocation'], -): Device['geolocation'] { - return { - countryCode: geolocation?.countryCode ?? undefined, - isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false, - isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false, - } -} - -async function getGeolocation(url: string): Promise<Device['geolocation']> { - const res = await fetch(url) - - if (!res.ok) { - throw new Error(`geolocation: lookup failed ${res.status}`) - } - - const json = await res.json() - - if (json.countryCode) { - return { - countryCode: json.countryCode, - isAgeBlockedGeo: json.isAgeBlockedGeo ?? false, - isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, - // @ts-ignore - regionCode: json.regionCode ?? undefined, - } - } else { - return undefined - } -} - -async function compareWithIPCC(bapp: Device['geolocation']) { - try { - const ipcc = await getGeolocation(IPCC_URL) - - if (!ipcc || !bapp) return - - logger.metric( - 'geo:debug', - { - bappCountryCode: bapp.countryCode, - // @ts-ignore - bappRegionCode: bapp.regionCode, - bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo, - bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo, - ipccCountryCode: ipcc.countryCode, - ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo, - ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo, - }, - { - statsig: false, - }, - ) - } catch {} -} - -/** - * Local promise used within this file only. - */ -let geolocationResolution: Promise<{success: boolean}> | undefined - -/** - * Begin the process of resolving geolocation. This should be called once at - * app start. - * - * THIS METHOD SHOULD NEVER THROW. - * - * This method is otherwise not used for any purpose. To ensure geolocation is - * resolved, use {@link ensureGeolocationResolved} - */ -export function beginResolveGeolocation() { - /** - * In dev, IP server is unavailable, so we just set the default geolocation - * and fail closed. - */ - if (__DEV__) { - geolocationResolution = new Promise(y => y({success: true})) - if (!device.get(['geolocation'])) { - device.set(['geolocation'], DEFAULT_GEOLOCATION) - } - return - } - - geolocationResolution = new Promise(async resolve => { - let success = true - - try { - // Try once, fail fast - const geolocation = await getGeolocation(BAPP_CONFIG_URL) - if (geolocation) { - device.set(['geolocation'], sanitizeGeolocation(geolocation)) - emitGeolocationUpdate(geolocation) - logger.debug(`geolocation: success`, {geolocation}) - compareWithIPCC(geolocation) - } else { - // endpoint should throw on all failures, this is insurance - throw new Error(`geolocation: nothing returned from initial request`) - } - } catch (e: any) { - success = false - - logger.debug(`geolocation: failed initial request`, { - safeMessage: e.message, - }) - - // set to default - device.set(['geolocation'], DEFAULT_GEOLOCATION) - - // retry 3 times, but don't await, proceed with default - networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL)) - .then(geolocation => { - if (geolocation) { - device.set(['geolocation'], sanitizeGeolocation(geolocation)) - emitGeolocationUpdate(geolocation) - logger.debug(`geolocation: success`, {geolocation}) - success = true - compareWithIPCC(geolocation) - } else { - // endpoint should throw on all failures, this is insurance - throw new Error(`geolocation: nothing returned from retries`) - } - }) - .catch((e: any) => { - // complete fail closed - logger.debug(`geolocation: failed retries`, {safeMessage: e.message}) - }) - } finally { - resolve({success}) - } - }) -} - -/** - * Ensure that geolocation has been resolved, or at the very least attempted - * once. Subsequent retries will not be captured by this `await`. Those will be - * reported via {@link events}. - */ -export async function ensureGeolocationResolved() { - if (!geolocationResolution) { - throw new Error(`geolocation: beginResolveGeolocation not called yet`) - } - - const cached = device.get(['geolocation']) - if (cached) { - logger.debug(`geolocation: using cache`, {cached}) - } else { - logger.debug(`geolocation: no cache`) - const {success} = await geolocationResolution - if (success) { - logger.debug(`geolocation: resolved`, { - resolved: device.get(['geolocation']), - }) - } else { - logger.error(`geolocation: failed to resolve`) - } - } -} - -type Context = { - geolocation: Device['geolocation'] -} - -const context = React.createContext<Context>({ - geolocation: DEFAULT_GEOLOCATION, -}) -context.displayName = 'GeolocationContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [geolocation, setGeolocation] = React.useState(() => { - const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION - return initial - }) - - React.useEffect(() => { - return onGeolocationUpdate(geolocation => { - setGeolocation(geolocation!) - }) - }, []) - - const ctx = React.useMemo(() => { - return { - geolocation, - } - }, [geolocation]) - - return <context.Provider value={ctx}>{children}</context.Provider> -} - -export function useGeolocation() { - return React.useContext(context) -} diff --git a/src/state/geolocation/config.ts b/src/state/geolocation/config.ts new file mode 100644 index 000000000..1f7f2daf2 --- /dev/null +++ b/src/state/geolocation/config.ts @@ -0,0 +1,143 @@ +import {networkRetry} from '#/lib/async/retry' +import { + DEFAULT_GEOLOCATION_CONFIG, + GEOLOCATION_CONFIG_URL, +} from '#/state/geolocation/const' +import {emitGeolocationConfigUpdate} from '#/state/geolocation/events' +import {logger} from '#/state/geolocation/logger' +import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env' +import {type Device, device} from '#/storage' + +async function getGeolocationConfig( + url: string, +): Promise<Device['geolocation']> { + const res = await fetch(url, { + headers: IS_DEV + ? { + 'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET, + } + : undefined, + }) + + if (!res.ok) { + throw new Error(`geolocation config: fetch failed ${res.status}`) + } + + const json = await res.json() + + if (json.countryCode) { + /** + * Only construct known values here, ignore any extras. + */ + const config: Device['geolocation'] = { + countryCode: json.countryCode, + regionCode: json.regionCode ?? undefined, + ageRestrictedGeos: json.ageRestrictedGeos ?? [], + ageBlockedGeos: json.ageBlockedGeos ?? [], + } + logger.debug(`geolocation config: success`) + return config + } else { + return undefined + } +} + +/** + * Local promise used within this file only. + */ +let geolocationConfigResolution: Promise<{success: boolean}> | undefined + +/** + * Begin the process of resolving geolocation config. This should be called + * once at app start. + * + * THIS METHOD SHOULD NEVER THROW. + * + * This method is otherwise not used for any purpose. To ensure geolocation + * config is resolved, use {@link ensureGeolocationConfigIsResolved} + */ +export function beginResolveGeolocationConfig() { + /** + * Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing. + */ + // if (__DEV__) { + // geolocationConfigResolution = new Promise(y => y({success: true})) + // device.set(['deviceGeolocation'], undefined) // clears GPS data + // device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data + // return + // } + + geolocationConfigResolution = new Promise(async resolve => { + let success = true + + try { + // Try once, fail fast + const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL) + if (config) { + device.set(['geolocation'], config) + emitGeolocationConfigUpdate(config) + } else { + // endpoint should throw on all failures, this is insurance + throw new Error( + `geolocation config: nothing returned from initial request`, + ) + } + } catch (e: any) { + success = false + + logger.debug(`geolocation config: failed initial request`, { + safeMessage: e.message, + }) + + // set to default + device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) + + // retry 3 times, but don't await, proceed with default + networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL)) + .then(config => { + if (config) { + device.set(['geolocation'], config) + emitGeolocationConfigUpdate(config) + success = true + } else { + // endpoint should throw on all failures, this is insurance + throw new Error(`geolocation config: nothing returned from retries`) + } + }) + .catch((e: any) => { + // complete fail closed + logger.debug(`geolocation config: failed retries`, { + safeMessage: e.message, + }) + }) + } finally { + resolve({success}) + } + }) +} + +/** + * Ensure that geolocation config has been resolved, or at the very least attempted + * once. Subsequent retries will not be captured by this `await`. Those will be + * reported via {@link emitGeolocationConfigUpdate}. + */ +export async function ensureGeolocationConfigIsResolved() { + if (!geolocationConfigResolution) { + throw new Error( + `geolocation config: beginResolveGeolocationConfig not called yet`, + ) + } + + const cached = device.get(['geolocation']) + if (cached) { + logger.debug(`geolocation config: using cache`) + } else { + logger.debug(`geolocation config: no cache`) + const {success} = await geolocationConfigResolution + if (success) { + logger.debug(`geolocation config: resolved`) + } else { + logger.info(`geolocation config: failed to resolve`) + } + } +} diff --git a/src/state/geolocation/const.ts b/src/state/geolocation/const.ts new file mode 100644 index 000000000..789d001aa --- /dev/null +++ b/src/state/geolocation/const.ts @@ -0,0 +1,30 @@ +import {type GeolocationStatus} from '#/state/geolocation/types' +import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env' +import {type Device} from '#/storage' + +export const IPCC_URL = `https://bsky.app/ipcc` +export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config` +export const BAPP_CONFIG_URL = IS_DEV + ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD) + : BAPP_CONFIG_URL_PROD +export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL + +/** + * Default geolocation config. + */ +export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = { + countryCode: undefined, + regionCode: undefined, + ageRestrictedGeos: [], + ageBlockedGeos: [], +} + +/** + * Default geolocation status. + */ +export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = { + countryCode: undefined, + regionCode: undefined, + isAgeRestrictedGeo: false, + isAgeBlockedGeo: false, +} diff --git a/src/state/geolocation/events.ts b/src/state/geolocation/events.ts new file mode 100644 index 000000000..61433bb2a --- /dev/null +++ b/src/state/geolocation/events.ts @@ -0,0 +1,19 @@ +import EventEmitter from 'eventemitter3' + +import {type Device} from '#/storage' + +const events = new EventEmitter() +const EVENT = 'geolocation-config-updated' + +export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => { + events.emit(EVENT, config) +} + +export const onGeolocationConfigUpdate = ( + listener: (config: Device['geolocation']) => void, +) => { + events.on(EVENT, listener) + return () => { + events.off(EVENT, listener) + } +} diff --git a/src/state/geolocation/index.tsx b/src/state/geolocation/index.tsx new file mode 100644 index 000000000..d01e3f262 --- /dev/null +++ b/src/state/geolocation/index.tsx @@ -0,0 +1,153 @@ +import React from 'react' + +import { + DEFAULT_GEOLOCATION_CONFIG, + DEFAULT_GEOLOCATION_STATUS, +} from '#/state/geolocation/const' +import {onGeolocationConfigUpdate} from '#/state/geolocation/events' +import {logger} from '#/state/geolocation/logger' +import { + type DeviceLocation, + type GeolocationStatus, +} from '#/state/geolocation/types' +import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation' +import { + computeGeolocationStatus, + mergeGeolocation, +} from '#/state/geolocation/util' +import {type Device, device} from '#/storage' + +export * from '#/state/geolocation/config' +export * from '#/state/geolocation/types' +export * from '#/state/geolocation/util' + +type DeviceGeolocationContext = { + deviceGeolocation: DeviceLocation | undefined +} + +type DeviceGeolocationAPIContext = { + setDeviceGeolocation(deviceGeolocation: DeviceLocation): void +} + +type GeolocationConfigContext = { + config: Device['geolocation'] +} + +type GeolocationStatusContext = { + /** + * Merged geolocation from config and device GPS (if available). + */ + location: DeviceLocation + /** + * Computed geolocation status based on the merged location and config. + */ + status: GeolocationStatus +} + +const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({ + deviceGeolocation: undefined, +}) +DeviceGeolocationContext.displayName = 'DeviceGeolocationContext' + +const DeviceGeolocationAPIContext = + React.createContext<DeviceGeolocationAPIContext>({ + setDeviceGeolocation: () => {}, + }) +DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext' + +const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({ + config: DEFAULT_GEOLOCATION_CONFIG, +}) +GeolocationConfigContext.displayName = 'GeolocationConfigContext' + +const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({ + location: { + countryCode: undefined, + regionCode: undefined, + }, + status: DEFAULT_GEOLOCATION_STATUS, +}) +GeolocationStatusContext.displayName = 'GeolocationStatusContext' + +/** + * Provider of geolocation config and computed geolocation status. + */ +export function GeolocationStatusProvider({ + children, +}: { + children: React.ReactNode +}) { + const {deviceGeolocation} = React.useContext(DeviceGeolocationContext) + const [config, setConfig] = React.useState(() => { + const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG + return initial + }) + + React.useEffect(() => { + return onGeolocationConfigUpdate(config => { + setConfig(config!) + }) + }, []) + + const configContext = React.useMemo(() => ({config}), [config]) + const statusContext = React.useMemo(() => { + if (deviceGeolocation) { + logger.debug('geolocation: has device geolocation available') + } + const geolocation = mergeGeolocation(deviceGeolocation, config) + const status = computeGeolocationStatus(geolocation, config) + return {location: geolocation, status} + }, [config, deviceGeolocation]) + + return ( + <GeolocationConfigContext.Provider value={configContext}> + <GeolocationStatusContext.Provider value={statusContext}> + {children} + </GeolocationStatusContext.Provider> + </GeolocationConfigContext.Provider> + ) +} + +/** + * Provider of providers. Provides device geolocation data to lower-level + * `GeolocationStatusProvider`, and device geolocation APIs to children. + */ +export function Provider({children}: {children: React.ReactNode}) { + const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation() + + const handleSetDeviceGeolocation = React.useCallback( + (location: DeviceLocation) => { + logger.debug('geolocation: setting device geolocation') + setDeviceGeolocation({ + countryCode: location.countryCode ?? undefined, + regionCode: location.regionCode ?? undefined, + }) + }, + [setDeviceGeolocation], + ) + + return ( + <DeviceGeolocationAPIContext.Provider + value={React.useMemo( + () => ({setDeviceGeolocation: handleSetDeviceGeolocation}), + [handleSetDeviceGeolocation], + )}> + <DeviceGeolocationContext.Provider + value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}> + <GeolocationStatusProvider>{children}</GeolocationStatusProvider> + </DeviceGeolocationContext.Provider> + </DeviceGeolocationAPIContext.Provider> + ) +} + +export function useDeviceGeolocationApi() { + return React.useContext(DeviceGeolocationAPIContext) +} + +export function useGeolocationConfig() { + return React.useContext(GeolocationConfigContext) +} + +export function useGeolocationStatus() { + return React.useContext(GeolocationStatusContext) +} diff --git a/src/state/geolocation/logger.ts b/src/state/geolocation/logger.ts new file mode 100644 index 000000000..afda81136 --- /dev/null +++ b/src/state/geolocation/logger.ts @@ -0,0 +1,3 @@ +import {Logger} from '#/logger' + +export const logger = Logger.create(Logger.Context.Geolocation) diff --git a/src/state/geolocation/types.ts b/src/state/geolocation/types.ts new file mode 100644 index 000000000..174761649 --- /dev/null +++ b/src/state/geolocation/types.ts @@ -0,0 +1,9 @@ +export type DeviceLocation = { + countryCode: string | undefined + regionCode: string | undefined +} + +export type GeolocationStatus = DeviceLocation & { + isAgeRestrictedGeo: boolean + isAgeBlockedGeo: boolean +} diff --git a/src/state/geolocation/useRequestDeviceLocation.ts b/src/state/geolocation/useRequestDeviceLocation.ts new file mode 100644 index 000000000..64e05b056 --- /dev/null +++ b/src/state/geolocation/useRequestDeviceLocation.ts @@ -0,0 +1,43 @@ +import {useCallback} from 'react' +import * as Location from 'expo-location' + +import {type DeviceLocation} from '#/state/geolocation/types' +import {getDeviceGeolocation} from '#/state/geolocation/util' + +export {PermissionStatus} from 'expo-location' + +export function useRequestDeviceLocation(): () => Promise< + | { + granted: true + location: DeviceLocation | undefined + } + | { + granted: false + status: { + canAskAgain: boolean + /** + * Enum, use `PermissionStatus` export for comparisons + */ + permissionStatus: Location.PermissionStatus + } + } +> { + return useCallback(async () => { + const status = await Location.requestForegroundPermissionsAsync() + + if (status.granted) { + return { + granted: true, + location: await getDeviceGeolocation(), + } + } else { + return { + granted: false, + status: { + canAskAgain: status.canAskAgain, + permissionStatus: status.status, + }, + } + } + }, []) +} diff --git a/src/state/geolocation/useSyncedDeviceGeolocation.ts b/src/state/geolocation/useSyncedDeviceGeolocation.ts new file mode 100644 index 000000000..602f29a30 --- /dev/null +++ b/src/state/geolocation/useSyncedDeviceGeolocation.ts @@ -0,0 +1,58 @@ +import {useEffect, useRef} from 'react' +import * as Location from 'expo-location' + +import {logger} from '#/state/geolocation/logger' +import {getDeviceGeolocation} from '#/state/geolocation/util' +import {device, useStorage} from '#/storage' + +/** + * 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 useSyncedDeviceGeolocation() { + const synced = useRef(false) + const [status] = Location.useForegroundPermissions() + const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [ + 'deviceGeolocation', + ]) + + useEffect(() => { + async function get() { + // no need to set this more than once per session + if (synced.current) return + + logger.debug('useSyncedDeviceGeolocation: checking perms') + + if (status?.granted) { + const location = await getDeviceGeolocation() + if (location) { + logger.debug('useSyncedDeviceGeolocation: syncing location') + setDeviceGeolocation(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( + 'useSyncedDeviceGeolocation: clearing cached location, perms revoked', + ) + device.set(['deviceGeolocation'], undefined) + } + } + } + + get().catch(e => { + logger.error('useSyncedDeviceGeolocation: failed to sync', { + safeMessage: e, + }) + }) + }, [status, setDeviceGeolocation]) + + return [deviceGeolocation, setDeviceGeolocation] as const +} diff --git a/src/state/geolocation/util.ts b/src/state/geolocation/util.ts new file mode 100644 index 000000000..c92b42b13 --- /dev/null +++ b/src/state/geolocation/util.ts @@ -0,0 +1,180 @@ +import { + getCurrentPositionAsync, + type LocationGeocodedAddress, + reverseGeocodeAsync, +} from 'expo-location' + +import {logger} from '#/state/geolocation/logger' +import {type DeviceLocation} from '#/state/geolocation/types' +import {type Device} from '#/storage' + +/** + * 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 `DeviceLocation`. + * + * 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, +): DeviceLocation { + 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 mergeGeolocation( + location?: DeviceLocation, + config?: Device['geolocation'], +): DeviceLocation { + if (location?.countryCode) return location + return { + countryCode: config?.countryCode, + regionCode: config?.regionCode, + } +} + +/** + * Computes the geolocation status (age-restricted, age-blocked) based on the + * given location and geolocation config. `location` here should be merged with + * `mergeGeolocation()` ahead of time if needed. + */ +export function computeGeolocationStatus( + location: DeviceLocation, + config: Device['geolocation'], +) { + /** + * We can't do anything if we don't have this data. + */ + if (!location.countryCode) { + return { + ...location, + isAgeRestrictedGeo: false, + isAgeBlockedGeo: false, + } + } + + const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => { + if (rule.countryCode === location.countryCode) { + if (!rule.regionCode) { + return true // whole country is blocked + } else if (rule.regionCode === location.regionCode) { + return true + } + } + }) + + const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => { + if (rule.countryCode === location.countryCode) { + if (!rule.regionCode) { + return true // whole country is blocked + } else if (rule.regionCode === location.regionCode) { + return true + } + } + }) + + return { + ...location, + isAgeRestrictedGeo: !!isAgeRestrictedGeo, + isAgeBlockedGeo: !!isAgeBlockedGeo, + } +} + +export async function getDeviceGeolocation(): Promise<DeviceLocation> { + try { + const geocode = await getCurrentPositionAsync() + const locations = await 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, + } + } +} |