about summary refs log tree commit diff
path: root/src/state/geolocation
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/geolocation')
-rw-r--r--src/state/geolocation/config.ts143
-rw-r--r--src/state/geolocation/const.ts30
-rw-r--r--src/state/geolocation/events.ts19
-rw-r--r--src/state/geolocation/index.tsx153
-rw-r--r--src/state/geolocation/logger.ts3
-rw-r--r--src/state/geolocation/types.ts9
-rw-r--r--src/state/geolocation/useRequestDeviceLocation.ts43
-rw-r--r--src/state/geolocation/useSyncedDeviceGeolocation.ts58
-rw-r--r--src/state/geolocation/util.ts180
9 files changed, 638 insertions, 0 deletions
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,
+    }
+  }
+}