[AAv2] Integrate on-device APIs (#9564)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-07-02 15:15:30 -05:00
committed by GitHub
parent b0a40145e9
commit 954268a4b6
20 changed files with 1107 additions and 319 deletions
+1
View File
@@ -128,6 +128,7 @@ module.exports = function (_config) {
'com.apple.security.application-groups': 'group.app.bsky',
'com.apple.developer.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
'com.apple.developer.declared-age-range': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
+3 -2
View File
@@ -93,8 +93,8 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.22",
"@atproto/syntax": "0.6.3",
"@atproto/api": "0.20.25",
"@atproto/syntax": "0.6.4",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
@@ -156,6 +156,7 @@
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "54.0.34",
"expo-age-range": "0.2.18",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
+16
View File
@@ -0,0 +1,16 @@
diff --git a/ios/AgeRangeModule.swift b/ios/AgeRangeModule.swift
index faf1089f4ddfb290105e959bb1f96a7250586f54..dafd2addb0c8d39a4a453bdeecbe27f7278e20f7 100644
--- a/ios/AgeRangeModule.swift
+++ b/ios/AgeRangeModule.swift
@@ -11,8 +11,9 @@ public class AgeRangeModule: Module {
return AgeRangeResponse()
}
- let currentVc: UIViewController? = await MainActor.run { [appContext] in
- appContext?.utilities?.currentViewController()
+ nonisolated(unsafe) let utilities = appContext?.utilities
+ let currentVc: UIViewController? = await MainActor.run {
+ utilities?.currentViewController()
}
guard let currentVc else {
+51 -36
View File
@@ -216,6 +216,7 @@ overrides:
patchedDependencies:
'@sentry/react-native@6.20.0': 1d48e4d5178f5684eb33262299e7eed283bf9601f79b9ae1af63a7f607d3483a
expo-age-range@0.2.18: c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306
expo-glass-effect@55.0.8: 6c9fa5b104e53b87df4e17b320acb3b94d12ac90c0101190045dd620681efff0
expo-haptics@15.0.8: b33910ba2753c4eccdc885b449e6e33aa90b9cefa75ca8639762a26013141410
expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab
@@ -241,11 +242,11 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.22
version: 0.20.22
specifier: 0.20.25
version: 0.20.25
'@atproto/syntax':
specifier: 0.6.3
version: 0.6.3
specifier: 0.6.4
version: 0.6.4
'@bitdrift/react-native':
specifier: ^0.6.8
version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -429,6 +430,9 @@ importers:
expo:
specifier: 54.0.34
version: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-age-range:
specifier: 0.2.18
version: 0.2.18(patch_hash=c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306)(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
expo-application:
specifier: ~7.0.8
version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
@@ -873,32 +877,32 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.22':
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
'@atproto/api@0.20.25':
resolution: {integrity: sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.2':
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
'@atproto/common-web@0.5.3':
resolution: {integrity: sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
'@atproto/lex-data@0.1.4':
resolution: {integrity: sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
'@atproto/lex-json@0.1.3':
resolution: {integrity: sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
'@atproto/lexicon@0.7.4':
resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
'@atproto/syntax@0.6.4':
resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
'@atproto/xrpc@0.8.3':
resolution: {integrity: sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -5278,6 +5282,12 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
expo-age-range@0.2.18:
resolution: {integrity: sha512-WThjp0/islDlysxRkamXmS9D2zU/T7P5fIizIBPaajfG4C+tjL7psFZp6R6l0eDkYJkYO+VPJRMr5NrQQAu6nQ==}
peerDependencies:
expo: '*'
react-native: '*'
expo-application@7.0.8:
resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==}
peerDependencies:
@@ -9464,51 +9474,51 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.22':
'@atproto/api@0.20.25':
dependencies:
'@atproto/common-web': 0.5.2
'@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.2
'@atproto/common-web': 0.5.3
'@atproto/lexicon': 0.7.4
'@atproto/syntax': 0.6.4
'@atproto/xrpc': 0.8.3
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.2':
'@atproto/common-web@0.5.3':
dependencies:
'@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.3
'@atproto/lex-data': 0.1.4
'@atproto/lex-json': 0.1.3
'@atproto/syntax': 0.6.4
zod: 3.25.76
'@atproto/lex-data@0.1.3':
'@atproto/lex-data@0.1.4':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.2':
'@atproto/lex-json@0.1.3':
dependencies:
'@atproto/lex-data': 0.1.3
'@atproto/lex-data': 0.1.4
tslib: 2.8.1
'@atproto/lexicon@0.7.3':
'@atproto/lexicon@0.7.4':
dependencies:
'@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.3
'@atproto/common-web': 0.5.3
'@atproto/syntax': 0.6.4
multiformats: 13.4.2
zod: 3.25.76
'@atproto/syntax@0.6.3':
'@atproto/syntax@0.6.4':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.2':
'@atproto/xrpc@0.8.3':
dependencies:
'@atproto/lexicon': 0.7.3
'@atproto/lexicon': 0.7.4
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -14773,6 +14783,11 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
expo-age-range@0.2.18(patch_hash=c325225749993424461eeece1d97a99b19c00da2ebc958a73c12e4eca0c39306)(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
expo-application@7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+1
View File
@@ -20,6 +20,7 @@ allowBuilds:
'unrs-resolver': true
patchedDependencies:
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
+200 -163
View File
@@ -1,14 +1,8 @@
import {useCallback, useEffect} from 'react'
import {ScrollView, View} from 'react-native'
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 {Trans, useLingui} from '@lingui/react/macro'
import {
SupportCode,
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session'
@@ -27,6 +21,7 @@ import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRe
import {Full as Logo} from '#/components/icons/Logo'
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Outlet as PortalOutlet} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
@@ -34,37 +29,51 @@ import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
import {
canBirthdateUpdateIncreaseAccess,
createGeolocationString,
isLegacyBirthdateBug,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {useDeviceGeolocationApi} from '#/geolocation'
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
const textStyles = [a.text_md, a.leading_snug]
export function NoAccessScreen() {
const t = useTheme()
const {_} = useLingui()
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {gtPhone} = useBreakpoints()
const insets = useSafeAreaInsets()
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {metadata} = useAgeAssuranceServerDataContext()
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const geolocation = useGeolocation()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const locationControl = Dialog.useDialogControl()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = metadata?.declaredAge !== undefined
const hasDeclaredAge = aa.flags.hasDeclaredAge
const birthdateMightIncreaseAccess = Boolean(
region &&
canBirthdateUpdateIncreaseAccess({region, metadata, deviceSignals}),
)
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
(isBirthdateUpdateAllowed ||
isLegacyBirthdateBug(metadata?.birthdate || '')) &&
birthdateMightIncreaseAccess
const geolocationString = createGeolocationString(geolocation, i18n.locale)
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
useEffect(() => {
// just counting overall hits here
@@ -106,7 +115,7 @@ export function NoAccessScreen() {
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
label={l`Click here to update your birthdate`}
style={[textStyles]}
{...createStaticClick(() => {
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
@@ -120,20 +129,7 @@ export function NoAccessScreen() {
{orgAdmonition}
</>
) : (
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, please{' '}
<SimpleInlineLinkText
to={createSupportLink({code: SupportCode.AA_BIRTHDATE})}
label={_(msg`Click here to contact our support team`)}
style={[textStyles]}>
contact our support team
</SimpleInlineLinkText>
.
</Trans>
</Text>
)
) : null
return (
<>
@@ -164,80 +160,147 @@ export function NoAccessScreen() {
<AgeAssuranceBadge />
</View>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hey there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you
to access the app.
</Trans>
</Text>
{region && geolocationString && (
<Text style={[textStyles]}>
{isUsingGPS ? (
<Trans>
Based on your device's location, we think you're
in{' '}
<Text style={[textStyles, a.font_bold]}>
{geolocationString}
</Text>
.
</Trans>
) : (
<>
<Trans>
Based on your network, we think you're in{' '}
<Text style={[textStyles, a.font_bold]}>
{geolocationString}
</Text>
. This estimate may be inaccurate if you're
using a VPN.
</Trans>
</>
)}
{IS_NATIVE && (
<>
{' '}
<SimpleInlineLinkText
label={l`Update your location`}
{...createStaticClick(() => {
locationControl.open()
})}
style={[textStyles]}>
<Trans>
Tap here to update your location with GPS.
</Trans>
</SimpleInlineLinkText>
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
const access =
computeAgeAssuranceRegionAccess(
props.geolocation,
)
if (access !== aa.Access.Full) {
props.disableDialogAction()
props.setDialogError(
l`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(props.geolocation)
Toast.show(l`Thanks! You're all set.`, {
type: 'success',
})
})
}
}}
/>
</>
)}
</Text>
)}
{!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}>
<Trans>
Unfortunately, your declared age indicates that
you are not old enough to access Bluesky in your
region.
</Trans>
</Text>
)}
{!isBlocked && birthdateUpdateText}
</View>
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hey there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to
access the app.
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
{!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}>
<Trans>
Unfortunately, your declared age indicates that you
are not old enough to access Bluesky in your region.
</Trans>
</Text>
)}
{!isBlocked && birthdateUpdateText}
{birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
In order to provide an age-appropriate experience, we need
to know your birthdate. This is a one-time thing, and your
data will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting
and exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={l`Click here to update your birthdate`}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
{birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
In order to provide an age-appropriate experience, we need
to know your birthdate. This is a one-time thing, and your
data will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting
and exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{orgAdmonition}
</View>
)}
{orgAdmonition}
</View>
)}
</View>
<View style={[a.pt_lg, a.gap_xl, {maxWidth: 280}]}>
<Logo width={120} textFill={t.atoms.text.color} />
@@ -251,7 +314,7 @@ export function NoAccessScreen() {
<Trans>
To log out,{' '}
<SimpleInlineLinkText
label={_(msg`Click here to log out`)}
label={l`Click here to log out`}
{...createStaticClick(() => {
onPressLogout()
})}
@@ -260,7 +323,7 @@ export function NoAccessScreen() {
</SimpleInlineLinkText>
. Or if youd prefer, you can{' '}
<SimpleInlineLinkText
label={_(msg`Click here to delete your account`)}
label={l`Click here to delete your account`}
{...createStaticClick(() => {
ax.metric(
'ageAssurance:noAccessScreen:openDeleteAccountDialog',
@@ -299,14 +362,11 @@ export function NoAccessScreen() {
function AccessSection() {
const t = useTheme()
const {_, i18n} = useLingui()
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const control = useDialogControl()
const appealControl = Dialog.useDialogControl()
const locationControl = Dialog.useDialogControl()
const getTimeAgo = useGetTimeAgo()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state
@@ -318,6 +378,15 @@ function AccessSection() {
const diff = lastInitiatedAt
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
const {
onPressVerify,
openInitDialog,
isVerifying,
verifyCta,
deviceSignalsFailed,
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
const useDeviceSignals =
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
return (
<>
@@ -331,7 +400,7 @@ function AccessSection() {
You are currently unable to access Bluesky's Age Assurance flow.
Please{' '}
<SimpleInlineLinkText
label={_(msg`Contact our moderation team`)}
label={l`Contact our moderation team`}
{...createStaticClick(() => {
appealControl.open()
ax.metric('ageAssurance:appealDialogOpen', {})
@@ -345,26 +414,38 @@ function AccessSection() {
<>
<View style={[a.gap_md]}>
<Button
label={_(msg`Verify now`)}
label={verifyCta}
size="large"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonIcon icon={ShieldIcon} />
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
) : (
<Trans>Verify now</Trans>
)}
</ButtonText>
color={
hasInitiated || aa.flags.hasSharedDeviceSignals
? 'secondary'
: 'primary'
}
disabled={isVerifying}
onPress={() => void onPressVerify()}>
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
<ButtonText>{verifyCta}</ButtonText>
</Button>
{lastInitiatedAt && timeAgo && diff ? (
{useDeviceSignals ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>
Sharing your age data uses information stored on your
device, and will therefore only work on this device.
Alternatively,{' '}
<SimpleInlineLinkText
label={l`Verify now using KWS`}
{...createStaticClick(() => {
openInitDialog()
})}>
you can use our trusted partner, KWS
</SimpleInlineLinkText>
, to complete your verification and enable access on all
platforms.
</Trans>
</Text>
) : lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
@@ -380,56 +461,12 @@ function AccessSection() {
) : (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>Age assurance only takes a few minutes</Trans>
<Trans>Age assurance only takes a few minutes.</Trans>
</Text>
)}
</View>
</>
)}
<View style={[a.gap_xs]}>
{IS_NATIVE && (
<>
<Admonition>
<Trans>
Is your location not accurate?{' '}
<SimpleInlineLinkText
label={_(msg`Update your location`)}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
</SimpleInlineLinkText>
</Trans>
</Admonition>
<DeviceLocationRequestDialog
control={locationControl}
onLocationAcquired={props => {
const access = computeAgeAssuranceRegionAccess(
props.geolocation,
)
if (access !== aa.Access.Full) {
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(props.geolocation)
Toast.show(_(msg`Thanks! You're all set.`), {
type: 'success',
})
})
}
}}
/>
</>
)}
</View>
</View>
</>
)
+17
View File
@@ -4,12 +4,29 @@ import {
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {ANDROID_API_LEVEL, IOS_MAJOR_VERSION, IS_ANDROID, IS_IOS} from '#/env'
/**
* Minimum age required to access the app at all.
*/
export const MIN_ACCESS_AGE = 13
/**
* Whether the current device can provide the native on-device age signals we
* use for age assurance (via `expo-age-range`). We gate on OS version because
* the underlying platform APIs are only available on:
*
* - iOS 26.0+ (Declared Age Range API)
* https://developer.apple.com/documentation/declaredagerange/
* - Android 6.0 / API level 23+ (Play Age Signals API)
* https://developer.android.com/google/play/age-signals/use-age-signals-api
*
* On unsupported platforms/versions (including web) this is `false`, so we skip
* the device flow and fall back to KWS.
*/
export const DEVICE_SIGNALS_SUPPORTED: boolean =
(IS_IOS && IOS_MAJOR_VERSION >= 26) || (IS_ANDROID && ANDROID_API_LEVEL >= 23)
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
+191 -7
View File
@@ -1,11 +1,11 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig,
type AppBskyAgeassuranceGetState,
AtpAgent,
type ChatBskyActorDeclaration,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
@@ -22,14 +22,21 @@ import {
} from '#/state/birthdate'
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
import {useAgent, useSession} from '#/state/session'
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
import {
type AgeAssuranceDeviceSignals,
type AgeAssuranceMetadata,
} from '#/ageAssurance/types'
import {
createRegionKey,
getAgeAssuranceRegionConfigForGeolocation,
getBirthdateStringFromAge,
isLegacyBirthdateBug,
} from '#/ageAssurance/util'
import {IS_DEV} from '#/env'
import {useGeolocation} from '#/geolocation'
import {device} from '#/storage'
/**
@@ -310,10 +317,7 @@ export function useServerStateQuery() {
const isAArequired = Boolean(
config &&
geolocation &&
!!getAgeAssuranceRegionConfig(config, {
countryCode: geolocation?.countryCode ?? '',
regionCode: geolocation?.regionCode,
}),
getAgeAssuranceRegionConfigForGeolocation(config, geolocation),
)
// only refetch when needed
@@ -485,6 +489,173 @@ export function useOtherRequiredDataQuery() {
)
}
export function createDeviceSignalsQueryKey({did}: {did: string}) {
return ['device-signals', did]
}
/**
* Prompts the native OS age API. Returns the raw response, or undefined if the
* platform can't provide one.
*
* Native-only: on web `expo-age-range` returns a misleading default (e.g.
* `{lowerBound: 18}`), so we never call it there — web users fall back to KWS.
*
* If this method throws for whatever reason, we catch and log the error and
* return undefined. The caller should treat undefined as "no device signals
* available" and fall back to KWS.
*/
export async function getDeviceSignals(): Promise<
AgeRange.AgeRangeResponse | undefined
> {
if (debug.enabled && debug.useMockDeviceSignalsAPIResponse)
return debug.resolve(debug.deviceSignals)
if (!DEVICE_SIGNALS_SUPPORTED) return undefined
try {
return await AgeRange.requestAgeRangeAsync({
threshold1: 13,
threshold2: 16,
threshold3: 18,
})
} catch (err) {
const e = err as Error
logger.error(`getDeviceSignals: failed to get device signals`, {
safeMessage: e.message,
})
return undefined
}
}
/**
* The raw region-keyed map of device signals (all regions). Used internally by
* the query + writer, which operate on the full map. Most consumers want
* {@link getDeviceSignalsFromCacheForRegion}, which resolves to the
* current region.
*/
export function getDeviceSignalsMapFromCache({
did,
}: {
did: string
}): AgeAssuranceDeviceSignals | undefined {
return qc.getQueryData<AgeAssuranceDeviceSignals>(
createDeviceSignalsQueryKey({did}),
)
}
/**
* Returns the device signals for the region the user is currently in, or
* undefined.
*/
export function getDeviceSignalsFromCacheForRegion({
did,
region,
}: {
did: string
region: AppBskyAgeassuranceDefs.ConfigRegion
}): AgeRange.AgeRangeResponse | undefined {
const regionKey = createRegionKey(region)
return getDeviceSignalsMapFromCache({did})?.[regionKey]
}
/**
* Stores freshly granted device signals into the (persisted) cache under the
* region they were captured in, merging with any signals already stored for
* other regions. Notifies the disabled `useDeviceSignalsQuery` observer so the
* AA state recomputes.
*
* Device assurance is client-side only (it can't be verified server-side) and
* region-bound — keying by region is what binds it. See
* {@link AgeAssuranceDeviceSignals}.
*/
export function setDeviceSignalsForRegion({
did,
region,
signals,
}: {
did: string
region: {countryCode: string; regionCode?: string}
signals: AgeRange.AgeRangeResponse
}) {
const regionKey = createRegionKey(region)
qc.setQueryData<AgeAssuranceDeviceSignals | undefined>(
createDeviceSignalsQueryKey({did}),
prev => ({...prev, [regionKey]: signals}),
)
}
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
/**
* 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 = getDeviceSignalsMapFromCache({did})
logger.debug(
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
cached,
)
/*
* Future silent-refresh path (intentionally left commented out). The OS
* returns a previously granted age range without re-prompting, so once a
* region is already cached we could refresh it on load instead of waiting for
* the user to re-verify. We don't do this yet because the native call can
* still surface a prompt for users who never opted in, so it must stay gated
* behind an explicit grant for now.
*
* const geolocation = device.get(['mergedGeolocation'])
* if (cached && geolocation?.countryCode) {
* const signals = await getDeviceSignals()
* if (signals) {
* setDeviceSignalsForRegion({did, region: geolocation, signals})
* }
* }
*/
}
export function useDeviceSignalsQuery() {
const agent = useAgent()
const did = getDidFromAgentSession(agent)
const {data: config} = useConfigQuery()
const geolocation = useGeolocation()
/*
* Resolve the matched config region (no fallback) and key off it, so the read
* stays symmetric with the write (see `setDeviceSignalsForRegion`). When
* geolocation matches no AA region there's no device grant to surface.
*/
const regionConfig = config
? getAgeAssuranceRegionConfigForGeolocation(config, geolocation)
: undefined
const regionKey = regionConfig ? createRegionKey(regionConfig) : undefined
return useQuery(
{
/**
* 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` +
* `setDeviceSignalsForRegion` 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: getDeviceSignalsMapFromCache({did: did!}),
queryKey: createDeviceSignalsQueryKey({did: did!}),
queryFn() {
// Never auto-fetches (see `enabled: false`); the verify flow writes the
// region-keyed signals directly via `setDeviceSignalsForRegion`.
return getDeviceSignalsMapFromCache({did: did!})
},
// The cache holds the full region-keyed map (the writer merges into it);
// `select` resolves it to the current region for consumers without
// mutating the cached value.
select: map => (map && regionKey ? map[regionKey] : undefined),
},
qc,
)
}
/**
* Helper to prefetch all age assurance data from the server.
*/
@@ -494,6 +665,7 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
configPrefetchPromise,
prefetchServerState({agent}),
prefetchOtherRequiredData({agent}),
prefetchDeviceSignals({agent}),
])
}
@@ -525,6 +697,14 @@ export type AgeAssuranceServerData = {
*/
state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined
/**
* The native on-device age signals for the region the user is currently in,
* if they've granted access there. Already resolved from the region-keyed
* cache (see `getDeviceSignalsFromCacheForRegion`), so a grant from
* another region won't appear here. Only consumed for regions that permit
* device verification.
*/
deviceSignals: AgeRange.AgeRangeResponse | undefined
}
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
config: undefined,
@@ -534,6 +714,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined,
birthdate: undefined,
},
deviceSignals: undefined,
})
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
@@ -547,6 +728,8 @@ export function AgeAssuranceServerDataProvider({
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
// `select` resolves the cached region-keyed map to the current region.
const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo(
() => ({
config,
@@ -559,8 +742,9 @@ export function AgeAssuranceServerDataProvider({
: undefined,
birthdate: data?.birthdate,
},
deviceSignals,
}),
[config, state, data, metadata],
[config, state, data, metadata, deviceSignals],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
+49 -12
View File
@@ -1,3 +1,4 @@
import type * as AgeRange from 'expo-age-range'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
@@ -17,17 +18,16 @@ export const geolocation: Geolocation | undefined = enabled
}
: undefined
const deviceGeolocationEnabled = false || IS_E2E
export const deviceGeolocation: Geolocation | undefined =
enabled && deviceGeolocationEnabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const deviceGeolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
regionCode: undefined,
...geolocation,
}
: undefined
export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2010, 12, 1).toISOString(),
birthdate: new Date(2000, 12, 1).toISOString(),
}
const serverStateEnabled = false || IS_E2E
@@ -35,9 +35,9 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
serverStateEnabled
? {
state: {
lastInitiatedAt: new Date(2025, 1, 1).toISOString(),
status: 'assured',
access: 'full',
lastInitiatedAt: undefined, // new Date(2025, 1, 1).toISOString(),
status: 'unknown',
access: 'unknown',
},
metadata: {
accountCreatedAt: new Date(2023, 1, 1).toISOString(),
@@ -58,6 +58,26 @@ export const config: AppBskyAgeassuranceDefs.Config = {
},
],
},
{
// On-device verification region. KWS is included as a fallback for
// platforms without the native age API (e.g. web) or when the device
// result is insufficient.
countryCode: 'US',
regionCode: 'TX',
minAccessAge: 18,
additionalVerificationMethods: ['device'],
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'GB',
minAccessAge: 13,
@@ -257,6 +277,23 @@ export const config: AppBskyAgeassuranceDefs.Config = {
],
}
/**
* When debug is enabled we mock the `deviceSignals` response by default. Set
* this to `false` to hit the real native age API (`expo-age-range`) so the OS
* age prompt actually shows — useful for testing the device flow on a physical
* device.
*/
export const useMockDeviceSignalsAPIResponse = true
export const deviceSignals: AgeRange.AgeRangeResponse | undefined =
useMockDeviceSignalsAPIResponse
? {
// Simulates the OS reporting the user is at least 18. Lower this below
// a region's IfAssuredOverAge threshold to exercise the KWS fallback.
lowerBound: 16,
upperBound: null,
}
: undefined
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
+8 -3
View File
@@ -51,9 +51,12 @@ const AgeAssuranceStateContext = createContext<{
adultContentDisabled: false,
chatDisabled: false,
groupChatDisabled: false,
hasDeclaredAge: false,
isDeclaredUnderAdultAge: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
allowsDeviceVerification: false,
hasSharedDeviceSignals: false,
},
})
@@ -79,7 +82,7 @@ export function Provider({children}: {children: React.ReactNode}) {
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
const {metadata} = useAgeAssuranceServerDataContext()
const {metadata, deviceSignals} = useAgeAssuranceServerDataContext()
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
@@ -89,6 +92,7 @@ function InnerProvider({children}: {children: React.ReactNode}) {
state: s,
regionConfig,
metadata,
deviceSignals,
})
if (flags.isAgeRestricted) {
void getAndRegisterPushToken({
@@ -103,7 +107,7 @@ function InnerProvider({children}: {children: React.ReactNode}) {
})
}
},
[agent, getAndRegisterPushToken, regionConfig, metadata],
[agent, getAndRegisterPushToken, regionConfig, metadata, deviceSignals],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
@@ -118,11 +122,12 @@ function InnerProvider({children}: {children: React.ReactNode}) {
state,
regionConfig,
metadata,
deviceSignals,
}),
}
logger.debug(`useAgeAssurance`, res)
return res
}, [state, metadata, regionConfig])}>
}, [state, metadata, regionConfig, deviceSignals])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+34 -2
View File
@@ -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,
getDeviceSignalsFromCacheForRegion,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceServerDataContext,
@@ -23,6 +25,8 @@ import {
} from '#/ageAssurance/types'
import {
computeAgeAssuranceFlags,
getAgeAssuranceDataFromDeviceSignals,
getAgeAssuranceRegionConfigForGeolocation,
getAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
@@ -39,12 +43,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 +99,19 @@ 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} = getAgeAssuranceDataFromDeviceSignals(
region,
deviceSignals,
)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
assuredAge,
})
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
@@ -138,6 +153,19 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
/*
* Device signals are keyed off the matched config region (no fallback): if
* geolocation matches no AA region there's no device grant to read, so we
* skip the lookup rather than keying off FALLBACK_REGION_CONFIG. This keeps
* the read key symmetric with the write (see `setDeviceSignalsForRegion`).
*/
const deviceRegion = getAgeAssuranceRegionConfigForGeolocation(
config,
geolocation,
)
const deviceSignals = deviceRegion
? getDeviceSignalsFromCacheForRegion({did, region: deviceRegion})
: undefined
const metadata: AgeAssuranceMetadata = {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: requiredData?.birthdate
@@ -151,6 +179,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation,
state: state.state,
metadata,
deviceSignals,
})
return {
@@ -159,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
state: computed,
regionConfig: region,
metadata,
deviceSignals,
}),
}
}
@@ -166,7 +196,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 +207,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation,
state,
metadata,
deviceSignals,
}),
[hasSession, geolocation, config, state, metadata],
[hasSession, geolocation, config, state, metadata, deviceSignals],
)
}
+20
View File
@@ -1,7 +1,24 @@
import type * as AgeRange from 'expo-age-range'
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
/**
* Native on-device age signals, keyed by the region (a `country[-region]`
* string, see `createRegionKey`) they were captured in. We keep one entry per
* region so multiple regions with differing criteria can each retain their own
* grant.
*
* Device assurance can't be verified server-side (the OS gives us no signed
* attestation, only age bounds), so we persist it client-side only and bind it
* to its capture region via the key. A grant captured in TX is only ever read
* back for TX — it can't silently unlock another region. See
* `getAgeAssuranceDataFromDeviceSignals`.
*/
export type AgeAssuranceDeviceSignals = {
[regionKey: string]: AgeRange.AgeRangeResponse
}
export enum AgeAssuranceAccess {
Unknown = 'unknown',
None = 'none',
@@ -34,9 +51,12 @@ export type AgeAssuranceFlags = {
adultContentDisabled: boolean
chatDisabled: boolean
groupChatDisabled: boolean
hasDeclaredAge: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
allowsDeviceVerification: boolean
hasSharedDeviceSignals: boolean
}
export function parseStatusFromString(raw: string) {
+171
View File
@@ -0,0 +1,171 @@
import {useCallback, useState} from 'react'
import type * as AgeRange from 'expo-age-range'
import {useLingui} from '@lingui/react/macro'
import {useSession} from '#/state/session'
import {type DialogControlProps} from '#/components/Dialog'
import * as Toast from '#/components/Toast'
import {useAgeAssurance} from '#/ageAssurance'
import {getDeviceSignals, setDeviceSignalsForRegion} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {
getAgeAssuranceDataFromDeviceSignals,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
/**
* Shared "Verify now" flow for the age assurance surfaces (the no-access screen
* and the account settings card). Handles the device-first verification path
* with a KWS fallback, keeping the two entry points in sync.
*
* The caller owns the KWS dialog (`AgeAssuranceInitDialog`) and passes its
* `initDialogControl` here so we can open it for the fallback / opt-in cases.
*
* Returns:
* - `onPressVerify`: the button handler. In device-verification regions it
* prompts the OS age API, persists a sufficient result (region-bound), and
* toasts the outcome; otherwise (or on any missing/failed device response) it
* opens the KWS dialog.
* - `openInitDialog`: opens the KWS dialog directly (for the inline "use KWS"
* link), emitting the same metric as the fallback path.
* - `isVerifying`: true while the OS age prompt is up, for a button
* loading state.
* - `verifyCta`: the localized button label for the current mode.
*/
export function useAgeAssuranceVerificationFlow({
initDialogControl,
}: {
initDialogControl: DialogControlProps
}) {
const {t: l} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const region = useAgeAssuranceRegionConfig()
const aa = useAgeAssurance()
const hasInitiated = !!aa.state.lastInitiatedAt
const allowsDeviceVerification = region && aa.flags.allowsDeviceVerification
const [isVerifying, setIsVerifying] = useState(false)
/*
* In-memory only, and scoped to this hook instance: each surface using this
* hook tracks its own failure, and a remount (or another surface) starts
* fresh and will offer the device flow again. That's intentional for now -
* it's a soft "don't immediately re-offer" signal, not a persisted judgment
* that the device can't provide signals.
*/
const [deviceSignalsFailed, setDeviceSignalsFailed] = useState(false)
const verifyCta =
allowsDeviceVerification && !deviceSignalsFailed
? l`Share age data`
: hasInitiated
? l`Verify again`
: l`Verify now`
const openInitDialog = useCallback(() => {
initDialogControl.open()
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}, [initDialogControl, ax, hasInitiated])
const onPressVerify = useCallback(async () => {
const did = currentAccount?.did
// Just for typescript, these surfaces aren't shown without a logged in user
if (!did) return
/*
* In regions that permit on-device verification, try the native age API
* first. We tag the result with the current region (device assurance is
* region-bound — a TX grant only counts in TX) and, if it's sufficient,
* persist it client-side so the AA state recompute lifts the gate.
*
* Once the OS returns a response we stay on the device path and report the
* outcome via a toast (sufficient, under-age, or no usable data) rather than
* silently falling back — users can still opt into KWS via the inline link.
* We only fall through to the KWS dialog below when the device can't give us
* a response at all: `getDeviceSignals` handles its own errors and returns
* undefined (e.g. on web or failure).
*/
if (allowsDeviceVerification && !deviceSignalsFailed) {
// Show a loading state while the OS age prompt is up.
setIsVerifying(true)
let signals: AgeRange.AgeRangeResponse | undefined
try {
signals = await getDeviceSignals()
} finally {
setIsVerifying(false)
}
if (signals) {
const {assuredAge} = getAgeAssuranceDataFromDeviceSignals(
region,
signals,
)
if (assuredAge !== undefined) {
// Persist (keyed by this region) so the AA state recomputes from the
// cache write. Recompute here too so we can react to the outcome: a
// sufficient age lifts the gate (nothing more to do), but the device
// may report an age below the region's threshold, in which case
// access stays `none` and we tell the user.
setDeviceSignalsForRegion({did, region, signals})
const {state} = unsafeGetAndComputeAgeAssurance({did})
if (state.access === aa.Access.None) {
Toast.show(
l`We're sorry, but based on the data shared by your device, you are not old enough to access Bluesky.`,
{type: 'info'},
)
} else if (state.access === aa.Access.Unknown) {
Toast.show(
l`Hmm, it seems we weren't able to compute your level of access. Please try again.`,
{type: 'warning'},
)
} else if (state.access === aa.Access.Safe) {
Toast.show(
l`You're all set! However, certain features may still be restricted until you're able to verify you're an adult.`,
{
type: 'success',
},
)
} else {
Toast.show(l`You're all set!`, {
type: 'success',
})
}
return
}
// We got a device response but it carried no usable age information.
Toast.show(
l`Hmm, it seems your device was unable to share age information with us.`,
{type: 'warning'},
)
setDeviceSignalsFailed(true)
return
}
logger.debug(
`onPressVerify: no device signals available (web/error), falling back to KWS`,
)
}
openInitDialog()
}, [
region,
currentAccount?.did,
openInitDialog,
allowsDeviceVerification,
aa,
l,
deviceSignalsFailed,
setDeviceSignalsFailed,
])
return {
onPressVerify,
openInitDialog,
isVerifying,
verifyCta,
deviceSignalsFailed,
}
}
+238 -13
View File
@@ -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
}
@@ -2,7 +2,6 @@ import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {regionName} from '#/locale/helpers'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -13,49 +12,22 @@ import {
useDialogControl,
} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonText} from '#/components/Button'
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 {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {useAgeAssuranceVerificationFlow} from '#/ageAssurance/useVerificationFlow'
import {createGeolocationString} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {
type Geolocation,
useDeviceGeolocationApi,
useGeolocation,
} from '#/geolocation'
import {USRegionNameToRegionCode} from '#/geolocation/util'
import {device, useStorage} from '#/storage'
const USRegionCodeToRegionName: {[regionCode: string]: string} =
Object.fromEntries(
Object.entries(USRegionNameToRegionCode).map(([name, code]) => [
code,
name,
]),
)
function formatRegion(
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
}
import {useDeviceGeolocationApi, useGeolocation} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const aa = useAgeAssurance()
@@ -91,6 +63,15 @@ function Inner({style}: ViewStyleProp & {}) {
const diff = lastInitiatedAt
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
const {
onPressVerify,
openInitDialog,
isVerifying,
verifyCta,
deviceSignalsFailed,
} = useAgeAssuranceVerificationFlow({initDialogControl: control})
const useDeviceSignals =
aa.flags.allowsDeviceVerification && !deviceSignalsFailed
return (
<>
@@ -169,26 +150,38 @@ function Inner({style}: ViewStyleProp & {}) {
: [a.gap_md],
]}>
<Button
label={l`Verify now`}
label={verifyCta}
size="small"
variant="solid"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
<ButtonText>
{hasInitiated ? (
<Trans>Verify again</Trans>
) : (
<Trans>Verify now</Trans>
)}
</ButtonText>
color={
hasInitiated || aa.flags.hasSharedDeviceSignals
? 'secondary'
: 'primary'
}
disabled={isVerifying}
onPress={() => void onPressVerify()}>
<ButtonIcon icon={isVerifying ? Loader : ShieldIcon} />
<ButtonText>{verifyCta}</ButtonText>
</Button>
{lastInitiatedAt && timeAgo && diff ? (
{useDeviceSignals ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>
Sharing your age data uses information stored on your
device, and will therefore only work on this device.
Alternatively,{' '}
<InlineLinkText
label={l`Verify now using KWS`}
{...createStaticClick(() => {
openInitDialog()
})}>
you can use our trusted partner, KWS
</InlineLinkText>
, to complete your verification and enable access on all
platforms.
</Trans>
</Text>
) : lastInitiatedAt && timeAgo && diff ? (
<Text
style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}
title={i18n.date(lastInitiatedAt, {
@@ -220,13 +213,12 @@ function RegionNotice() {
const {t: l, i18n} = useLingui()
const aa = useAgeAssurance()
const geolocation = useGeolocation()
const [deviceGeolocation] = useStorage(device, ['deviceGeolocation'])
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const locationControl = Dialog.useDialogControl()
const region = formatRegion(geolocation, i18n.locale)
const isGPS = !!deviceGeolocation?.countryCode && IS_NATIVE
const region = createGeolocationString(geolocation, i18n.locale)
const isGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
return (
<>
@@ -258,8 +250,7 @@ function RegionNotice() {
{isGPS ? (
<Trans>
Based on your device's location, we think you're in{' '}
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>. This
estimate may be inaccurate if you're using a VPN.
<Text style={[a.text_sm, a.font_bold]}>{region}</Text>.
</Trans>
) : (
<Trans>
@@ -268,20 +259,20 @@ function RegionNotice() {
estimate may be inaccurate if you're using a VPN.
</Trans>
)}
</Text>
)}
{IS_NATIVE && (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
<InlineLinkText
label={l`Update your location`}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
</InlineLinkText>
</Trans>
{IS_NATIVE && (
<Text style={[a.text_sm, a.leading_snug]}>
{' '}
<Trans>
<InlineLinkText
label={l`Update your location`}
{...createStaticClick(() => {
locationControl.open()
})}>
Tap here to update your location with GPS.
</InlineLinkText>
</Trans>
</Text>
)}
</Text>
)}
</>
+16 -6
View File
@@ -5,12 +5,23 @@ import {BUNDLE_IDENTIFIER, IS_TESTFLIGHT, RELEASE_VERSION} from '#/env/common'
export * from '#/env/common'
// for some reason Platform.OS === 'ios' AND Platform.Version is undefined in our CI unit tests -sfn
const iOSMajorVersion =
/**
* The major version number of the current iOS device (e.g. `26` for iOS 26.x),
* or `0` on non-iOS platforms.
*
* Note: for some reason Platform.OS === 'ios' AND Platform.Version is undefined
* in our CI unit tests -sfn
*/
export const IOS_MAJOR_VERSION: number =
Platform.OS === 'ios' && typeof Platform.Version === 'string'
? parseInt(Platform.Version.split('.')[0], 10)
: 0
const androidPlatformVersion =
/**
* The Android API level of the current device (e.g. `23` for Android 6.0), or
* `0` on non-Android platforms. Note this is the API level, not the marketing
* version.
*/
export const ANDROID_API_LEVEL: number =
Platform.OS === 'android' && typeof Platform.Version === 'number'
? Platform.Version
: 0
@@ -52,8 +63,7 @@ export const IS_WEB_FIREFOX: boolean = false
*/
export const IS_HIGH_DPI: boolean = true
// ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now
export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
export const IS_LIQUID_GLASS: boolean = IOS_MAJOR_VERSION >= 26
// So we can avoid attempting on-device translation when we know it's unsupported.
export const IS_TRANSLATION_SUPPORTED: boolean =
(IS_IOS && iOSMajorVersion >= 18) ||
(IS_ANDROID && androidPlatformVersion > 22)
(IS_IOS && IOS_MAJOR_VERSION >= 18) || (IS_ANDROID && ANDROID_API_LEVEL > 22)
+11
View File
@@ -14,6 +14,17 @@ export const APP_VERSION = RELEASE_VERSION
*/
export const APP_METADATA = `${BUNDLE_IDENTIFIER.slice(0, 7)} (${__DEV__ ? 'dev' : 'prod'})`
/**
* The major version number of the current iOS device, or `0` on non-iOS
* platforms (always `0` on web).
*/
export const IOS_MAJOR_VERSION: number = 0
/**
* The Android API level of the current device, or `0` on non-Android platforms
* (always `0` on web).
*/
export const ANDROID_API_LEVEL: number = 0
/**
* Platform detection
*/
+4 -4
View File
@@ -5,8 +5,8 @@ import {type Geolocation} from '#/geolocation/types'
const localEnabled = false
export const enabled = IS_DEV && (localEnabled || aaDebug.geolocation)
export const geolocation: Geolocation = aaDebug.geolocation ?? {
countryCode: 'US',
regionCode: 'TX',
countryCode: 'AA',
regionCode: undefined,
}
const deviceLocalEnabled = false
@@ -14,8 +14,8 @@ export const deviceGeolocation: Geolocation | undefined =
aaDebug.deviceGeolocation ||
(deviceLocalEnabled
? {
countryCode: 'US',
regionCode: 'TX',
countryCode: 'AA',
regionCode: undefined,
}
: undefined)
+13 -1
View File
@@ -22,6 +22,8 @@ export * from '#/geolocation/types'
const GeolocationContext = createContext<Geolocation>({
countryCode: undefined,
regionCode: undefined,
serviceGeolocation: undefined,
deviceGeolocation: undefined,
})
const DeviceGeolocationAPIContext = createContext<{
@@ -44,7 +46,17 @@ export function Provider({children}: {children: ReactNode}) {
'deviceGeolocation',
])
const geolocation = useMemo(() => {
return mergeGeolocations(deviceGeolocation, geolocationService)
const geo = mergeGeolocations(deviceGeolocation, geolocationService)
// create new objects to avoid cyclical references
geo.serviceGeolocation = {
countryCode: geolocationService?.countryCode,
regionCode: geolocationService?.regionCode,
}
geo.deviceGeolocation = {
countryCode: deviceGeolocation?.countryCode,
regionCode: deviceGeolocation?.regionCode,
}
return geo
}, [deviceGeolocation, geolocationService])
useEffect(() => {
+2
View File
@@ -1,4 +1,6 @@
export type Geolocation = {
countryCode: string | undefined
regionCode: string | undefined
serviceGeolocation?: Geolocation
deviceGeolocation?: Geolocation
}