about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-09-04 11:07:12 -0500
committerGitHub <noreply@github.com>2025-09-04 11:07:12 -0500
commitf8ae0540a062e6346baf9fbf0481f769fb23a120 (patch)
treefc888e55258169e8e7246a1c099aab78fc7d9c99
parent625b4e61dbf11c1d485bf8e8265df4d5af0c9657 (diff)
downloadvoidsky-f8ae0540a062e6346baf9fbf0481f769fb23a120.tar.zst
Provide geo-gated users optional GPS fallback for precise location data (#8973)
-rw-r--r--.env.example6
-rw-r--r--app.config.js1
-rw-r--r--assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/pinLocation_stroke2_corner0_rounded.svg1
-rw-r--r--package.json1
-rw-r--r--src/App.native.tsx13
-rw-r--r--src/App.web.tsx13
-rw-r--r--src/components/BlockedGeoOverlay.tsx175
-rw-r--r--src/components/ageAssurance/AgeAssuranceAccountCard.tsx50
-rw-r--r--src/components/dialogs/DeviceLocationRequestDialog.tsx171
-rw-r--r--src/components/icons/PinLocation.tsx9
-rw-r--r--src/env/common.ts13
-rw-r--r--src/lib/currency.ts4
-rw-r--r--src/logger/types.ts1
-rw-r--r--src/state/ageAssurance/index.tsx4
-rw-r--r--src/state/ageAssurance/useInitAgeAssurance.ts4
-rw-r--r--src/state/ageAssurance/useIsAgeAssuranceEnabled.ts4
-rw-r--r--src/state/geolocation.tsx226
-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
-rw-r--r--src/storage/schema.ts25
-rw-r--r--src/view/shell/index.tsx4
-rw-r--r--src/view/shell/index.web.tsx4
-rw-r--r--yarn.lock5
31 files changed, 1075 insertions, 298 deletions
diff --git a/.env.example b/.env.example
index 4aefa3320..ac8dcab1f 100644
--- a/.env.example
+++ b/.env.example
@@ -33,3 +33,9 @@ EXPO_PUBLIC_SENTRY_DSN=
 
 # Bitdrift API key. If undefined, Bitdrift will be disabled.
 EXPO_PUBLIC_BITDRIFT_API_KEY=
+
+# bapp-config web worker URL
+BAPP_CONFIG_DEV_URL=
+
+# Dev-only passthrough value for bapp-config web worker
+BAPP_CONFIG_DEV_BYPASS_SECRET=
diff --git a/app.config.js b/app.config.js
index 8a0e6e48c..0b469a986 100644
--- a/app.config.js
+++ b/app.config.js
@@ -360,6 +360,7 @@ module.exports = function (_config) {
           },
         ],
         ['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
+        ['expo-location'],
       ].filter(Boolean),
       extra: {
         eas: {
diff --git a/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg b/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..becc18b12
--- /dev/null
+++ b/assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.591 21.806h.002l.001-.002.006-.004.018-.014a10 10 0 0 0 .304-.235 26 26 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a26 26 0 0 0 3.333 3.196 16 16 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/pinLocation_stroke2_corner0_rounded.svg b/assets/icons/pinLocation_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..11674c912
--- /dev/null
+++ b/assets/icons/pinLocation_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a26 26 0 0 1-3.333 3.197q-.152.12-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1 1 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a24 24 0 0 1-1.066-.877 26 26 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A24 24 0 0 0 12 19.723a24 24 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
diff --git a/package.json b/package.json
index 323fdce78..9e9c6d7c5 100644
--- a/package.json
+++ b/package.json
@@ -151,6 +151,7 @@
     "expo-linear-gradient": "~14.1.5",
     "expo-linking": "~7.1.5",
     "expo-localization": "~16.1.5",
+    "expo-location": "~18.1.6",
     "expo-media-library": "~17.1.7",
     "expo-notifications": "~0.31.3",
     "expo-screen-orientation": "~8.1.7",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index e22ab3f0e..036ecff60 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -32,8 +32,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
 import {Provider as EmailVerificationProvider} from '#/state/email-verification'
 import {listenSessionDropped} from '#/state/events'
 import {
-  beginResolveGeolocation,
-  ensureGeolocationResolved,
+  beginResolveGeolocationConfig,
+  ensureGeolocationConfigIsResolved,
   Provider as GeolocationProvider,
 } from '#/state/geolocation'
 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
@@ -91,7 +91,7 @@ if (isAndroid) {
 /**
  * Begin geolocation ASAP
  */
-beginResolveGeolocation()
+beginResolveGeolocationConfig()
 
 function InnerApp() {
   const [isReady, setIsReady] = React.useState(false)
@@ -203,9 +203,10 @@ function App() {
   const [isReady, setReady] = useState(false)
 
   React.useEffect(() => {
-    Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
-      setReady(true),
-    )
+    Promise.all([
+      initPersistedState(),
+      ensureGeolocationConfigIsResolved(),
+    ]).then(() => setReady(true))
   }, [])
 
   if (!isReady) {
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 17434bfcd..c86960172 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -21,8 +21,8 @@ import {Provider as DialogStateProvider} from '#/state/dialogs'
 import {Provider as EmailVerificationProvider} from '#/state/email-verification'
 import {listenSessionDropped} from '#/state/events'
 import {
-  beginResolveGeolocation,
-  ensureGeolocationResolved,
+  beginResolveGeolocationConfig,
+  ensureGeolocationConfigIsResolved,
   Provider as GeolocationProvider,
 } from '#/state/geolocation'
 import {Provider as HomeBadgeProvider} from '#/state/home-badge'
@@ -69,7 +69,7 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
 /**
  * Begin geolocation ASAP
  */
-beginResolveGeolocation()
+beginResolveGeolocationConfig()
 
 function InnerApp() {
   const [isReady, setIsReady] = React.useState(false)
@@ -178,9 +178,10 @@ function App() {
   const [isReady, setReady] = useState(false)
 
   React.useEffect(() => {
-    Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
-      setReady(true),
-    )
+    Promise.all([
+      initPersistedState(),
+      ensureGeolocationConfigIsResolved(),
+    ]).then(() => setReady(true))
   }, [])
 
   if (!isReady) {
diff --git a/src/components/BlockedGeoOverlay.tsx b/src/components/BlockedGeoOverlay.tsx
index ae5790da9..df8ed63d4 100644
--- a/src/components/BlockedGeoOverlay.tsx
+++ b/src/components/BlockedGeoOverlay.tsx
@@ -6,16 +6,27 @@ import {useLingui} from '@lingui/react'
 
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
+import {useDeviceGeolocationApi} from '#/state/geolocation'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
+import {Divider} from '#/components/Divider'
 import {Full as Logo, Mark} from '#/components/icons/Logo'
+import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
 import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link'
+import {Outlet as PortalOutlet} from '#/components/Portal'
+import * as Toast from '#/components/Toast'
 import {Text} from '#/components/Typography'
+import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
 
 export function BlockedGeoOverlay() {
   const t = useTheme()
   const {_} = useLingui()
   const {gtPhone} = useBreakpoints()
   const insets = useSafeAreaInsets()
+  const geoDialog = Dialog.useDialogControl()
+  const {setDeviceGeolocation} = useDeviceGeolocationApi()
 
   useEffect(() => {
     // just counting overall hits here
@@ -51,59 +62,133 @@ export function BlockedGeoOverlay() {
   ]
 
   return (
-    <ScrollView
-      contentContainerStyle={[
-        a.px_2xl,
-        {
-          paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
-          paddingBottom: 100,
-        },
-      ]}>
-      <View
-        style={[
-          a.mx_auto,
-          web({
-            maxWidth: 440,
-            paddingTop: gtPhone ? '8vh' : undefined,
-          }),
+    <>
+      <ScrollView
+        contentContainerStyle={[
+          a.px_2xl,
+          {
+            paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
+            paddingBottom: 100,
+          },
         ]}>
-        <View style={[a.align_start]}>
-          <View
-            style={[
-              a.pl_md,
-              a.pr_lg,
-              a.py_sm,
-              a.rounded_full,
-              a.flex_row,
-              a.align_center,
-              a.gap_xs,
-              {
-                backgroundColor: t.palette.primary_25,
-              },
-            ]}>
-            <Mark fill={t.palette.primary_600} width={14} />
-            <Text
+        <View
+          style={[
+            a.mx_auto,
+            web({
+              maxWidth: 380,
+              paddingTop: gtPhone ? '8vh' : undefined,
+            }),
+          ]}>
+          <View style={[a.align_start]}>
+            <View
               style={[
-                a.font_bold,
+                a.pl_md,
+                a.pr_lg,
+                a.py_sm,
+                a.rounded_full,
+                a.flex_row,
+                a.align_center,
+                a.gap_xs,
                 {
-                  color: t.palette.primary_600,
+                  backgroundColor: t.palette.primary_25,
                 },
               ]}>
-              <Trans>Announcement</Trans>
-            </Text>
+              <Mark fill={t.palette.primary_600} width={14} />
+              <Text
+                style={[
+                  a.font_bold,
+                  {
+                    color: t.palette.primary_600,
+                  },
+                ]}>
+                <Trans>Announcement</Trans>
+              </Text>
+            </View>
+          </View>
+
+          <View style={[a.gap_lg, {paddingTop: 32}]}>
+            {blocks.map((block, index) => (
+              <Text key={index} style={[textStyles]}>
+                {block}
+              </Text>
+            ))}
           </View>
-        </View>
 
-        <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}>
-          {blocks.map((block, index) => (
-            <Text key={index} style={[textStyles]}>
-              {block}
-            </Text>
-          ))}
+          {!isWeb && (
+            <>
+              <View style={[a.pt_2xl]}>
+                <Divider />
+              </View>
+
+              <View style={[a.mt_xl, a.align_start]}>
+                <Text
+                  style={[a.text_lg, a.font_heavy, a.leading_snug, a.pb_xs]}>
+                  <Trans>Not in Mississippi?</Trans>
+                </Text>
+                <Text
+                  style={[
+                    a.text_sm,
+                    a.leading_snug,
+                    t.atoms.text_contrast_medium,
+                    a.pb_md,
+                  ]}>
+                  <Trans>
+                    Confirm your location with GPS. Your location data is not
+                    tracked and does not leave your device.
+                  </Trans>
+                </Text>
+                <Button
+                  label={_(msg`Confirm your location`)}
+                  onPress={() => geoDialog.open()}
+                  size="small"
+                  color="primary_subtle">
+                  <ButtonIcon icon={LocationIcon} />
+                  <ButtonText>
+                    <Trans>Confirm your location</Trans>
+                  </ButtonText>
+                </Button>
+              </View>
+
+              <DeviceLocationRequestDialog
+                control={geoDialog}
+                onLocationAcquired={props => {
+                  if (props.geolocationStatus.isAgeBlockedGeo) {
+                    props.disableDialogAction()
+                    props.setDialogError(
+                      _(
+                        msg`We're sorry, but based on your device's location, you are currently located in a region we cannot provide access at this time.`,
+                      ),
+                    )
+                  } else {
+                    props.closeDialog(() => {
+                      // set this after close!
+                      setDeviceGeolocation({
+                        countryCode: props.geolocationStatus.countryCode,
+                        regionCode: props.geolocationStatus.regionCode,
+                      })
+                      Toast.show(_(msg`Thanks! You're all set.`), {
+                        type: 'success',
+                      })
+                    })
+                  }
+                }}
+              />
+            </>
+          )}
+
+          <View style={[{paddingTop: 48}]}>
+            <Logo width={120} textFill={t.atoms.text.color} />
+          </View>
         </View>
+      </ScrollView>
 
-        <Logo width={120} textFill={t.atoms.text.color} />
-      </View>
-    </ScrollView>
+      {/*
+       * While this blocking overlay is up, other dialogs in the shell
+       * are not mounted, so it _should_ be safe to use these here
+       * without fear of other modals showing up.
+       */}
+      <BottomSheetOutlet />
+      <PortalOutlet />
+    </>
   )
 }
diff --git a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
index a00a8c71a..be9935d9f 100644
--- a/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
+++ b/src/components/ageAssurance/AgeAssuranceAccountCard.tsx
@@ -3,8 +3,10 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {isNative} from '#/platform/detection'
 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance'
 import {logger} from '#/state/ageAssurance/util'
+import {useDeviceGeolocationApi} from '#/state/geolocation'
 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
 import {Admonition} from '#/components/Admonition'
 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -16,8 +18,10 @@ import {
 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
+import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog'
 import {Divider} from '#/components/Divider'
 import {createStaticClick, InlineLinkText} from '#/components/Link'
+import * as Toast from '#/components/Toast'
 import {Text} from '#/components/Typography'
 
 export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
@@ -35,8 +39,10 @@ function Inner({style}: ViewStyleProp & {}) {
   const {_, i18n} = useLingui()
   const control = useDialogControl()
   const appealControl = Dialog.useDialogControl()
+  const locationControl = Dialog.useDialogControl()
   const getTimeAgo = useGetTimeAgo()
   const {gtPhone} = useBreakpoints()
+  const {setDeviceGeolocation} = useDeviceGeolocationApi()
 
   const copy = useAgeAssuranceCopy()
   const {status, lastInitiatedAt} = useAgeAssurance()
@@ -71,8 +77,50 @@ function Inner({style}: ViewStyleProp & {}) {
             </View>
           </View>
 
-          <View style={[a.pb_md]}>
+          <View style={[a.pb_md, a.gap_xs]}>
             <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
+
+            {isNative && (
+              <>
+                <Text style={[a.text_sm, a.leading_snug]}>
+                  <Trans>
+                    Is your location not accurate?{' '}
+                    <InlineLinkText
+                      label={_(msg`Confirm your location`)}
+                      {...createStaticClick(() => {
+                        locationControl.open()
+                      })}>
+                      Click here to confirm your location.
+                    </InlineLinkText>{' '}
+                  </Trans>
+                </Text>
+
+                <DeviceLocationRequestDialog
+                  control={locationControl}
+                  onLocationAcquired={props => {
+                    if (props.geolocationStatus.isAgeRestrictedGeo) {
+                      props.disableDialogAction()
+                      props.setDialogError(
+                        _(
+                          msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`,
+                        ),
+                      )
+                    } else {
+                      props.closeDialog(() => {
+                        // set this after close!
+                        setDeviceGeolocation({
+                          countryCode: props.geolocationStatus.countryCode,
+                          regionCode: props.geolocationStatus.regionCode,
+                        })
+                        Toast.show(_(msg`Thanks! You're all set.`), {
+                          type: 'success',
+                        })
+                      })
+                    }
+                  }}
+                />
+              </>
+            )}
           </View>
 
           {isBlocked ? (
diff --git a/src/components/dialogs/DeviceLocationRequestDialog.tsx b/src/components/dialogs/DeviceLocationRequestDialog.tsx
new file mode 100644
index 000000000..6c82dc0e9
--- /dev/null
+++ b/src/components/dialogs/DeviceLocationRequestDialog.tsx
@@ -0,0 +1,171 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {
+  computeGeolocationStatus,
+  type GeolocationStatus,
+  useGeolocationConfig,
+} from '#/state/geolocation'
+import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export type Props = {
+  onLocationAcquired?: (props: {
+    geolocationStatus: GeolocationStatus
+    setDialogError: (error: string) => void
+    disableDialogAction: () => void
+    closeDialog: (callback?: () => void) => void
+  }) => void
+}
+
+export function DeviceLocationRequestDialog({
+  control,
+  onLocationAcquired,
+}: Props & {
+  control: Dialog.DialogOuterProps['control']
+}) {
+  const {_} = useLingui()
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+
+      <Dialog.ScrollableInner
+        label={_(msg`Confirm your location`)}
+        style={[web({maxWidth: 380})]}>
+        <DeviceLocationRequestDialogInner
+          onLocationAcquired={onLocationAcquired}
+        />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {close} = Dialog.useDialogContext()
+  const requestDeviceLocation = useRequestDeviceLocation()
+  const {config} = useGeolocationConfig()
+  const cleanError = useCleanError()
+
+  const [isRequesting, setIsRequesting] = useState(false)
+  const [error, setError] = useState<string>('')
+  const [dialogDisabled, setDialogDisabled] = useState(false)
+
+  const onPressConfirm = async () => {
+    setError('')
+    setIsRequesting(true)
+
+    try {
+      const req = await wait(1e3, requestDeviceLocation())
+
+      if (req.granted) {
+        const location = req.location
+
+        if (location && location.countryCode) {
+          const geolocationStatus = computeGeolocationStatus(location, config)
+          onLocationAcquired?.({
+            geolocationStatus,
+            setDialogError: setError,
+            disableDialogAction: () => setDialogDisabled(true),
+            closeDialog: close,
+          })
+        } else {
+          setError(_(msg`Failed to resolve location. Please try again.`))
+        }
+      } else {
+        setError(
+          _(
+            msg`Unable to access location. You'll need to visit your system settings to enable location services for Bluesky`,
+          ),
+        )
+      }
+    } catch (e: any) {
+      const {clean, raw} = cleanError(e)
+      setError(clean || raw || e.message)
+      if (!isNetworkError(e)) {
+        logger.error(`blockedGeoOverlay: unexpected error`, {
+          safeMessage: e.message,
+        })
+      }
+    } finally {
+      setIsRequesting(false)
+    }
+  }
+
+  return (
+    <View style={[a.gap_md]}>
+      <Text style={[a.text_xl, a.font_heavy]}>
+        <Trans>Confirm your location</Trans>
+      </Text>
+      <View style={[a.gap_sm, a.pb_xs]}>
+        <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Click below to allow Bluesky to access your GPS location. We will
+            then use that data to more accurately determine the content and
+            features available in your region.
+          </Trans>
+        </Text>
+
+        <Text
+          style={[
+            a.text_md,
+            a.leading_snug,
+            t.atoms.text_contrast_medium,
+            a.pb_xs,
+          ]}>
+          <Trans>
+            Your location data is not tracked and does not leave your device.
+          </Trans>
+        </Text>
+      </View>
+
+      {error && (
+        <View style={[a.pb_xs]}>
+          <Admonition type="error">{error}</Admonition>
+        </View>
+      )}
+
+      <View style={[a.gap_sm]}>
+        {!dialogDisabled && (
+          <Button
+            disabled={isRequesting}
+            label={_(msg`Confirm your location`)}
+            onPress={onPressConfirm}
+            size={isWeb ? 'small' : 'large'}
+            color="primary">
+            <ButtonIcon icon={isRequesting ? Loader : LocationIcon} />
+            <ButtonText>
+              <Trans>Allow location access</Trans>
+            </ButtonText>
+          </Button>
+        )}
+
+        {!isWeb && (
+          <Button
+            label={_(msg`Confirm your location`)}
+            onPress={() => close()}
+            size={isWeb ? 'small' : 'large'}
+            color="secondary">
+            <ButtonText>
+              <Trans>Cancel</Trans>
+            </ButtonText>
+          </Button>
+        )}
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/icons/PinLocation.tsx b/src/components/icons/PinLocation.tsx
new file mode 100644
index 000000000..9673995d7
--- /dev/null
+++ b/src/components/icons/PinLocation.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PinLocation_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a25.964 25.964 0 0 1-3.333 3.197c-.101.08-.181.142-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1.001 1.001 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a23.449 23.449 0 0 1-1.066-.877 25.973 25.973 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A23.978 23.978 0 0 0 12 19.723a23.976 23.976 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z',
+})
+
+export const PinLocationFilled_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12.591 21.806h.002l.001-.002.006-.004.018-.014a10.028 10.028 0 0 0 .304-.235 25.952 25.952 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a25.955 25.955 0 0 0 3.333 3.196 15.733 15.733 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
+})
diff --git a/src/env/common.ts b/src/env/common.ts
index 7b64c35a6..210ad037c 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -93,3 +93,16 @@ export const GCP_PROJECT_ID: number =
   process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined
     ? 0
     : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID)
+
+/**
+ * URL for the bapp-config web worker _development_ environment. Can be a
+ * locally running server, see `env.example` for more.
+ */
+export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL
+
+/**
+ * Dev environment passthrough value for bapp-config web worker. Allows local
+ * dev access to the web worker running in `development` mode.
+ */
+export const BAPP_CONFIG_DEV_BYPASS_SECRET: string =
+  process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
diff --git a/src/lib/currency.ts b/src/lib/currency.ts
index c43078a24..cc5a9a7b0 100644
--- a/src/lib/currency.ts
+++ b/src/lib/currency.ts
@@ -1,7 +1,7 @@
 import React from 'react'
 
 import {deviceLocales} from '#/locale/deviceLocales'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
 import {useLanguagePrefs} from '#/state/preferences'
 
 /**
@@ -275,7 +275,7 @@ export const countryCodeToCurrency: Record<string, string> = {
 export function useFormatCurrency(
   options?: Parameters<typeof Intl.NumberFormat>[1],
 ) {
-  const {geolocation} = useGeolocation()
+  const {location: geolocation} = useGeolocationStatus()
   const {appLanguage} = useLanguagePrefs()
   return React.useMemo(() => {
     const locale = deviceLocales.at(0)
diff --git a/src/logger/types.ts b/src/logger/types.ts
index ee3069a08..19e12c504 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -14,6 +14,7 @@ export enum LogContext {
   PostSource = 'post-source',
   AgeAssurance = 'age-assurance',
   PolicyUpdate = 'policy-update',
+  Geolocation = 'geolocation',
 
   /**
    * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
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,
+    }
+  }
+}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index a3f2336cf..d562d9fae 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -7,11 +7,32 @@ export type Device = {
   fontScale: '-2' | '-1' | '0' | '1' | '2'
   fontFamily: 'system' | 'theme'
   lastNuxDialog: string | undefined
+
+  /**
+   * Geolocation config, fetched from the IP service. This previously did
+   * double duty as the "status" for geolocation state, but that has since
+   * moved here to the client.
+   */
   geolocation?: {
     countryCode: string | undefined
-    isAgeRestrictedGeo: boolean | undefined
-    isAgeBlockedGeo: boolean | undefined
+    regionCode: string | undefined
+    ageRestrictedGeos: {
+      countryCode: string
+      regionCode: string | undefined
+    }[]
+    ageBlockedGeos: {
+      countryCode: string
+      regionCode: string | undefined
+    }[]
+  }
+  /**
+   * The GPS-based geolocation, if the user has granted permission.
+   */
+  deviceGeolocation?: {
+    countryCode: string | undefined
+    regionCode: string | undefined
   }
+
   trendingBetaEnabled: boolean
   devMode: boolean
   demoMode: boolean
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 8b4c65b8f..277e5c523 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,7 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
 import {isStateAtTabRoot} from '#/lib/routes/helpers'
 import {isAndroid, isIOS} from '#/platform/detection'
 import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
 import {useSession} from '#/state/session'
 import {
   useIsDrawerOpen,
@@ -184,7 +184,7 @@ function ShellInner() {
 
 export function Shell() {
   const t = useTheme()
-  const {geolocation} = useGeolocation()
+  const {status: geolocation} = useGeolocationStatus()
   const fullyExpandedCount = useDialogFullyExpandedCountContext()
 
   useIntentHandler()
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index bb09b5f62..268e77eae 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -9,7 +9,7 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {type NavigationProp} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
-import {useGeolocation} from '#/state/geolocation'
+import {useGeolocationStatus} from '#/state/geolocation'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -142,7 +142,7 @@ function ShellInner() {
 
 export function Shell() {
   const t = useTheme()
-  const {geolocation} = useGeolocation()
+  const {status: geolocation} = useGeolocationStatus()
   return (
     <View style={[a.util_screen_outer, t.atoms.bg]}>
       {geolocation?.isAgeBlockedGeo ? (
diff --git a/yarn.lock b/yarn.lock
index 20020f85d..c4b296d5d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11386,6 +11386,11 @@ expo-localization@~16.1.5:
   dependencies:
     rtl-detect "^1.0.2"
 
+expo-location@~18.1.6:
+  version "18.1.6"
+  resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631"
+  integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA==
+
 expo-manifests@~0.16.5:
   version "0.16.5"
   resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-0.16.5.tgz#bb57ceff3db4eb74679d4a155b2ca2050375ce10"