notification_sync_kit 0.4.0 copy "notification_sync_kit: ^0.4.0" to clipboard
notification_sync_kit: ^0.4.0 copied to clipboard

PlatformAndroid

Capture Android notifications, persist them to a local queue, and sync them to your server via HTTP with automatic retry. Enriches each notification with GPS speed, location, and driver interaction ty [...]

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:notification_sync_kit/notification_sync_kit.dart';

// ─── YOUR CONFIG ────────────────────────────────────────────────────────────
// Replace these two values before running.
const String _kEndpoint = 'https://your-api.example.com/notifications';
const String _kBearerToken = 'YOUR_BEARER_TOKEN_HERE';
// ────────────────────────────────────────────────────────────────────────────

// ─── Fake event for debug injection ─────────────────────────────────────────
class _FakeEvent {
  _FakeEvent({
    required this.packageName,
    required this.id,
    required this.hasRemoved,
    this.title = '',
    this.content = '',
    this.canReply = false,
    this.haveExtraPicture = false,
  });
  final String packageName;
  final String id;
  final bool hasRemoved;
  final String title;
  final String content;
  final bool canReply;
  final bool haveExtraPicture;
}

// Sample payloads for each monitored app.
const _kTestEvents = [
  {
    'label': 'Uber — Trip Request',
    'package': 'com.ubercab.driver',
    'title': 'New Trip Request',
    'text': 'Pickup: Connaught Place → Drop: IGI Airport',
  },
  {
    'label': 'Ola — Ride Request',
    'package': 'com.olacabs.captain',
    'title': 'New Ride Request',
    'text': '3.2 km away — Sector 18, Noida',
  },
  {
    'label': 'Rapido — Passenger Message',
    'package': 'com.rapido.captain',
    'title': 'Passenger',
    'text': 'I am at the main gate',
    'canReply': true,
  },
  {
    'label': 'InDrive — Ride Offer',
    'package': 'com.sinet.indriver',
    'title': 'New Ride Offer',
    'text': '₹280 — Dwarka Sec 21 → Gurgaon Cyber City',
  },
  {
    'label': '🚫 WhatsApp (should be blocked)',
    'package': 'com.whatsapp',
    'title': 'Alice',
    'text': 'Hey, are you free?',
  },
];

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const NotificationCaptureApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notification Capture',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const NotificationHomePage(),
    );
  }
}

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

  @override
  State<NotificationHomePage> createState() => _NotificationHomePageState();
}

class _NotificationHomePageState extends State<NotificationHomePage> {
  late final NotificationQueueStore _queueStore;
  late final InteractionDetector _interactionDetector;
  late final NotificationListenerController _listenerController;
  late final NotificationUploader _uploader;
  late final NotificationSyncManager _syncManager;

  // Injects fake events for testing without real app notifications.
  final StreamController<dynamic> _fakeStream = StreamController<dynamic>();
  int _fakeEventCounter = 0;

  StreamSubscription<NotificationRecord>? _listenerSub;
  List<NotificationRecord> _recent = const [];
  bool _accessGranted = false;
  bool _isLoading = true;
  String _status = 'Starting...';

  @override
  void initState() {
    super.initState();
    _bootstrap();
  }

  Future<void> _bootstrap() async {
    _queueStore = NotificationQueueStore();
    await _queueStore.init();

    // Set up interaction detection (requires Usage Access permission).
    _interactionDetector = InteractionDetector();
    if (!await _interactionDetector.hasPermission()) {
      await _interactionDetector.requestPermission();
    }

    _uploader = NotificationUploader(
      endpoint: _kEndpoint,
      internalToken: _kBearerToken,
    );

    _syncManager = NotificationSyncManager(
      queueStore: _queueStore,
      uploader: _uploader,
      onSyncResult: (remaining, message) async {
        if (!mounted) return;
        setState(() => _status = message);
      },
    );

    _listenerController = NotificationListenerController(
      interactionDetector: _interactionDetector,
      notificationStream: _fakeStream.stream,
    );

    final storedNotifications = await _queueStore.readAll();
    _listenerSub = _listenerController.events.listen(_handleIncomingEvent);
    // Use startForTesting() since we're driving the stream ourselves.
    _listenerController.startForTesting();

    final accessGranted = await _listenerController.isAccessGranted();

    if (!mounted) return;
    setState(() {
      _recent = storedNotifications;
      _accessGranted = accessGranted;
      _isLoading = false;
      _status = accessGranted
          ? 'Listening — will upload instantly, queue retries every 30 s.'
          : 'Notification access is not granted.';
    });
  }

