companion_device_manager 0.2.4 copy "companion_device_manager: ^0.2.4" to clipboard
companion_device_manager: ^0.2.4 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,
          ],
        ),
      ),
    );
  }
}
1
likes
0
points
105
downloads

Publisher

unverified uploader

Weekly Downloads

Android-only Flutter plugin for Companion Device Manager (CDM) association and wake callbacks

Repository (GitHub)
View/report issues

Topics

#companion-device #android #bluetooth #plugin #background

License

unknown (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on companion_device_manager

Packages that implement companion_device_manager