companion_device_manager 0.2.3
companion_device_manager: ^0.2.3 copied to clipboard
Android-only Flutter plugin for Companion Device Manager (CDM) association and wake callbacks
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:ui' as ui;
import 'package:companion_device_manager/companion_device_manager.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const CompanionDeviceManagerExampleApp());
}
@pragma('vm:entry-point')
Future<void> companionDeviceWakeCallback(CompanionDeviceEvent event) async {
WidgetsFlutterBinding.ensureInitialized();
ui.DartPluginRegistrant.ensureInitialized();
final timestamp = DateTime.now();
final isoTime = timestamp.toIso8601String();
debugPrint(
'[CDM Background Callback] Invoked at $isoTime (${timestamp.millisecondsSinceEpoch}ms) '
'type=${event.type.wireValue} mac=${event.association?.macAddress}',
);
// Verify this is truly executing in Dart by logging the event
final manager = CompanionDeviceManager();
final lastEvent = await manager.getLastBackgroundEvent();
if (lastEvent != null) {
debugPrint(
'[CDM Background Callback] Last background event: ${lastEvent.type.wireValue} '
'mac=${lastEvent.association?.macAddress} at ${lastEvent.timestamp}',
);
}
}
class CompanionDeviceManagerExampleApp extends StatelessWidget {
const CompanionDeviceManagerExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Companion Device Manager Example',
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
home: const _InitializeCallbackWrapper(
child: CompanionDeviceManagerHomePage(),
),
);
}
}
class _InitializeCallbackWrapper extends StatefulWidget {
const _InitializeCallbackWrapper({required this.child});
final Widget child;
@override
State<_InitializeCallbackWrapper> createState() =>
_InitializeCallbackWrapperState();
}
class _InitializeCallbackWrapperState
extends State<_InitializeCallbackWrapper> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureCallbackRegistered();
});
}
Future<void> _ensureCallbackRegistered() async {
final manager = CompanionDeviceManager();
try {
await manager.registerBackgroundCallback(companionDeviceWakeCallback);
debugPrint('[CDM] Background callback auto-registered on app start.');
} on ArgumentError {
// Callback already registered or not a valid function
debugPrint('[CDM] Background callback already registered.');
} catch (error) {
debugPrint('[CDM] Error auto-registering callback: $error');
}
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class CompanionDeviceManagerHomePage extends StatefulWidget {
const CompanionDeviceManagerHomePage({super.key});
@override
State<CompanionDeviceManagerHomePage> createState() =>
_CompanionDeviceManagerHomePageState();
}
class _CompanionDeviceManagerHomePageState
extends State<CompanionDeviceManagerHomePage> {
final CompanionDeviceManager _manager = CompanionDeviceManager();
final TextEditingController _addressController = TextEditingController(
text: 'A7:09:65:57:B7:D6',
);
StreamSubscription<CompanionDeviceEvent>? _eventSubscription;
Timer? _timeAgoTicker;
String? _lastEventSignature;
DateTime? _lastEventTimestamp;
bool _available = false;
bool _callbackRegistered = false;
bool _busy = false;
String _status = 'Ready';
String? _lastEventJson;
List<CompanionDeviceAssociation> _associations =
<CompanionDeviceAssociation>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_subscribeToBackgroundEvents();
_refreshAvailability();
_refreshAssociations();
_refreshLastEvent();
_checkCallbackRegistration();
});
_timeAgoTicker = Timer.periodic(const Duration(seconds: 30), (_) {
if (!mounted || _lastEventTimestamp == null) {
return;
}
setState(() {
// Trigger rebuild so the "time ago" subtitle stays current.
});
});
}
Future<void> _checkCallbackRegistration() async {
try {
final lastEvent = await _manager.getLastBackgroundEvent();
if (!mounted) return;
setState(() => _callbackRegistered = lastEvent != null);
} catch (_) {
// If we can't get last event, assume callback might not be registered
}
}
@override
void dispose() {
_eventSubscription?.cancel();
_timeAgoTicker?.cancel();
_addressController.dispose();
super.dispose();
}
void _subscribeToBackgroundEvents() {
_eventSubscription?.cancel();
_eventSubscription = _manager.backgroundEvents.listen(
(event) {
_applyEventUpdate(
event,
logIfChanged: true,
updateStatusOnChange: true,
logPrefix: '[CDM] New background event from stream:',
);
},
onError: (Object error) {
if (!mounted) return;
setState(() => _status = 'Background event stream error: $error');
},
);
}
Future<void> _refreshAvailability() async {
setState(() => _busy = true);
try {
final available = await _manager.isAvailable();
if (!mounted) return;
setState(() {
_available = available;
_status = available
? 'Companion Device Manager is available on this Android device.'
: 'Companion Device Manager is not available on this Android device.';
});
} on PlatformException catch (error) {
if (!mounted) return;
setState(() => _status = 'Availability check failed: ${error.message}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _refreshAssociations() async {
setState(() => _busy = true);
try {
final associations = await _manager.getAssociations();
if (!mounted) return;
setState(() => _associations = associations);
} on PlatformException catch (error) {
if (!mounted) return;
setState(() => _status = 'Unable to load associations: ${error.message}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _refreshLastEvent({
bool logIfChanged = false,
bool updateStatusOnChange = false,
}) async {
try {
final event = await _manager.getLastBackgroundEvent();
_applyEventUpdate(
event,
logIfChanged: logIfChanged,
updateStatusOnChange: updateStatusOnChange,
logPrefix: '[CDM] New background event from persisted state:',
);
} on PlatformException catch (error) {
if (!mounted) return;
setState(
() => _status = 'Unable to load the last event: ${error.message}',
);
}
}
void _applyEventUpdate(
CompanionDeviceEvent? event, {
required bool logIfChanged,
required bool updateStatusOnChange,
required String logPrefix,
}) {
if (!mounted) return;
final prettyJson = event == null
? null
: const JsonEncoder.withIndent(' ').convert(event.toMap());
final nextSignature = event?.toJson();
final changed = nextSignature != _lastEventSignature;
if (!changed && _lastEventJson == prettyJson) {
return;
}
if (changed) {
_lastEventSignature = nextSignature;
if (logIfChanged) {
developer.log(
event == null
? '[CDM] Last background event cleared (null).'
: '$logPrefix $prettyJson',
name: 'CDMExample',
);
}
}
setState(() {
_lastEventJson = prettyJson;
_lastEventTimestamp = event?.timestamp;
if (updateStatusOnChange && changed && event != null) {
_status = 'New background event received: ${event.type.wireValue}';
}
});
}
Future<void> _logLastEvent() async {
try {
final event = await _manager.getLastBackgroundEvent();
_lastEventSignature = event?.toJson();
final message = event == null
? '[CDM] No background event captured yet.'
: '[CDM] Last background event: ${const JsonEncoder.withIndent(' ').convert(event.toMap())}';
developer.log(message, name: 'CDMExample');
if (!mounted) return;
setState(() {
_lastEventJson = event == null
? null
: const JsonEncoder.withIndent(' ').convert(event.toMap());
_lastEventTimestamp = event?.timestamp;
_status = event == null
? 'No background event captured yet.'
: 'Last background event logged to console.';
});
} on PlatformException catch (error) {
if (!mounted) return;
setState(
() => _status = 'Unable to log the last event: ${error.message}',
);
}
}
Future<void> _associate() async {
final address = _addressController.text.trim();
if (address.isEmpty) {
setState(() => _status = 'Please enter a Bluetooth MAC address.');
return;
}
setState(() {
_busy = true;
_status = 'Launching the Android companion device chooser...';
});
try {
final association = await _manager.associate(
CompanionDeviceAssociationRequest(
displayName: 'Companion Device Manager Example',
filters: <CompanionDeviceFilter>[
CompanionDeviceFilter.bluetoothLe(address: address),
],
singleDevice: true,
),
);
if (!mounted) return;
setState(() {
_status =
'Association completed for ${association.macAddress ?? 'unknown device'}.';
});
await _refreshAssociations();
await _refreshLastEvent();
_showSnackBar('Association completed.');
} on PlatformException catch (error) {
if (!mounted) return;
setState(() => _status = 'Association failed: ${error.message}');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _disassociate(CompanionDeviceAssociation association) async {
setState(() => _busy = true);
try {
await _manager.disassociate(association);
if (!mounted) return;
setState(
() => _status =
'Association removed for ${association.macAddress ?? 'unknown device'}',
);
await _refreshAssociations();
} on PlatformException catch (error) {
if (!mounted) return;
setState(
() => _status = 'Unable to remove association: ${error.message}',
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
String? _lastEventSubtitle() {
final timestamp = _lastEventTimestamp;
if (timestamp == null) {
return null;
}
final local = timestamp.toLocal();
final formatted =
'${_twoDigits(local.day)}/${_twoDigits(local.month)}/${local.year} '
'${_twoDigits(local.hour)}:${_twoDigits(local.minute)}:${_twoDigits(local.second)}';
return 'Ricevuto: $formatted (${_formatTimeAgo(local, DateTime.now())})';
}
String _twoDigits(int value) => value.toString().padLeft(2, '0');
String _formatTimeAgo(DateTime timestamp, DateTime now) {
final difference = now.difference(timestamp);
if (difference.isNegative || difference.inSeconds < 5) {
return 'adesso';
}
if (difference.inMinutes < 1) {
return '${difference.inSeconds}s fa';
}
if (difference.inHours < 1) {
final minutes = difference.inMinutes;
return minutes == 1 ? '1 min fa' : '$minutes min fa';
}
if (difference.inDays < 1) {
final hours = difference.inHours;
return hours == 1 ? '1 ora fa' : '$hours ore fa';
}
final days = difference.inDays;
return days == 1 ? '1 giorno fa' : '$days giorni fa';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Companion Device Manager'),
actions: [
IconButton(
onPressed: _busy
? null
: () async {
await _refreshAvailability();
await _refreshAssociations();
await _refreshLastEvent();
},
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_InfoCard(title: 'Status', child: Text(_status)),
const SizedBox(height: 12),
_InfoCard(
title: 'Runtime availability',
child: Text(_available ? 'Available' : 'Not available'),
),
const SizedBox(height: 12),
_InfoCard(
title: 'Background callback',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_callbackRegistered ? 'Registered (auto)' : 'Not registered',
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton(
onPressed: _busy ? null : _refreshLastEvent,
child: const Text('Reload last event'),
),
OutlinedButton(
onPressed: _busy ? null : _logLastEvent,
child: const Text('Log last event'),
),
],
),
],
),
),
const SizedBox(height: 12),
_InfoCard(
title: 'Association setup',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Bluetooth MAC address',
helperText: 'Use the BLE device MAC address when testing.',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _busy ? null : _associate,
child: const Text('Start association'),
),
],
),
),
const SizedBox(height: 12),
_InfoCard(
title: 'Current associations',
child: _associations.isEmpty
? const Text('No associations found yet.')
: Column(
children: _associations
.map(
(association) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
association.displayName ??
association.macAddress ??
'Unknown device',
),
subtitle: Text(
'MAC: ${association.macAddress ?? 'n/a'}\n'
'Association ID: ${association.associationId?.toString() ?? 'n/a'}',
),
isThreeLine: true,
trailing: TextButton(
onPressed: _busy
? null
: () => _disassociate(association),
child: const Text('Remove'),
),
),
)
.toList(),
),
),
const SizedBox(height: 12),
_InfoCard(
title: 'Last background event',
subtitle: _lastEventSubtitle(),
child: SelectableText(
_lastEventJson ?? 'No background event captured yet.',
),
),
const SizedBox(height: 12),
const _InfoCard(
title: 'Notes',
child: Text(
'The background callback is auto-registered on app startup and executes with full Dart access, '
'even when the app is backgrounded or killed. Device presence events arrive via CompanionDeviceService '
'and trigger the callback in a headless Flutter engine with full plugin and storage access.',
),
),
],
),
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.title, required this.child, this.subtitle});
final String title;
final String? subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(subtitle!, style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 12),
child,
],
),
),
);
}
}