  /// Try to upload immediately. If it fails, save to the local queue so the
  /// [NotificationSyncManager] can retry it on the next 30-second tick.
  Future<void> _handleIncomingEvent(NotificationRecord record) async {
    final uploaded = await _uploader.upload(record);

    String statusMsg;
    if (uploaded) {
      statusMsg = '✓ Uploaded ${record.packageName} to server';
    } else {
      await _queueStore.add(record);
      statusMsg = '⚠ Upload failed — ${record.packageName} queued for retry';
    }

    final storedNotifications = await _queueStore.readAll();
    if (!mounted) return;
    setState(() {
      _recent = storedNotifications;
      _status = statusMsg;
    });
  }

  /// Pushes a fake post → removal pair into the controller stream.
  /// Simulates a notification arriving and being dismissed after 2 seconds.
  Future<void> _injectFakeEvent(Map<String, dynamic> config) async {
    final id = 'fake-${++_fakeEventCounter}';
    final pkg = config['package'] as String;
    final title = config['title'] as String;
    final text = config['text'] as String;
    final canReply = config['canReply'] as bool? ?? false;

    setState(() => _status = 'Injecting: ${config['label']}...');

    // Post the notification.
    _fakeStream.add(_FakeEvent(
      packageName: pkg,
      id: id,
      hasRemoved: false,
      title: title,
      content: text,
      canReply: canReply,
    ));

    // Simulate the driver dismissing it after 2 seconds.
    await Future<void>.delayed(const Duration(seconds: 2));

    // Remove the notification.
    _fakeStream.add(_FakeEvent(
      packageName: pkg,
      id: id,
      hasRemoved: true,
      title: title,
      content: text,
      canReply: canReply,
    ));
  }

  Future<void> _requestAccess() async {
    setState(() => _status = 'Opening notification access settings...');
    final granted = await _listenerController.requestAccess();
    if (!mounted) return;
    setState(() {
      _accessGranted = granted;
      _status = granted
          ? 'Access granted. Capture is active.'
          : 'Access still not granted.';
    });
  }

  Future<void> _refreshAccess() async {
    final granted = await _listenerController.isAccessGranted();
    if (!mounted) return;
    setState(() {
      _accessGranted = granted;
      _status = granted ? 'Access confirmed.' : 'Access is still disabled.';
    });
  }

  @override
  void dispose() {
    _listenerSub?.cancel();
    _listenerController.dispose();
    _syncManager.dispose();
    _fakeStream.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Notification Capture')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              _accessGranted ? 'Access: Granted' : 'Access: Not Granted',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton(
                  onPressed: _requestAccess,
                  child: const Text('Grant Access'),
                ),
                OutlinedButton(
                  onPressed: _refreshAccess,
                  child: const Text('Refresh Access'),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(_status, style: Theme.of(context).textTheme.bodyMedium),
            const SizedBox(height: 16),
            const Divider(height: 24),
            Text(
              'Inject test notification',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: _kTestEvents.map((config) {
                final isBlocked =
                    !watchedPackages.contains(config['package'] as String);
                return OutlinedButton(
                  style: isBlocked
                      ? OutlinedButton.styleFrom(
                          foregroundColor: Colors.red,
                          side: const BorderSide(color: Colors.red),
                        )
                      : null,
                  onPressed: () => _injectFakeEvent(config),
                  child: Text(config['label'] as String),
                );
              }).toList(),
            ),
            const Divider(height: 24),
            Text(
              'Local queue (pending / failed uploads).',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            Expanded(
              child: _recent.isEmpty
                  ? const Center(child: Text('No notifications queued.'))
                  : ListView.builder(
                      itemCount: _recent.length,
                      itemBuilder: (context, index) {
                        final item = _recent[index];
                        return Card(
                          child: ListTile(
                            onTap: () => Navigator.of(context).push(
                              MaterialPageRoute<void>(
                                builder: (_) =>
                                    NotificationDetailPage(record: item),
                              ),
                            ),
                            dense: true,
                            title: Text(
                              item.title.isEmpty ? '(No title)' : item.title,
                            ),
                            subtitle: Text(
                              '${item.packageName}\n${item.text}',
                              maxLines: 3,
                              overflow: TextOverflow.ellipsis,
                            ),
                            isThreeLine: true,
                            trailing: Text(
                              item.hasRemoved ? 'Removed' : 'Posted',
                            ),
                          ),
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }
}
1
likes
130
points
12
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Capture Android notifications, persist them to a local queue, and sync them to your server via HTTP with automatic retry. Enriches each notification with GPS speed, location, and driver interaction type (OPENED, DISMISSED, REPLIED, IGNORED) for driver behaviour analysis.

Repository (GitHub)
View/report issues

Topics

#notifications #android #background-service #gps #driver-behaviour

License

MIT (license)

Dependencies

flutter, geolocator, http, logging, notification_listener_service, shared_preferences

More

Packages that depend on notification_sync_kit