age_verification

pub version

A Flutter plugin for querying platform-level age signals on Android and iOS.

  • Android — Google Play Age Signals API (com.google.android.play:age-signals:0.0.3)
  • iOS — Apple DeclaredAgeRange framework (iOS 26.0+, requires entitlement)
Platform API Min version
Android Google Play Age Signals Android 6.0 (API 23)
iOS Apple DeclaredAgeRange iOS 26.0

Platform setup

Android

Add the Play Age Signals dependency to your app's build.gradle:

implementation("com.google.android.play:age-signals:0.0.3")

No permissions or manifest entries are required. The API only returns data for users in applicable jurisdictions (e.g. US states with age verification laws, Brazil).

iOS

  1. Entitlement — request the com.apple.developer.declared-age-range entitlement from Apple before submitting to the App Store. Add it to your app's .entitlements file once approved:

    <key>com.apple.developer.declared-age-range</key>
    <true/>
    
  2. iOS 26.0+ — the DeclaredAgeRange framework requires iOS 26. On older OS versions verifyAge returns an apiNotAvailable error.

  3. Swift Package Manager — this plugin uses SPM by default. A CocoaPods podspec is also provided for older Flutter tooling that does not support SPM.


Usage

import 'package:age_verification/age_verification.dart';

// AgeVerification is a singleton — always use .instance
final plugin = AgeVerification.instance;

// 1. Initialize once (e.g. in initState or app startup)
await plugin.init();

// 2. Query age signals
try {
  final result = await plugin.verifyAge(ageGates: [13, 18]); // ageGates used on iOS only
  print(result.status);   // AgeVerificationStatus
  print(result.ageLower); // e.g. 13
  print(result.ageUpper); // e.g. 17
} on PlatformException catch (e) {
  print(e.code); // AgeVerificationErrorCode name, e.g. "apiNotAvailable"
}

Age gates (iOS only)

Pass 1–3 integer age thresholds to verifyAge. The API shows a system prompt asking the user to share their age range relative to those thresholds:

// Single gate: am I 18 or over?
await plugin.verifyAge(ageGates: [18]);

// Two gates: under 13 / 13–17 / 18+
await plugin.verifyAge(ageGates: [13, 18]);

// Three gates: under 13 / 13–15 / 16–17 / 18+
await plugin.verifyAge(ageGates: [13, 16, 18]);

Android ignores ageGates — age ranges are determined by Google Play parental controls.

Skipping the eligibility check (iOS 26.2+)

On iOS 26.2+, verifyAge first awaits AgeRangeService.shared.isEligibleForAgeFeatures to determine whether the user is in a supported region. If ineligible, the user is never prompted and unknown is returned immediately.

Some reports suggest this API can hang indefinitely. If you experience this, pass skipEligibilityCheck: true to call requestAgeRange directly without the eligibility gate:

final result = await plugin.verifyAge(
  ageGates: [18],
  skipEligibilityCheck: true, // bypasses isEligibleForAgeFeatures on iOS 26.2+
);

The trade-off: users in non-applicable regions will be shown the age-sharing sheet even if the platform would not normally prompt them. Has no effect on Android.


Result

verifyAge returns an AgeVerificationResult:

Field Type Description
status AgeVerificationStatus Outcome of the age check
ageLower int? Inclusive lower bound of the age range
ageUpper int? Inclusive upper bound (null for open-ended ranges)
source AgeDeclarationSource? Who provided the declaration (iOS only)
installId String? Play-generated install identifier (Android only)

AgeVerificationStatus values

Value Description
verified User meets the age requirement
supervised User is under parental supervision / below the age gate
supervisedApprovalPending Guardian approval requested but not yet responded (Android)
supervisedApprovalDenied Guardian denied the request (Android)
declared Age self-declared through Google Play (Android)
declined User declined to share their age range (iOS)
unknown Age signal unavailable or user not in an applicable region

AgeDeclarationSource values (iOS only)

Value Description
selfDeclared User declared their own age
guardianDeclared A guardian declared via Family Sharing

Error codes

Errors are thrown as PlatformException. The code field is the name of an AgeVerificationErrorCode value:

Code Description
apiNotAvailable API unavailable on this device or region (iOS < 26, sideloaded app, missing entitlement)
notInitialized verifyAge called before init (Android)
initError Manager could not be created (Play Services unavailable)
networkError No network connection
playServicesError Play Services or Play Store issue
sdkVersionOutdated Age Signals SDK version no longer supported
apiError Unclassified error
try {
  await plugin.verifyAge();
} on PlatformException catch (e) {
  switch (e.code) {
    case 'apiNotAvailable':
      // Platform doesn't support age verification
    case 'notInitialized':
      // Call init() first
    case 'networkError':
      // Ask user to check connectivity
    default:
      // Handle other errors
  }
}

How it works

Android

init() creates an AgeSignalsManager via AgeSignalsManagerFactory. verifyAge() calls checkAgeSignals() which queries Google Play for the account's parental control status. Results reflect the device's Google account configuration — no UI is shown by the plugin. The age verification dialog (if any) is presented by Google Play separately.

iOS

init() is a no-op. verifyAge() calls AgeRangeService.shared.requestAgeRange(ageGates:in:) which presents a system sheet asking the user to share their age range. On iOS 26.2+, isEligibleForAgeFeatures is checked first — users in non-applicable regions receive unknown status without being prompted. Pass skipEligibilityCheck: true to bypass this check and go straight to requestAgeRange.


Testing with mock data

Pass an AgeVerificationMockConfig to init() to bypass the native API entirely on both platforms — no supervised account, no iOS 26 device, no entitlement required.

await plugin.init(
  mockConfig: AgeVerificationMockConfig(
    status: AgeVerificationStatus.supervised,
    ageLower: 13,
    ageUpper: 15,
    installId: 'test-install-abc',
  ),
);

final result = await plugin.verifyAge(ageGates: [13, 18]);
// result.status == AgeVerificationStatus.supervised
// result.ageLower == 13

verifyAge returns the mock data instantly — no system sheet is shown on iOS, no Play Services call is made on Android.

To return to real behaviour, call init() without mockConfig:

await plugin.init(); // clears mock, uses real native API

Common test scenarios

// Verified adult
AgeVerificationMockConfig(status: AgeVerificationStatus.verified, ageLower: 18)

// Supervised teen (Android-style)
AgeVerificationMockConfig(
  status: AgeVerificationStatus.supervised,
  ageLower: 13,
  ageUpper: 15,
  installId: 'mock-install-id',
)

// Guardian approval pending
AgeVerificationMockConfig(status: AgeVerificationStatus.supervisedApprovalPending)

// User declined to share (iOS-style)
AgeVerificationMockConfig(status: AgeVerificationStatus.declined)

// Self-declared via iOS (with source)
AgeVerificationMockConfig(
  status: AgeVerificationStatus.verified,
  ageLower: 18,
  source: AgeDeclarationSource.selfDeclared,
)

// Unknown / not in applicable region
AgeVerificationMockConfig(status: AgeVerificationStatus.unknown)

Android — how mock works internally

On Android, AgeVerificationMockConfig is translated into an AgeSignalsResult and fed into Google's official FakeAgeSignalsManager. The result flows through the real checkAgeSignals() callback path, so the status-mapping logic is exercised identically to production. No additional gradle dependency is needed.

Run integration tests

cd example
flutter test integration_test/plugin_integration_test.dart -d <device-id>

Libraries

age_verification