notification_sync_kit 0.4.0
notification_sync_kit: ^0.4.0 copied to clipboard
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 [...]
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',
),
),
);
},
),
),
],
),
),
);
}
}