about summary refs log tree commit diff
path: root/src/state/geolocation/util.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/geolocation/util.ts')
-rw-r--r--src/state/geolocation/util.ts180
1 files changed, 180 insertions, 0 deletions
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,
+    }
+  }
+}