[AAv2] Integrate on-device APIs (#9564)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+238
-13
@@ -1,13 +1,20 @@
|
||||
import {useMemo} from 'react'
|
||||
import type * as AgeRange from 'expo-age-range'
|
||||
import {
|
||||
type AppBskyAgeassuranceDefs,
|
||||
AppBskyAgeassuranceDefs,
|
||||
computeAgeAssuranceRegionAccess,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {
|
||||
DEVICE_SIGNALS_SUPPORTED,
|
||||
FALLBACK_REGION_CONFIG,
|
||||
MIN_ACCESS_AGE,
|
||||
} from '#/ageAssurance/const'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
@@ -16,24 +23,213 @@ import {
|
||||
type AgeAssuranceState,
|
||||
} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
import {USRegionNameToRegionCode} from '#/geolocation/util'
|
||||
|
||||
/**
|
||||
* Resolves a geolocation to its matched age assurance region config, or
|
||||
* undefined when the geolocation matches no AA region.
|
||||
*
|
||||
* This is the single source of truth for geolocation -> region resolution.
|
||||
* Device signals are written and read back under a key derived from the
|
||||
* matched region (see `createRegionKey`), so every site that resolves a region
|
||||
* for that purpose MUST go through this helper - independent re-implementations
|
||||
* risk desyncing the write and read keys and silently losing grants.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigForGeolocation(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion | undefined {
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
*
|
||||
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
|
||||
* return undefined if the geolocation does not match any AA region.
|
||||
* See {@link getAgeAssuranceRegionConfigForGeolocation} for the generic option,
|
||||
* which can return undefined if the geolocation does not match any AA region.
|
||||
*/
|
||||
export function getAgeAssuranceRegionConfigWithFallback(
|
||||
config: AppBskyAgeassuranceDefs.Config,
|
||||
geolocation: Geolocation,
|
||||
): AppBskyAgeassuranceDefs.ConfigRegion {
|
||||
const region = getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
return (
|
||||
getAgeAssuranceRegionConfigForGeolocation(config, geolocation) ||
|
||||
FALLBACK_REGION_CONFIG
|
||||
)
|
||||
}
|
||||
|
||||
return region || FALLBACK_REGION_CONFIG
|
||||
/**
|
||||
* Returns the verification methods permitted for a region *in addition to* the
|
||||
* always-supported KWS flow. Empty when the region doesn't specify any (the
|
||||
* historical KWS-only behavior).
|
||||
*/
|
||||
export function getRegionAdditionalVerificationMethods(
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||
): NonNullable<
|
||||
AppBskyAgeassuranceDefs.ConfigRegion['additionalVerificationMethods']
|
||||
> {
|
||||
return region.additionalVerificationMethods ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 getRegionAdditionalVerificationMethods(region).includes('device')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the cache key for a region's device signals — a `country[-region]`
|
||||
* string (e.g. `US-TX` or `GB`). This is the key under which on-device
|
||||
* assurance is stored and read back, which is what binds a grant to its capture
|
||||
* region.
|
||||
*/
|
||||
export function createRegionKey(region: {
|
||||
countryCode: string
|
||||
regionCode?: string
|
||||
}): string {
|
||||
return region.regionCode
|
||||
? `${region.countryCode}-${region.regionCode}`
|
||||
: region.countryCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives age assurance data from native device signals for the given region,
|
||||
* but only when the region permits device verification. The signals are
|
||||
* expected to already be resolved to the user's current region (see
|
||||
* `getDeviceSignalsFromCacheForRegion`), so a grant captured in another region
|
||||
* won't reach here.
|
||||
*
|
||||
* The OS-provided `lowerBound` is the minimum age the platform will attest to,
|
||||
* which maps onto the `assuredAge` input of the rule engine (i.e.
|
||||
* `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
|
||||
*
|
||||
* Always returns an object (so callers can spread it unconditionally); fields
|
||||
* are populated only when device verification applies and the OS provided
|
||||
* usable data.
|
||||
*/
|
||||
export function getAgeAssuranceDataFromDeviceSignals(
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion,
|
||||
deviceSignals: AgeRange.AgeRangeResponse | undefined,
|
||||
): {
|
||||
assuredAge?: number
|
||||
} {
|
||||
if (!regionAllowsDeviceVerification(region)) return {}
|
||||
const lowerBound = deviceSignals?.lowerBound
|
||||
return {
|
||||
assuredAge: typeof lowerBound === 'number' ? lowerBound : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks access levels from most to least restrictive so we can compare two
|
||||
* outcomes. Higher number = more access.
|
||||
*/
|
||||
const ACCESS_RANK: Record<string, number> = {
|
||||
[AgeAssuranceAccess.None]: 0,
|
||||
[AgeAssuranceAccess.Safe]: 1,
|
||||
[AgeAssuranceAccess.Full]: 2,
|
||||
// `unknown` isn't a real granted level; treat it as the floor.
|
||||
[AgeAssuranceAccess.Unknown]: -1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether correcting the user's declared age (i.e. updating their birthdate)
|
||||
* could meaningfully improve their standing in the current region.
|
||||
*
|
||||
* There are two ways a birthdate update can help:
|
||||
*
|
||||
* 1. Raising the rule-engine access level. Some regions grant `safe`/`full` off
|
||||
* a sufficient *declared* age, so a user whose birthdate is wrong (too young)
|
||||
* can unlock more by correcting it. Other regions gate higher access purely
|
||||
* on an *assured* age (or account date), where a declared age changes
|
||||
* nothing.
|
||||
* 2. Crossing the region's `minAccessAge`. Below it the user is hard-blocked
|
||||
* with no verify path (see `isOverRegionMinAccessAge` gating in the
|
||||
* NoAccessScreen); crossing it unlocks the verify flow, which is itself a
|
||||
* path to more access even when the rule-engine level would still be `none`.
|
||||
*
|
||||
* We answer by simulating the real rule engine: hold `accountCreatedAt` and
|
||||
* the device-derived `assuredAge` fixed and re-run access for a set of
|
||||
* candidate declared ages drawn from the region's declared-age rule thresholds
|
||||
* and its `minAccessAge`. If any candidate yields strictly more access, or
|
||||
* crosses `minAccessAge` when the current declared age doesn't, a birthdate
|
||||
* update could help. Simulating rather than statically inspecting rules means
|
||||
* first-match precedence (e.g. an assured/account rule pre-empting a declared
|
||||
* rule) is handled correctly for free.
|
||||
*
|
||||
* `deviceSignals` must be passed so the baseline matches the user's *real*
|
||||
* computed access (see `computeAgeAssuranceState`, which derives `assuredAge`
|
||||
* the same way). Without it, a device-assured user's baseline would compute
|
||||
* lower than their actual access and we'd claim a birthdate update helps when
|
||||
* it can't - and account-date rules would flip on when they're really skipped
|
||||
* (the engine bypasses them whenever `assuredAge` is set).
|
||||
*/
|
||||
export function canBirthdateUpdateIncreaseAccess({
|
||||
region,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}: {
|
||||
region: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}): boolean {
|
||||
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
|
||||
region,
|
||||
deviceSignals,
|
||||
)
|
||||
const baseline = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge: metadata?.declaredAge,
|
||||
assuredAge,
|
||||
})
|
||||
const baselineRank =
|
||||
ACCESS_RANK[baseline?.access ?? AgeAssuranceAccess.Unknown]
|
||||
const baselineOverMin =
|
||||
metadata?.declaredAge !== undefined &&
|
||||
metadata.declaredAge >= region.minAccessAge
|
||||
|
||||
/*
|
||||
* Candidate declared ages to probe: each declared-age rule's threshold and
|
||||
* the region's `minAccessAge`, plus one below each (to cover
|
||||
* `IfDeclaredUnderAge` and the min-age boundary). Anything a birthdate edit
|
||||
* could achieve is captured by crossing one of these thresholds, so we don't
|
||||
* need to sweep every integer.
|
||||
*/
|
||||
const thresholds = new Set<number>([region.minAccessAge])
|
||||
for (const rule of region.rules) {
|
||||
if (
|
||||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) ||
|
||||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule)
|
||||
) {
|
||||
thresholds.add(rule.age)
|
||||
}
|
||||
}
|
||||
const candidates = new Set<number>()
|
||||
for (const threshold of thresholds) {
|
||||
candidates.add(threshold)
|
||||
candidates.add(Math.max(0, threshold - 1))
|
||||
}
|
||||
|
||||
for (const declaredAge of candidates) {
|
||||
const result = computeAgeAssuranceRegionAccess(region, {
|
||||
accountCreatedAt: metadata?.accountCreatedAt,
|
||||
declaredAge,
|
||||
assuredAge,
|
||||
})
|
||||
const rank = ACCESS_RANK[result?.access ?? AgeAssuranceAccess.Unknown]
|
||||
const overMin = declaredAge >= region.minAccessAge
|
||||
if (rank > baselineRank || (overMin && !baselineOverMin)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +243,7 @@ export function useAgeAssuranceRegionConfig() {
|
||||
return useMemo(() => {
|
||||
if (!config) return
|
||||
// use generic helper, we want to potentially return undefined
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
})
|
||||
return getAgeAssuranceRegionConfigForGeolocation(config, geolocation)
|
||||
}, [config, geolocation])
|
||||
}
|
||||
|
||||
@@ -101,10 +294,12 @@ export function computeAgeAssuranceFlags({
|
||||
state,
|
||||
regionConfig,
|
||||
metadata,
|
||||
deviceSignals,
|
||||
}: {
|
||||
state: AgeAssuranceState
|
||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}): AgeAssuranceFlags {
|
||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||
const chatDisabled = isAgeRestricted
|
||||
@@ -120,14 +315,44 @@ export function computeAgeAssuranceFlags({
|
||||
: false
|
||||
const adultContentDisabled =
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
const allowsDeviceVerification =
|
||||
DEVICE_SIGNALS_SUPPORTED && regionAllowsDeviceVerification(regionConfig)
|
||||
const hasSharedDeviceSignals = deviceSignals?.lowerBound !== undefined
|
||||
|
||||
return {
|
||||
isAgeRestricted,
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
groupChatDisabled,
|
||||
hasDeclaredAge: metadata?.declaredAge !== undefined,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
allowsDeviceVerification,
|
||||
hasSharedDeviceSignals,
|
||||
}
|
||||
}
|
||||
|
||||
const USRegionCodeToRegionName: {[regionCode: string]: string} =
|
||||
Object.fromEntries(
|
||||
Object.entries(USRegionNameToRegionCode).map(([name, code]) => [
|
||||
code,
|
||||
name,
|
||||
]),
|
||||
)
|
||||
export function createGeolocationString(
|
||||
geolocation: Geolocation,
|
||||
appLang: string,
|
||||
): string | undefined {
|
||||
const {countryCode, regionCode} = geolocation
|
||||
if (!countryCode) return undefined
|
||||
const country = regionName(countryCode, appLang)
|
||||
// If `regionName` couldn't resolve a real name and fell through to the raw
|
||||
// code, we'd rather show nothing than a bare ISO code in the prose.
|
||||
if (country === countryCode) return undefined
|
||||
if (regionCode && countryCode === 'US') {
|
||||
const state = USRegionCodeToRegionName[regionCode]
|
||||
if (state) return `${state}, ${country}`
|
||||
}
|
||||
return country
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user