crossmint_device_signer 0.1.1
crossmint_device_signer: ^0.1.1 copied to clipboard
Native device-signer plugin for the Crossmint Flutter SDK — hardware-backed key generation and signing via Secure Enclave (iOS) and Android Keystore.
example/lib/main.dart
import 'package:crossmint_device_signer/crossmint_device_signer.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MyApp());
}
abstract class DeviceSignerExampleBackend {
Future<bool> isAvailable();
Future<String?> getDeviceName();
Future<String> generateKey({String? address});
Future<String?> getKey(String address);
Future<bool> hasKey(String publicKeyBase64);
Future<void> mapAddressToKey(String address, String publicKeyBase64);
Future<Map<String, Object?>> signMessage({
required String address,
required String message,
});
Future<void> deleteKey(String address);
Future<void> deletePendingKey(String publicKeyBase64);
}
class PluginDeviceSignerExampleBackend implements DeviceSignerExampleBackend {
const PluginDeviceSignerExampleBackend();
static const MethodChannel _channel = MethodChannel(
'crossmint_device_signer',
);
static const CrossmintDeviceSigner _plugin = CrossmintDeviceSigner();
@override
Future<bool> isAvailable() async {
final bool? value = await _channel.invokeMethod<bool>('isAvailable');
return value ?? false;
}
@override
Future<String?> getDeviceName() {
return _plugin.getDeviceName();
}
@override
Future<String> generateKey({String? address}) {
return _plugin.generateKey(address: address);
}
@override
Future<String?> getKey(String address) {
return _plugin.getKey(address);
}
@override
Future<bool> hasKey(String publicKeyBase64) {
return _plugin.hasKey(publicKeyBase64);
}
@override
Future<void> mapAddressToKey(String address, String publicKeyBase64) {
return _plugin.mapAddressToKey(address, publicKeyBase64);
}
@override
Future<Map<String, Object?>> signMessage({
required String address,
required String message,
}) {
return _plugin.signMessage(address: address, message: message);
}
@override
Future<void> deleteKey(String address) {
return _plugin.deleteKey(address);
}
@override
Future<void> deletePendingKey(String publicKeyBase64) {
return _channel.invokeMethod<void>('deletePendingKey', <String, Object?>{
'publicKeyBase64': publicKeyBase64,
});
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key, this.backend = const PluginDeviceSignerExampleBackend()});
final DeviceSignerExampleBackend backend;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1A6C7A)),
useMaterial3: true,
),
home: DeviceSignerHarnessScreen(backend: backend),
);
}
}
class DeviceSignerHarnessScreen extends StatefulWidget {
const DeviceSignerHarnessScreen({super.key, required this.backend});
final DeviceSignerExampleBackend backend;
@override
State<DeviceSignerHarnessScreen> createState() =>
_DeviceSignerHarnessScreenState();
}
class _DeviceSignerHarnessScreenState extends State<DeviceSignerHarnessScreen> {
final TextEditingController _addressController =
TextEditingController(text: '0x1234567890123456789012345678901234567890');
final TextEditingController _publicKeyController = TextEditingController();
final TextEditingController _messageController = TextEditingController(
text: 'aGVsbG8=',
);
final List<String> _events = <String>[];
String _deviceName = 'Unknown';
String _availability = 'Unknown';
String _lastLookup = 'n/a';
String _lastHasKey = 'n/a';
String _lastSignature = 'n/a';
bool _busy = false;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final String? deviceName = await widget.backend.getDeviceName();
if (mounted) {
setState(() {
_deviceName = deviceName?.trim().isNotEmpty == true
? deviceName!
: 'Unknown device';
});
}
} on PlatformException catch (error) {
_appendEvent('device name error: ${error.code}');
}
try {
final bool available = await widget.backend.isAvailable();
if (mounted) {
setState(() {
_availability = available ? 'available' : 'unavailable';
});
}
} on PlatformException catch (error) {
_appendEvent('availability error: ${error.code}');
}
}
@override
void dispose() {
_addressController.dispose();
_publicKeyController.dispose();
_messageController.dispose();
super.dispose();
}
Future<void> _checkAvailability() async {
await _run('availability', () async {
final bool available = await widget.backend.isAvailable();
setState(() {
_availability = available ? 'available' : 'unavailable';
});
});
}
Future<void> _generatePendingKey() async {
await _run('generate pending key', () async {
final String publicKey = await widget.backend.generateKey();
setState(() {
_publicKeyController.text = publicKey;
});
_appendEvent('generated pending key: $publicKey');
});
}
Future<void> _mapKeyToAddress() async {
await _run('map key to address', () async {
final String address = _trimmed(_addressController.text);
final String publicKey = _trimmed(_publicKeyController.text);
await widget.backend.mapAddressToKey(address, publicKey);
_appendEvent('mapped $publicKey to $address');
});
}
Future<void> _getKeyByAddress() async {
await _run('get key by address', () async {
final String address = _trimmed(_addressController.text);
final String? publicKey = await widget.backend.getKey(address);
setState(() {
_lastLookup = publicKey ?? 'null';
if (publicKey != null && publicKey.isNotEmpty) {
_publicKeyController.text = publicKey;
}
});
_appendEvent('getKey($address) -> ${publicKey ?? 'null'}');
});
}
Future<void> _hasKey() async {
await _run('has key', () async {
final String publicKey = _trimmed(_publicKeyController.text);
final bool hasKey = await widget.backend.hasKey(publicKey);
setState(() {
_lastHasKey = hasKey.toString();
});
_appendEvent('hasKey($publicKey) -> $hasKey');
});
}
Future<void> _signMessage() async {
await _run('sign base64 message', () async {
final String address = _trimmed(_addressController.text);
final String message = _trimmed(_messageController.text);
final Map<String, Object?> signature = await widget.backend.signMessage(
address: address,
message: message,
);
setState(() {
_lastSignature = signature.toString();
});
_appendEvent('signMessage($address) -> $signature');
});
}
Future<void> _deleteKey() async {
await _run('delete key', () async {
final String address = _trimmed(_addressController.text);
await widget.backend.deleteKey(address);
_appendEvent('deleted key for $address');
});
}
Future<void> _deletePendingKey() async {
await _run('delete pending key', () async {
final String publicKey = _trimmed(_publicKeyController.text);
await widget.backend.deletePendingKey(publicKey);
_appendEvent('deleted pending key $publicKey');
});
}
Future<void> _run(String label, Future<void> Function() action) async {
if (_busy) {
return;
}
setState(() {
_busy = true;
});
try {
await action();
} on PlatformException catch (error) {
_appendEvent('$label error: ${error.code} ${error.message ?? ''}'.trim());
} catch (error) {
_appendEvent('$label error: $error');
} finally {
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
void _appendEvent(String event) {
if (!mounted) {
return;
}
setState(() {
_events.insert(0, event);
if (_events.length > 20) {
_events.removeLast();
}
});
}
String _trimmed(String value) {
final String trimmed = value.trim();
if (trimmed.isEmpty) {
throw const FormatException('Required value is empty.');
}
return trimmed;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Crossmint Device Signer Spike')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_HeaderCard(
deviceName: _deviceName,
availability: _availability,
busy: _busy,
),
const SizedBox(height: 16),
_FieldCard(
title: 'Address',
child: TextField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Wallet address',
helperText: 'Used for map/get/delete actions.',
),
),
),
const SizedBox(height: 12),
_FieldCard(
title: 'Public Key',
child: TextField(
controller: _publicKeyController,
decoration: const InputDecoration(
labelText: 'Base64 public key',
helperText: 'Generated key is reused for map / has / delete.',
),
),
),
const SizedBox(height: 12),
_FieldCard(
title: 'Message',
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
labelText: 'Base64 message',
helperText: 'Plugin expects base64-encoded bytes.',
),
),
),
const SizedBox(height: 16),
_ActionGrid(
actions: <_HarnessAction>[
_HarnessAction(
label: 'Availability',
onPressed: _checkAvailability,
),
_HarnessAction(
label: 'Generate pending key',
onPressed: _generatePendingKey,
),
_HarnessAction(
label: 'Map key to address',
onPressed: _mapKeyToAddress,
),
_HarnessAction(
label: 'Get key by address',
onPressed: _getKeyByAddress,
),
_HarnessAction(
label: 'Has key',
onPressed: _hasKey,
),
_HarnessAction(
label: 'Sign base64 message',
onPressed: _signMessage,
),
_HarnessAction(
label: 'Delete key',
onPressed: _deleteKey,
),
_HarnessAction(
label: 'Delete pending key',
onPressed: _deletePendingKey,
),
],
),
const SizedBox(height: 16),
_StatusCard(
lastLookup: _lastLookup,
lastHasKey: _lastHasKey,
lastSignature: _lastSignature,
events: _events,
),
],
),
),
),
);
}
}
class _HeaderCard extends StatelessWidget {
const _HeaderCard({
required this.deviceName,
required this.availability,
required this.busy,
});
final String deviceName;
final String availability;
final bool busy;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Device: $deviceName',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text('Signer availability: $availability'),
const SizedBox(height: 8),
Text(busy ? 'Busy' : 'Idle'),
],
),
),
);
}
}
class _FieldCard extends StatelessWidget {
const _FieldCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
class _ActionGrid extends StatelessWidget {
const _ActionGrid({required this.actions});
final List<_HarnessAction> actions;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: actions
.map(
(_HarnessAction action) => FilledButton.tonal(
onPressed: action.onPressed,
child: Text(action.label),
),
)
.toList(growable: false),
),
),
);
}
}
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.lastLookup,
required this.lastHasKey,
required this.lastSignature,
required this.events,
});
final String lastLookup;
final String lastHasKey;
final String lastSignature;
final List<String> events;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Last lookup: $lastLookup'),
const SizedBox(height: 4),
Text('Last hasKey: $lastHasKey'),
const SizedBox(height: 4),
Text('Last signature: $lastSignature'),
const SizedBox(height: 16),
Text('Event log', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
if (events.isEmpty)
const Text('No events yet.')
else
...events.map((String event) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text('- $event'),
)),
],
),
),
);
}
}
class _HarnessAction {
const _HarnessAction({required this.label, required this.onPressed});
final String label;
final Future<void> Function() onPressed;
}