flutter_digital_credentials 0.1.0-dev.3 copy "flutter_digital_credentials: ^0.1.0-dev.3" to clipboard
flutter_digital_credentials: ^0.1.0-dev.3 copied to clipboard

PlatformAndroid

Android Credential Manager Digital Credentials verifier bridge for Flutter.

example/lib/main.dart

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_digital_credentials/flutter_digital_credentials.dart';
import 'package:http/http.dart' as http;

const _verifierBaseUrl = String.fromEnvironment(
  'VERIFIER_BASE_URL',
  defaultValue: 'http://127.0.0.1:8787',
);

const _firebasePnvTestToken = String.fromEnvironment('FIREBASE_PNV_TEST_TOKEN');

void main() => runApp(const VerifiedEmailExample());

class VerifiedEmailExample extends StatelessWidget {
  const VerifiedEmailExample({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const VerifiedEmailScreen(),
    );
  }
}

class VerifiedEmailScreen extends StatefulWidget {
  const VerifiedEmailScreen({super.key});

  @override
  State<VerifiedEmailScreen> createState() => _VerifiedEmailScreenState();
}

class _VerifiedEmailScreenState extends State<VerifiedEmailScreen> {
  final _credentials = FlutterDigitalCredentials();
  String _status = 'Ready to request a verified email.';
  bool _isLoading = false;

