diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
index b2c220104b..f5b050d408 100644
--- a/src/ageAssurance/components/NoAccessScreen.tsx
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -4,7 +4,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
-import * as AgeRange from 'expo-age-range'
import {
SupportCode,
@@ -12,7 +11,7 @@ import {
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
-import {useSessionApi} from '#/state/session'
+import {useSession, useSessionApi} from '#/state/session'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -33,10 +32,17 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
-import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
+import {
+ getDeviceSignals,
+ setDeviceSignalsForDid,
+ useAgeAssuranceServerDataContext,
+} from '#/ageAssurance/data'
+import {logger} from '#/ageAssurance/logger'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
+ getAssuredAgeFromDeviceSignals,
isLegacyBirthdateBug,
+ regionAllowsDeviceVerification,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
@@ -308,6 +314,8 @@ function AccessSection() {
const getTimeAgo = useGetTimeAgo()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
+ const {currentAccount} = useSession()
+ const region = useAgeAssuranceRegionConfig()
const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state
@@ -320,6 +328,41 @@ function AccessSection() {
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
+ const openKwsDialog = useCallback(() => {
+ control.open()
+ ax.metric('ageAssurance:initDialogOpen', {
+ hasInitiatedPreviously: hasInitiated,
+ })
+ }, [control, ax, hasInitiated])
+
+ const onPressVerify = useCallback(async () => {
+ /*
+ * In regions that permit on-device verification, try the native age API
+ * first. If it returns a sufficient age, the cached signals flow into the
+ * AA state recompute and lift the gate. Otherwise we fall back to the KWS
+ * flow below. `getDeviceSignals` handles its own errors and returns
+ * undefined on failure, which also routes us to the fallback.
+ */
+ if (region && regionAllowsDeviceVerification(region)) {
+ const did = currentAccount?.did
+ const signals = await getDeviceSignals()
+ if (did) {
+ setDeviceSignalsForDid({did, signals})
+ }
+ const assuredAge = getAssuredAgeFromDeviceSignals(region, signals)
+ if (assuredAge !== undefined) {
+ // Sufficient device signals: AA state recomputes from the cache
+ // write above and unlocks access. Nothing else to do here.
+ return
+ }
+ logger.debug(
+ `onPressVerify: device signals insufficient, falling back to KWS`,
+ )
+ }
+
+ openKwsDialog()
+ }, [region, currentAccount?.did, openKwsDialog])
+
return (
<>
@@ -349,23 +392,7 @@ function AccessSection() {
label={_(msg`Verify now`)}
size="large"
color={hasInitiated ? 'secondary' : 'primary'}
- onPress={async () => {
- try {
- const ageRange = await AgeRange.requestAgeRangeAsync({
- threshold1: 10,
- threshold2: 13,
- threshold3: 18,
- });
- console.log(ageRange)
- } catch (e) {
- console.error(e)
- }
- return
- control.open()
- ax.metric('ageAssurance:initDialogOpen', {
- hasInitiatedPreviously: hasInitiated,
- })
- }}>
+ onPress={() => void onPressVerify()}>
{hasInitiated ? (
diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx
index 91e2f9f2d6..5167552fc7 100644
--- a/src/ageAssurance/data.tsx
+++ b/src/ageAssurance/data.tsx
@@ -1,4 +1,5 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
+import * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig,
@@ -11,7 +12,6 @@ import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persist
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce'
-import * as AgeRange from 'expo-age-range';
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
@@ -489,15 +489,18 @@ export function useOtherRequiredDataQuery() {
export function createDeviceSignalsQueryKey({did}: {did: string}) {
return ['device-signals', did]
}
-export async function getDeviceSignals(): Promise {
+export async function getDeviceSignals(): Promise<
+ AgeRange.AgeRangeResponse | undefined
+> {
if (debug.enabled) return debug.resolve(debug.deviceSignals)
try {
- return AgeRange.requestAgeRangeAsync({
+ return await AgeRange.requestAgeRangeAsync({
threshold1: 13,
threshold2: 16,
threshold3: 18,
- });
- } catch (e: any) {
+ })
+ } catch (err) {
+ const e = err as Error
logger.error(`getDeviceSignals: failed to get device signals`, {
safeMessage: e.message,
})
@@ -508,66 +511,60 @@ export function getDeviceSignalsFromCache({
did,
}: {
did: string
-}):
- | AgeRange.AgeRangeResponse
- | undefined {
+}): AgeRange.AgeRangeResponse | undefined {
return qc.getQueryData(
createDeviceSignalsQueryKey({did}),
)
}
-let deviceSignalsPrefetchPromise: Promise | undefined
-export async function prefetchDeviceSignals({
- agent,
+/**
+ * Writes freshly granted device signals into the (persisted) cache. Notifies
+ * the disabled `useDeviceSignalsQuery` observer so the AA state recomputes.
+ */
+export function setDeviceSignalsForDid({
+ did,
+ signals,
}: {
- agent: AtpAgent
+ did: string
+ signals: AgeRange.AgeRangeResponse | undefined
}) {
+ qc.setQueryData(
+ createDeviceSignalsQueryKey({did}),
+ signals,
+ )
+}
+export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
/**
- * If we don't have a cache, it's possible the user hasn't granted access.
- * We don't want to do this during the prefetch phase, so just exit early,
- * the user can potentially enable it later.
+ * Device signals are restored from the persisted cache only — we never call
+ * the native age API during prefetch, since that would prompt the OS for
+ * users who haven't opted in. Awaiting cache hydration ensures any previously
+ * granted signals are available before the AA state is first computed. The
+ * user can (re)grant access later via the NoAccessScreen verify flow.
*/
+ await cacheHydrationPromise
const cached = getDeviceSignalsFromCache({did})
- if (!cached) return
-
- if (deviceSignalsPrefetchPromise) {
- logger.debug(`prefetchDeviceSignals: already in progress`)
- return
- }
-
- deviceSignalsPrefetchPromise = new Promise(async resolve => {
- await cacheHydrationPromise
- const cached = getDeviceSignalsFromCache({did})
-
- if (cached) {
- logger.debug(`prefetchDeviceSignals: using cache`)
- resolve()
- } else {
- try {
- logger.debug(`prefetchDeviceSignals: resolving...`)
- const res = await getDeviceSignals()
- qc.setQueryData(
- createDeviceSignalsQueryKey({did}),
- res,
- )
- } catch (e: any) {
- logger.warn(`prefetchDeviceSignals: failed`, {
- safeMessage: e.message,
- })
- } finally {
- resolve()
- }
- }
- })
+ logger.debug(
+ `prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
+ )
}
export function useDeviceSignalsQuery() {
const agent = useAgent()
const did = getDidFromAgentSession(agent)
return useQuery(
{
- enabled: !!did,
+ /**
+ * Disabled so we never auto-call the native age API on load — that would
+ * prompt the OS for every logged-in user. We restore from the persisted
+ * cache (via `initialData`) and otherwise only update reactively when the
+ * user explicitly verifies (see `getDeviceSignals` + `setQueryData` in
+ * the NoAccessScreen verify flow).
+ *
+ * A future enhancement could silently refresh here when already cached,
+ * since the OS returns the granted result without re-prompting.
+ */
+ enabled: false,
initialData: getDeviceSignalsFromCache({did: did!}),
queryKey: createDeviceSignalsQueryKey({did: did!}),
async queryFn() {
@@ -620,6 +617,11 @@ export type AgeAssuranceServerData = {
*/
state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined
+ /**
+ * The native on-device age signals, if the user has granted access. Only
+ * consumed for regions that permit device verification.
+ */
+ deviceSignals: AgeRange.AgeRangeResponse | undefined
}
const AgeAssuranceServerDataContext = createContext({
config: undefined,
@@ -629,6 +631,7 @@ const AgeAssuranceServerDataContext = createContext({
declaredAge: undefined,
birthdate: undefined,
},
+ deviceSignals: undefined,
})
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
@@ -642,6 +645,7 @@ export function AgeAssuranceServerDataProvider({
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
+ const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo(
() => ({
config,
@@ -654,8 +658,9 @@ export function AgeAssuranceServerDataProvider({
: undefined,
birthdate: data?.birthdate,
},
+ deviceSignals,
}),
- [config, state, data, metadata],
+ [config, state, data, metadata, deviceSignals],
)
return (
diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts
index 1fdbd53d40..986d875f0b 100644
--- a/src/ageAssurance/debug.ts
+++ b/src/ageAssurance/debug.ts
@@ -1,14 +1,23 @@
import type * as AgeRange from 'expo-age-range'
import {
ageAssuranceRuleIDs as ids,
- type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetState,
} from '@atproto/api'
import {type OtherRequiredData} from '#/ageAssurance/data'
+import {type AgeAssuranceConfigRegion} from '#/ageAssurance/types'
import {IS_DEV, IS_E2E} from '#/env'
import {type Geolocation} from '#/geolocation'
+/**
+ * Debug-only config shape. Mirrors {@link AppBskyAgeassuranceDefs.Config} but
+ * uses {@link AgeAssuranceConfigRegion}, which carries the not-yet-in-lexicon
+ * `verificationMethods` field so we can prototype on-device verification.
+ */
+export type DebugConfig = {
+ regions: AgeAssuranceConfigRegion[]
+}
+
export const enabled = (IS_DEV && true) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
@@ -46,7 +55,7 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
}
: undefined
-export const config: AppBskyAgeassuranceDefs.Config = {
+export const config: DebugConfig = {
regions: [
{
countryCode: 'AA',
@@ -59,6 +68,25 @@ export const config: AppBskyAgeassuranceDefs.Config = {
},
],
},
+ {
+ // On-device verification region (e.g. Texas). Set debug.geolocation to
+ // {countryCode: 'US', regionCode: 'TX'} to exercise the device flow.
+ countryCode: 'US',
+ regionCode: 'TX',
+ minAccessAge: 18,
+ verificationMethods: ['device'],
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
{
countryCode: 'GB',
minAccessAge: 13,
@@ -262,7 +290,9 @@ const deviceSignalsEnabled = true
export const deviceSignals: AgeRange.AgeRangeResponse | undefined =
deviceSignalsEnabled
? {
- lowerBound: null,
+ // Simulates the OS reporting the user is at least 18. Lower this below
+ // a region's IfAssuredOverAge threshold to exercise the KWS fallback.
+ lowerBound: 18,
upperBound: null,
}
: undefined
diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts
index ff80bca725..69276c9737 100644
--- a/src/ageAssurance/state.ts
+++ b/src/ageAssurance/state.ts
@@ -1,4 +1,5 @@
import {useEffect, useMemo, useState} from 'react'
+import type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
@@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
getConfigFromCache,
+ getDeviceSignalsFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceServerDataContext,
@@ -24,6 +26,7 @@ import {
import {
computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback,
+ getAssuredAgeFromDeviceSignals,
} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -39,12 +42,14 @@ function computeAgeAssuranceState({
config,
state,
metadata,
+ deviceSignals,
}: {
hasSession: boolean
geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata
+ deviceSignals?: AgeRange.AgeRangeResponse
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -93,10 +98,16 @@ function computeAgeAssuranceState({
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
+ *
+ * In regions that permit on-device verification, the OS-provided age range
+ * is treated as an assured age and fed into the rule engine, where it
+ * matches `IfAssuredOverAge`/`IfAssuredUnderAge` rules.
*/
+ const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
+ assuredAge,
})
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
@@ -126,6 +137,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did})
+ const deviceSignals = getDeviceSignalsFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !requiredData) {
@@ -151,6 +163,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation,
state: state.state,
metadata,
+ deviceSignals,
})
return {
@@ -166,7 +179,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
- const {config, state, metadata} = useAgeAssuranceServerDataContext()
+ const {config, state, metadata, deviceSignals} =
+ useAgeAssuranceServerDataContext()
return useMemo(
() =>
@@ -176,8 +190,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation,
state,
metadata,
+ deviceSignals,
}),
- [hasSession, geolocation, config, state, metadata],
+ [hasSession, geolocation, config, state, metadata, deviceSignals],
)
}
diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts
index 21abff89f0..1215e888f2 100644
--- a/src/ageAssurance/types.ts
+++ b/src/ageAssurance/types.ts
@@ -1,7 +1,32 @@
-import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
+import {
+ type AppBskyAgeassuranceDefs,
+ type computeAgeAssuranceRegionAccess,
+} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
+/**
+ * The ways a user can satisfy age assurance within a given region.
+ *
+ * - `kws`: the third-party (KWS) verification flow.
+ * - `device`: native on-device age APIs (Apple Declared Age Range / Google
+ * Play Age Signals), surfaced via `expo-age-range`.
+ *
+ * NOTE: this is not yet part of the `app.bsky.ageassurance` lexicon. It's
+ * modeled client-side (see {@link AgeAssuranceConfigRegion}) while we prototype
+ * the shape. Once the lexicon adds `verificationMethods`, this can be removed in
+ * favor of the generated type.
+ */
+export type AgeAssuranceVerificationMethod = 'device' | 'kws'
+
+/**
+ * A region config extended with the (not-yet-in-lexicon) `verificationMethods`
+ * field. Regions without the field are treated as KWS-only.
+ */
+export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & {
+ verificationMethods?: AgeAssuranceVerificationMethod[]
+}
+
export enum AgeAssuranceAccess {
Unknown = 'unknown',
None = 'none',
diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts
index 0d493f1130..6c5b6478f1 100644
--- a/src/ageAssurance/util.ts
+++ b/src/ageAssurance/util.ts
@@ -1,4 +1,5 @@
import {useMemo} from 'react'
+import type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig,
@@ -11,9 +12,11 @@ import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {
AgeAssuranceAccess,
+ type AgeAssuranceConfigRegion,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
+ type AgeAssuranceVerificationMethod,
} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
@@ -36,6 +39,48 @@ export function getAgeAssuranceRegionConfigWithFallback(
return region || FALLBACK_REGION_CONFIG
}
+/**
+ * Returns the verification methods permitted for a region, defaulting to
+ * `['kws']` when the region doesn't specify any (the historical behavior).
+ *
+ * NOTE: `verificationMethods` is not yet part of the lexicon, so we read it via
+ * {@link AgeAssuranceConfigRegion}. See that type for the migration note.
+ */
+export function getRegionVerificationMethods(
+ region: AppBskyAgeassuranceDefs.ConfigRegion,
+): AgeAssuranceVerificationMethod[] {
+ const methods = (region as AgeAssuranceConfigRegion).verificationMethods
+ return methods && methods.length > 0 ? methods : ['kws']
+}
+
+/**
+ * Whether a region permits satisfying age assurance via the native on-device
+ * age APIs (Apple Declared Age Range / Google Play Age Signals).
+ */
+export function regionAllowsDeviceVerification(
+ region: AppBskyAgeassuranceDefs.ConfigRegion,
+): boolean {
+ return getRegionVerificationMethods(region).includes('device')
+}
+
+/**
+ * Derives an assured age from native device signals, but only for regions that
+ * permit device verification. The OS-provided `lowerBound` is the minimum age
+ * the platform will attest to, which maps directly onto the `assuredAge` input
+ * of the rule engine (i.e. `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
+ *
+ * Returns undefined when the region doesn't allow device verification or when
+ * the OS didn't provide a usable lower bound.
+ */
+export function getAssuredAgeFromDeviceSignals(
+ region: AppBskyAgeassuranceDefs.ConfigRegion,
+ deviceSignals: AgeRange.AgeRangeResponse | undefined,
+): number | undefined {
+ if (!regionAllowsDeviceVerification(region)) return undefined
+ const lowerBound = deviceSignals?.lowerBound
+ return typeof lowerBound === 'number' ? lowerBound : undefined
+}
+
/**
* Hook to get the age assurance region config based on current geolocation.
* Does not fall-back to our app defaults. If no config is found, returns