  Future<void> _requestCredential() async {
    setState(() {
      _isLoading = true;
      _status = 'Creating a one-time verification session…';
    });
    try {
      final session = await _createSession();
      if (!mounted) return;
      setState(() => _status = 'Opening the Android credential selector…');
      final presentation = await _credentials.request(
        requestJson: session.requestJson,
      );
      if (!mounted) return;
      setState(() => _status = 'Verifying the presentation on the server…');
      final result = await _completeSession(
        session.id,
        presentation.credentialJson,
      );
      if (!mounted) return;
      setState(
        () => _status = 'Verified ${result.displayName} (${result.email}).',
      );
    } on DigitalCredentialException catch (error) {
      if (mounted) setState(() => _status = error.message);
    } on _VerifierException catch (error) {
      if (mounted) setState(() => _status = error.message);
    } catch (_) {
      if (mounted) {
        setState(
          () => _status = 'Could not reach the local verifier. Is it running?',
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  Future<void> _requestAgeOver18() async {
    setState(() {
      _isLoading = true;
      _status = 'Creating a one-time 18+ request…';
    });
    try {
      final requestJson = await _createAgeOver18Request();
      if (!mounted) return;
      setState(() => _status = 'Opening the Android credential selector…');
      await _credentials.request(requestJson: requestJson);
      if (mounted) {
        setState(
          () => _status =
              'Received an unverified 18+ presentation. No access was granted.',
        );
      }
    } on DigitalCredentialException catch (error) {
      if (mounted) setState(() => _status = error.message);
    } on _VerifierException catch (error) {
      if (mounted) setState(() => _status = error.message);
    } catch (_) {
      if (mounted) {
        setState(
          () => _status = 'Could not reach the local verifier. Is it running?',
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  Future<void> _requestPhoneNumber() async {
    if (_firebasePnvTestToken.isEmpty) {
      setState(
        () => _status = 'Pass FIREBASE_PNV_TEST_TOKEN with --dart-define before testing phone verification.',
      );
      return;
    }
    setState(() {
      _isLoading = true;
      _status = 'Opening the Android phone-number consent sheet…';
    });
    try {
      final verification =
          await _FirebasePhoneVerification.getVerifiedPhoneNumber(
            _firebasePnvTestToken,
          );
      if (!mounted) return;
      setState(
        () => _status = 'Verifying the signed phone token on the server…',
      );
      final phoneNumber = await _completePhoneVerification(verification.token);
      if (mounted) {
        setState(() => _status = 'Verified phone number: $phoneNumber');
      }
    } on PlatformException catch (error) {
      if (mounted) setState(() => _status = error.message ?? error.code);
    } on _VerifierException catch (error) {
      if (mounted) setState(() => _status = error.message);
    } catch (_) {
      if (mounted) {
        setState(
          () => _status = 'Could not reach the local verifier. Is it running?',
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  Future<_VerificationSession> _createSession() async {
    final response = await http.post(
      Uri.parse('$_verifierBaseUrl/verification-sessions'),
    );
    final body = _decodeBody(response);
    if (response.statusCode != 201) {
      throw _VerifierException(_errorMessage(body));
    }
    return _VerificationSession(
      id: body['sessionId'] as String,
      requestJson: body['requestJson'] as String,
    );
  }

  Future<String> _createAgeOver18Request() async {
    final response = await http.post(
      Uri.parse('$_verifierBaseUrl/age-over-18-requests'),
    );
    final body = _decodeBody(response);
    if (response.statusCode != 201 || body['requestJson'] is! String) {
      throw _VerifierException(_errorMessage(body));
    }
    return body['requestJson'] as String;
  }

  Future<_VerifiedEmail> _completeSession(
    String sessionId,
    String credentialJson,
  ) async {
    final response = await http.post(
      Uri.parse('$_verifierBaseUrl/verification-sessions/$sessionId/complete'),
      headers: {'content-type': 'application/json'},
      body: jsonEncode({'credentialJson': credentialJson}),
    );
    final body = _decodeBody(response);
    if (response.statusCode != 200) {
      throw _VerifierException(_errorMessage(body));
    }
    return _VerifiedEmail(
      email: body['email'] as String,
      displayName: body['displayName'] as String,
    );
  }

  Future<String> _completePhoneVerification(String token) async {
    final response = await http.post(
      Uri.parse('$_verifierBaseUrl/phone-verification/complete'),
      headers: {'content-type': 'application/json'},
      body: jsonEncode({'token': token}),
    );
    final body = _decodeBody(response);
    if (response.statusCode != 200 || body['phoneNumber'] is! String) {
      throw _VerifierException(_errorMessage(body));
    }
    return body['phoneNumber'] as String;
  }

  Map<String, dynamic> _decodeBody(http.Response response) {
    final body = jsonDecode(response.body);
    if (body is! Map<String, dynamic>) {
      throw const _VerifierException('Invalid verifier response.');
    }
    return body;
  }

  String _errorMessage(Map<String, dynamic> body) =>
      body['error'] as String? ?? 'Verification failed.';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Verified email example')),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          const Text(
            'This demo gets a one-time request from its verifier, then sends the raw presentation back for server-side validation.',
          ),
          const SizedBox(height: 16),
          FilledButton(
            onPressed: _isLoading ? null : _requestCredential,
            child: Text(_isLoading ? 'Verifying…' : 'Request verified email'),
          ),
          const SizedBox(height: 12),
          OutlinedButton(
            onPressed: _isLoading ? null : _requestAgeOver18,
            child: const Text('Request 18+ proof (mDL wallet required)'),
          ),
          const SizedBox(height: 12),
          OutlinedButton(
            onPressed: _isLoading ? null : _requestPhoneNumber,
            child: const Text('Verify phone number (Firebase test mode)'),
          ),
          const SizedBox(height: 16),
          Text(_status),
          const SizedBox(height: 24),
          const Text(
            'Local verifier: 127.0.0.1:8787 via adb reverse. The demo does not create an account.',
            style: TextStyle(fontSize: 12),
          ),
        ],
      ),
    );
  }
}

class _VerificationSession {
  const _VerificationSession({required this.id, required this.requestJson});

  final String id;
  final String requestJson;
}

class _VerifiedEmail {
  const _VerifiedEmail({required this.email, required this.displayName});

  final String email;
  final String displayName;
}

class _VerifierException implements Exception {
  const _VerifierException(this.message);

  final String message;
}

class _FirebasePhoneVerification {
  static const _channel = MethodChannel(
    'com.wreos.flutter_digital_credentials_example/firebase_pnv',
  );

  static Future<_PhoneVerification> getVerifiedPhoneNumber(
    String testToken,
  ) async {
    final result = await _channel.invokeMapMethod<String, dynamic>(
      'getVerifiedPhoneNumber',
      {'testToken': testToken},
    );
    final token = result?['token'];
    if (token is! String || token.isEmpty) {
      throw PlatformException(
        code: 'invalid_phone_verification_response',
        message: 'Firebase PNV did not return a verification token.',
      );
    }
    return _PhoneVerification(token: token);
  }
}

class _PhoneVerification {
  const _PhoneVerification({required this.token});

  final String token;
}