From cbd791de2b4f11c552035d088ca8689ebb04b973 Mon Sep 17 00:00:00 2001 From: Aarav Garg Date: Tue, 23 Dec 2025 21:31:13 +0800 Subject: [PATCH 1/4] initial commit --- app/android/app/src/main/AndroidManifest.xml | 1 + app/ios/Runner/Info.plist | 2 + app/lib/main.dart | 7 + app/lib/pages/conversation_detail/page.dart | 26 +- .../widgets/share_to_contacts_sheet.dart | 511 +++++++++++++++++ app/lib/pages/home/page.dart | 30 + ...ant_conversation_notification_handler.dart | 93 +++ .../notification_service_fcm.dart | 530 +++++++++--------- app/pubspec.yaml | 1 + backend/database/redis_db.py | 15 + .../conversations/process_conversation.py | 44 ++ backend/utils/notifications.py | 46 +- 12 files changed, 1041 insertions(+), 265 deletions(-) create mode 100644 app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart create mode 100644 app/lib/services/notifications/important_conversation_notification_handler.dart diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 5ded268a8c..b6bb795c76 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -45,6 +45,7 @@ + diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 2a53c16bd6..7e34685ccd 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -93,6 +93,8 @@ When creating your memories, we use your location for determining where they were created. NSMicrophoneUsageDescription We need access to your microphone so you can share audio explanations of Bugs you find. + NSContactsUsageDescription + We need access to your contacts so you can share conversation summaries with your contacts via SMS. NSPhotoLibraryUsageDescription We need access to your photo library to allow you to upload and share photos through Instabug. NSRemindersUsageDescription diff --git a/app/lib/main.dart b/app/lib/main.dart index 1322a2e9e8..9ec9cf38cb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -51,6 +51,7 @@ import 'package:omi/services/auth_service.dart'; import 'package:omi/services/desktop_update_service.dart'; import 'package:omi/services/notifications.dart'; import 'package:omi/services/notifications/action_item_notification_handler.dart'; +import 'package:omi/services/notifications/important_conversation_notification_handler.dart'; import 'package:omi/services/notifications/merge_notification_handler.dart'; import 'package:omi/services/services.dart'; import 'package:omi/utils/analytics/growthbook.dart'; @@ -101,6 +102,12 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { channelKey, isAppInForeground: false, ); + } else if (messageType == 'important_conversation') { + await ImportantConversationNotificationHandler.handleImportantConversation( + data, + channelKey, + isAppInForeground: false, + ); } } diff --git a/app/lib/pages/conversation_detail/page.dart b/app/lib/pages/conversation_detail/page.dart index 0824420262..7a0b1b06a2 100644 --- a/app/lib/pages/conversation_detail/page.dart +++ b/app/lib/pages/conversation_detail/page.dart @@ -28,6 +28,7 @@ import 'package:pull_down_button/pull_down_button.dart'; import 'conversation_detail_provider.dart'; import 'widgets/name_speaker_sheet.dart'; +import 'widgets/share_to_contacts_sheet.dart'; // import 'share.dart'; import 'test_prompts.dart'; // import 'package:omi/pages/settings/developer.dart'; @@ -36,8 +37,14 @@ import 'test_prompts.dart'; class ConversationDetailPage extends StatefulWidget { final ServerConversation conversation; final bool isFromOnboarding; + final bool openShareToContactsOnLoad; - const ConversationDetailPage({super.key, this.isFromOnboarding = false, required this.conversation}); + const ConversationDetailPage({ + super.key, + this.isFromOnboarding = false, + required this.conversation, + this.openShareToContactsOnLoad = false, + }); @override State createState() => _ConversationDetailPageState(); @@ -187,6 +194,15 @@ class _ConversationDetailPageState extends State with Ti await _appReviewService.showReviewPromptIfNeeded(context, isProcessingFirstConversation: true); } } + + // Auto-open share to contacts sheet if requested (from important conversation notification) + if (widget.openShareToContactsOnLoad && mounted) { + // Small delay to ensure the page is fully rendered + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) { + _showShareToContactsBottomSheet(); + } + } }); // _animationController = AnimationController( // vsync: this, @@ -194,8 +210,6 @@ class _ConversationDetailPageState extends State with Ti // )..repeat(reverse: true); // // _opacityAnimation = Tween(begin: 1.0, end: 0.5).animate(_animationController); - - super.initState(); } @override @@ -208,6 +222,12 @@ class _ConversationDetailPageState extends State with Ti super.dispose(); } + /// Show the share to contacts bottom sheet + void _showShareToContactsBottomSheet() { + final provider = Provider.of(context, listen: false); + showShareToContactsBottomSheet(context, provider.conversation); + } + String _getTabTitle(ConversationTab tab) { switch (tab) { case ConversationTab.transcript: diff --git a/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart b/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart new file mode 100644 index 0000000000..84aea85498 --- /dev/null +++ b/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart @@ -0,0 +1,511 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:omi/backend/http/api/conversations.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Contact with phone number for sharing +class ShareableContact { + final String id; + final String displayName; + final String phoneNumber; + bool isSelected; + + ShareableContact({ + required this.id, + required this.displayName, + required this.phoneNumber, + this.isSelected = false, + }); +} + +/// Show the share to contacts bottom sheet +void showShareToContactsBottomSheet(BuildContext context, ServerConversation conversation) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => ShareToContactsBottomSheet(conversation: conversation), + ); +} + +/// Bottom sheet for selecting contacts and sharing conversation via native SMS +class ShareToContactsBottomSheet extends StatefulWidget { + final ServerConversation conversation; + + const ShareToContactsBottomSheet({super.key, required this.conversation}); + + @override + State createState() => _ShareToContactsBottomSheetState(); +} + +class _ShareToContactsBottomSheetState extends State { + final TextEditingController _searchController = TextEditingController(); + List _contacts = []; + List _filteredContacts = []; + bool _isLoading = true; + bool _isPreparingShare = false; + String? _errorMessage; + bool _permissionDenied = false; + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadContacts() async { + setState(() { + _isLoading = true; + _errorMessage = null; + _permissionDenied = false; + }); + + // Request contacts permission using flutter_contacts' own method + final permissionGranted = await FlutterContacts.requestPermission(); + + if (!permissionGranted) { + setState(() { + _isLoading = false; + _permissionDenied = true; + _errorMessage = 'Contacts permission is required to share via SMS'; + }); + return; + } + + try { + // Fetch contacts with phone numbers + final contacts = await FlutterContacts.getContacts( + withProperties: true, + withPhoto: false, + ); + + // Filter contacts that have phone numbers and create ShareableContact list + final shareableContacts = []; + for (final contact in contacts) { + for (final phone in contact.phones) { + if (phone.number.isNotEmpty) { + shareableContacts.add(ShareableContact( + id: '${contact.id}_${phone.number}', + displayName: contact.displayName.isNotEmpty ? contact.displayName : phone.number, + phoneNumber: _cleanPhoneNumber(phone.number), + )); + } + } + } + + // Sort by display name + shareableContacts.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); + + setState(() { + _contacts = shareableContacts; + _filteredContacts = shareableContacts; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Failed to load contacts: $e'; + }); + } + } + + /// Clean phone number for SMS URI (remove spaces, dashes, etc.) + String _cleanPhoneNumber(String phone) { + return phone.replaceAll(RegExp(r'[\s\-\(\)]'), ''); + } + + void _filterContacts(String query) { + if (query.isEmpty) { + setState(() { + _filteredContacts = _contacts; + }); + return; + } + + final lowerQuery = query.toLowerCase(); + setState(() { + _filteredContacts = _contacts.where((contact) { + return contact.displayName.toLowerCase().contains(lowerQuery) || contact.phoneNumber.contains(query); + }).toList(); + }); + } + + void _toggleContactSelection(ShareableContact contact) { + setState(() { + contact.isSelected = !contact.isSelected; + }); + } + + List get _selectedContacts => _contacts.where((c) => c.isSelected).toList(); + + Future _openNativeSms() async { + final selected = _selectedContacts; + if (selected.isEmpty) return; + + setState(() { + _isPreparingShare = true; + _errorMessage = null; + }); + + try { + // First, set conversation to shared visibility + final shared = await setConversationVisibility(widget.conversation.id); + if (!shared) { + if (!mounted) return; + setState(() { + _isPreparingShare = false; + _errorMessage = 'Failed to prepare conversation for sharing. Please try again.'; + }); + return; + } + + // Build the share link and message + final shareLink = 'https://h.omi.me/conversations/${widget.conversation.id}'; + final message = "Here's what we just discussed: $shareLink"; + + // Build recipients string (comma-separated phone numbers) + final recipients = selected.map((c) => c.phoneNumber).join(','); + + // Build SMS URI + // iOS uses & for body separator, Android uses ? + final Uri smsUri; + if (Platform.isIOS) { + smsUri = Uri.parse('sms:$recipients&body=${Uri.encodeComponent(message)}'); + } else { + smsUri = Uri.parse('sms:$recipients?body=${Uri.encodeComponent(message)}'); + } + + if (!mounted) return; + + // Launch native SMS app + if (await canLaunchUrl(smsUri)) { + HapticFeedback.mediumImpact(); + Navigator.of(context).pop(); + await launchUrl(smsUri); + } else { + setState(() { + _isPreparingShare = false; + _errorMessage = 'Could not open SMS app. Please try again.'; + }); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isPreparingShare = false; + _errorMessage = 'Error: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFF1A1A1A), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade600, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Share via SMS', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.grey), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Select contacts to share your conversation summary', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade400, + ), + ), + ], + ), + ), + // Search bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + onChanged: _filterContacts, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search contacts...', + hintStyle: TextStyle(color: Colors.grey.shade500), + prefixIcon: const Icon(Icons.search, color: Colors.grey), + filled: true, + fillColor: const Color(0xFF2A2A2A), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + const SizedBox(height: 8), + // Selected count + if (_selectedContacts.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.deepPurple.withOpacity(0.3), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_selectedContacts.length} selected', + style: const TextStyle( + color: Colors.deepPurple, + fontWeight: FontWeight.w600, + ), + ), + ), + const Spacer(), + TextButton( + onPressed: () { + setState(() { + for (var contact in _contacts) { + contact.isSelected = false; + } + }); + }, + child: const Text( + 'Clear all', + style: TextStyle(color: Colors.grey), + ), + ), + ], + ), + ), + // Error message + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ], + ), + ), + ), + // Contacts list + Expanded( + child: _buildContactsList(scrollController), + ), + // Send button + if (!_permissionDenied) + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _selectedContacts.isEmpty || _isPreparingShare ? null : _openNativeSms, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + disabledBackgroundColor: Colors.grey.shade800, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isPreparingShare + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + _selectedContacts.isEmpty + ? 'Select contacts to share' + : 'Share with ${_selectedContacts.length} contact${_selectedContacts.length > 1 ? 's' : ''}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildContactsList(ScrollController scrollController) { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.deepPurple), + ); + } + + if (_permissionDenied) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.contacts, size: 64, color: Colors.grey.shade600), + const SizedBox(height: 16), + Text( + 'Contacts permission required', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey.shade400, + ), + ), + const SizedBox(height: 8), + Text( + 'Please grant contacts permission to share via SMS', + style: TextStyle(color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () async { + // Open app settings + if (Platform.isIOS) { + await launchUrl(Uri.parse('app-settings:')); + } else { + await launchUrl(Uri.parse('package:com.friend.ios')); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + ), + child: const Text('Open Settings'), + ), + ], + ), + ); + } + + if (_filteredContacts.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 64, color: Colors.grey.shade600), + const SizedBox(height: 16), + Text( + _searchController.text.isEmpty ? 'No contacts with phone numbers found' : 'No contacts match your search', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade400, + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: _filteredContacts.length, + itemBuilder: (context, index) { + final contact = _filteredContacts[index]; + return _buildContactTile(contact); + }, + ); + } + + Widget _buildContactTile(ShareableContact contact) { + return ListTile( + onTap: () => _toggleContactSelection(contact), + leading: CircleAvatar( + backgroundColor: contact.isSelected ? Colors.deepPurple : Colors.grey.shade800, + child: contact.isSelected + ? const Icon(Icons.check, color: Colors.white, size: 20) + : Text( + contact.displayName.isNotEmpty ? contact.displayName[0].toUpperCase() : '?', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + title: Text( + contact.displayName, + style: TextStyle( + color: Colors.white, + fontWeight: contact.isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + subtitle: Text( + contact.phoneNumber, + style: TextStyle(color: Colors.grey.shade500, fontSize: 12), + ), + trailing: contact.isSelected + ? const Icon(Icons.check_circle, color: Colors.deepPurple) + : Icon(Icons.circle_outlined, color: Colors.grey.shade600), + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 60fc3107f1..9908fbad47 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -16,7 +16,9 @@ import 'package:omi/pages/apps/app_detail/app_detail.dart'; import 'package:omi/pages/apps/page.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/conversations/conversations_page.dart'; +import 'package:omi/pages/conversation_detail/page.dart'; import 'package:omi/pages/memories/page.dart'; +import 'package:omi/backend/http/api/conversations.dart'; import 'package:omi/pages/settings/data_privacy_page.dart'; import 'package:omi/pages/settings/settings_drawer.dart'; import 'package:omi/providers/app_provider.dart'; @@ -306,6 +308,34 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), ); break; + case "conversation": + // Handle conversation deep link: /conversation/{id}?share=1 + if (detailPageId != null && detailPageId.isNotEmpty) { + // Check for share query param + final shouldOpenShare = navigateToUri?.queryParameters['share'] == '1'; + final conversationId = detailPageId; // Capture non-null value + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + + // Fetch conversation from server + final conversation = await getConversationById(conversationId); + if (conversation != null && mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ConversationDetailPage( + conversation: conversation, + openShareToContactsOnLoad: shouldOpenShare, + ), + ), + ); + } else { + debugPrint('Conversation not found: $conversationId'); + } + }); + } + break; default: } }); diff --git a/app/lib/services/notifications/important_conversation_notification_handler.dart b/app/lib/services/notifications/important_conversation_notification_handler.dart new file mode 100644 index 0000000000..f55deae53d --- /dev/null +++ b/app/lib/services/notifications/important_conversation_notification_handler.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:flutter/material.dart'; + +/// Event data for important conversation completion +class ImportantConversationEvent { + final String conversationId; + final String navigateTo; + + ImportantConversationEvent({ + required this.conversationId, + required this.navigateTo, + }); +} + +/// Handler for important conversation FCM notifications +/// Triggered when a conversation >30 minutes completes processing +class ImportantConversationNotificationHandler { + static final _awesomeNotifications = AwesomeNotifications(); + + /// Stream controller for important conversation events + static final StreamController _importantConversationController = + StreamController.broadcast(); + + /// Stream to listen for important conversation events + static Stream get onImportantConversation => _importantConversationController.stream; + + /// Handle important_conversation FCM data message + /// + /// The app receives this when a long conversation (>30 min) completes processing. + /// - Foreground: Provider can show toast, then user can tap notification + /// - Background: Shows a local notification that navigates to conversation detail with share sheet + static Future handleImportantConversation( + Map data, + String channelKey, { + bool isAppInForeground = true, + }) async { + final conversationId = data['conversation_id']; + final navigateTo = data['navigate_to'] as String?; + + if (conversationId == null) { + debugPrint('[ImportantConversationNotification] Invalid data: missing conversation_id'); + return; + } + + debugPrint('[ImportantConversationNotification] Important conversation completed: $conversationId'); + debugPrint('[ImportantConversationNotification] Navigate to: $navigateTo'); + + // Broadcast the event so providers can update their state + _importantConversationController.add(ImportantConversationEvent( + conversationId: conversationId, + navigateTo: navigateTo ?? '/conversation/$conversationId?share=1', + )); + + // Always show notification (foreground and background) so user can tap to share + await _showImportantConversationNotification( + channelKey: channelKey, + conversationId: conversationId, + navigateTo: navigateTo ?? '/conversation/$conversationId?share=1', + ); + } + + /// Show local notification for important conversation + static Future _showImportantConversationNotification({ + required String channelKey, + required String conversationId, + required String navigateTo, + }) async { + try { + final notificationId = conversationId.hashCode; + + await _awesomeNotifications.createNotification( + content: NotificationContent( + id: notificationId, + channelKey: channelKey, + title: 'Important Conversation', + body: 'You just had an important convo. Tap to share the summary with others.', + payload: { + 'conversation_id': conversationId, + 'navigate_to': navigateTo, + }, + notificationLayout: NotificationLayout.Default, + category: NotificationCategory.Social, + ), + ); + + debugPrint('[ImportantConversationNotification] Showed notification for conversation: $conversationId'); + } catch (e) { + debugPrint('[ImportantConversationNotification] Error showing notification: $e'); + } + } +} diff --git a/app/lib/services/notifications/notification_service_fcm.dart b/app/lib/services/notifications/notification_service_fcm.dart index aec783a753..56944cf7a0 100644 --- a/app/lib/services/notifications/notification_service_fcm.dart +++ b/app/lib/services/notifications/notification_service_fcm.dart @@ -1,261 +1,269 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:math'; -import 'dart:ui'; - -import 'package:awesome_notifications/awesome_notifications.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_timezone/flutter_timezone.dart'; -import 'package:omi/backend/http/api/notifications.dart'; -import 'package:omi/backend/schema/message.dart'; -import 'package:omi/services/notifications/notification_interface.dart'; -import 'package:omi/services/notifications/action_item_notification_handler.dart'; -import 'package:omi/services/notifications/merge_notification_handler.dart'; -import 'package:omi/utils/analytics/intercom.dart'; -import 'package:omi/utils/platform/platform_service.dart'; - -/// Firebase Cloud Messaging enabled notification service -/// Supports iOS, Android, macOS, web, and Linux with full FCM functionality -class _FCMNotificationService implements NotificationInterface { - _FCMNotificationService._(); - - MethodChannel platform = const MethodChannel('com.friend.ios/notifyOnKill'); - final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; - - final channel = NotificationChannel( - channelGroupKey: 'channel_group_key', - channelKey: 'channel', - channelName: 'Omi Notifications', - channelDescription: 'Notification channel for Omi', - defaultColor: const Color(0xFF9D50DD), - ledColor: Colors.white, - ); - - final AwesomeNotifications _awesomeNotifications = AwesomeNotifications(); - - @override - Future initialize() async { - await _initializeAwesomeNotifications(); - // Calling it here because the APNS token can sometimes arrive early or it might take some time (like a few seconds) - // Reference: https://github.com/firebase/flutterfire/issues/12244#issuecomment-1969286794 - await _firebaseMessaging.getAPNSToken(); - listenForMessages(); - } - - Future _initializeAwesomeNotifications() async { - bool initialized = await _awesomeNotifications.initialize( - // set the icon to null if you want to use the default app icon - 'resource://drawable/icon', - [ - NotificationChannel( - channelGroupKey: 'channel_group_key', - channelKey: channel.channelKey, - channelName: channel.channelName, - channelDescription: channel.channelDescription, - defaultColor: const Color(0xFF9D50DD), - ledColor: Colors.white, - ) - ], - // Channel groups are only visual and are not required - channelGroups: [ - NotificationChannelGroup( - channelGroupKey: channel.channelKey!, - channelGroupName: channel.channelName!, - ) - ], - debug: false); - - debugPrint('initializeNotifications: $initialized'); - } - - @override - void showNotification({ - required int id, - required String title, - required String body, - Map? payload, - bool wakeUpScreen = false, - NotificationSchedule? schedule, - NotificationLayout layout = NotificationLayout.Default, - }) { - _awesomeNotifications.createNotification( - content: NotificationContent( - id: id, - channelKey: channel.channelKey!, - actionType: ActionType.Default, - title: title, - body: body, - payload: payload, - notificationLayout: layout, - ), - ); - } - - @override - Future requestNotificationPermissions() async { - bool isAllowed = await _awesomeNotifications.isNotificationAllowed(); - if (!isAllowed) { - isAllowed = await _awesomeNotifications.requestPermissionToSendNotifications(); - register(); - } - return isAllowed; - } - - @override - Future register() async { - try { - if (PlatformService.isDesktop) return; - await platform.invokeMethod( - 'setNotificationOnKillService', - { - 'title': "Your Omi Device Disconnected", - 'description': "Please keep your app opened to continue using your Omi.", - }, - ); - } catch (e) { - debugPrint('NotifOnKill error: $e'); - } - } - - @override - Future getTimeZone() async { - final String currentTimeZone = await FlutterTimezone.getLocalTimezone(); - return currentTimeZone; - } - - @override - Future saveFcmToken(String? token) async { - if (token == null) return; - String timeZone = await getTimeZone(); - if (FirebaseAuth.instance.currentUser != null && token.isNotEmpty) { - await saveFcmTokenServer(token: token, timeZone: timeZone); - - try { - await IntercomManager.instance.sendTokenToIntercom(token); - } catch (e) { - print(e); - } - } - } - - @override - void saveNotificationToken() async { - if (Platform.isIOS || Platform.isMacOS) { - String? apnsToken; - for (int i = 0; i < 10; i++) { - apnsToken = await _firebaseMessaging.getAPNSToken(); - if (apnsToken != null) break; - await Future.delayed(const Duration(seconds: 1)); - } - - if (apnsToken == null) { - debugPrint('APNS token not available yet, will retry on refresh'); - _firebaseMessaging.onTokenRefresh.listen(saveFcmToken); - return; - } - } - - String? token = await _firebaseMessaging.getToken(); - await saveFcmToken(token); - _firebaseMessaging.onTokenRefresh.listen(saveFcmToken); - } - - @override - Future hasNotificationPermissions() async { - return await _awesomeNotifications.isNotificationAllowed(); - } - - @override - Future createNotification({ - String title = '', - String body = '', - int notificationId = 1, - Map? payload, - }) async { - var allowed = await _awesomeNotifications.isNotificationAllowed(); - debugPrint('createNotification: $allowed'); - if (!allowed) return; - debugPrint('createNotification ~ Creating notification: $title'); - showNotification(id: notificationId, title: title, body: body, wakeUpScreen: true, payload: payload); - } - - @override - void clearNotification(int id) => _awesomeNotifications.cancel(id); - - // FIXME: Causes the different behavior on android and iOS - bool _shouldShowForegroundNotificationOnFCMMessageReceived() { - return Platform.isAndroid; - } - - @override - Future listenForMessages() async { - FirebaseMessaging.onMessage.listen((RemoteMessage message) { - final data = message.data; - final noti = message.notification; - - // Plugin - if (data.isNotEmpty) { - late Map payload = {}; - payload.addAll({ - "navigate_to": data['navigate_to'] ?? "", - }); - - // Handle action item data messages - final messageType = data['type']; - if (messageType == 'action_item_reminder') { - ActionItemNotificationHandler.handleReminderMessage(data, channel.channelKey!); - return; - } else if (messageType == 'action_item_update') { - ActionItemNotificationHandler.handleUpdateMessage(data, channel.channelKey!); - return; - } else if (messageType == 'action_item_delete') { - ActionItemNotificationHandler.handleDeletionMessage(data); - return; - } else if (messageType == 'merge_completed') { - MergeNotificationHandler.handleMergeCompleted( - data, - channel.channelKey!, - isAppInForeground: true, - ); - return; - } - - // plugin, daily summary - final notificationType = data['notification_type']; - if (notificationType == 'plugin' || notificationType == 'daily_summary') { - data['from_integration'] = data['from_integration'] == 'true'; - _serverMessageStreamController.add(ServerMessage.fromJson(data)); - } - if (noti != null && _shouldShowForegroundNotificationOnFCMMessageReceived()) { - _showForegroundNotification(noti: noti, payload: payload); - } - return; - } - - // Announcement likes - if (noti != null && _shouldShowForegroundNotificationOnFCMMessageReceived()) { - _showForegroundNotification(noti: noti, layout: NotificationLayout.BigText); - return; - } - }); - } - - final _serverMessageStreamController = StreamController.broadcast(); - - @override - Stream get listenForServerMessages => _serverMessageStreamController.stream; - - Future _showForegroundNotification( - {required RemoteNotification noti, - NotificationLayout layout = NotificationLayout.Default, - Map? payload}) async { - final id = Random().nextInt(10000); - showNotification(id: id, title: noti.title!, body: noti.body!, layout: layout, payload: payload); - } -} - -/// Factory function to create the FCM notification service -NotificationInterface createNotificationService() => _FCMNotificationService._(); +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:awesome_notifications/awesome_notifications.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:omi/backend/http/api/notifications.dart'; +import 'package:omi/backend/schema/message.dart'; +import 'package:omi/services/notifications/notification_interface.dart'; +import 'package:omi/services/notifications/action_item_notification_handler.dart'; +import 'package:omi/services/notifications/important_conversation_notification_handler.dart'; +import 'package:omi/services/notifications/merge_notification_handler.dart'; +import 'package:omi/utils/analytics/intercom.dart'; +import 'package:omi/utils/platform/platform_service.dart'; + +/// Firebase Cloud Messaging enabled notification service +/// Supports iOS, Android, macOS, web, and Linux with full FCM functionality +class _FCMNotificationService implements NotificationInterface { + _FCMNotificationService._(); + + MethodChannel platform = const MethodChannel('com.friend.ios/notifyOnKill'); + final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance; + + final channel = NotificationChannel( + channelGroupKey: 'channel_group_key', + channelKey: 'channel', + channelName: 'Omi Notifications', + channelDescription: 'Notification channel for Omi', + defaultColor: const Color(0xFF9D50DD), + ledColor: Colors.white, + ); + + final AwesomeNotifications _awesomeNotifications = AwesomeNotifications(); + + @override + Future initialize() async { + await _initializeAwesomeNotifications(); + // Calling it here because the APNS token can sometimes arrive early or it might take some time (like a few seconds) + // Reference: https://github.com/firebase/flutterfire/issues/12244#issuecomment-1969286794 + await _firebaseMessaging.getAPNSToken(); + listenForMessages(); + } + + Future _initializeAwesomeNotifications() async { + bool initialized = await _awesomeNotifications.initialize( + // set the icon to null if you want to use the default app icon + 'resource://drawable/icon', + [ + NotificationChannel( + channelGroupKey: 'channel_group_key', + channelKey: channel.channelKey, + channelName: channel.channelName, + channelDescription: channel.channelDescription, + defaultColor: const Color(0xFF9D50DD), + ledColor: Colors.white, + ) + ], + // Channel groups are only visual and are not required + channelGroups: [ + NotificationChannelGroup( + channelGroupKey: channel.channelKey!, + channelGroupName: channel.channelName!, + ) + ], + debug: false); + + debugPrint('initializeNotifications: $initialized'); + } + + @override + void showNotification({ + required int id, + required String title, + required String body, + Map? payload, + bool wakeUpScreen = false, + NotificationSchedule? schedule, + NotificationLayout layout = NotificationLayout.Default, + }) { + _awesomeNotifications.createNotification( + content: NotificationContent( + id: id, + channelKey: channel.channelKey!, + actionType: ActionType.Default, + title: title, + body: body, + payload: payload, + notificationLayout: layout, + ), + ); + } + + @override + Future requestNotificationPermissions() async { + bool isAllowed = await _awesomeNotifications.isNotificationAllowed(); + if (!isAllowed) { + isAllowed = await _awesomeNotifications.requestPermissionToSendNotifications(); + register(); + } + return isAllowed; + } + + @override + Future register() async { + try { + if (PlatformService.isDesktop) return; + await platform.invokeMethod( + 'setNotificationOnKillService', + { + 'title': "Your Omi Device Disconnected", + 'description': "Please keep your app opened to continue using your Omi.", + }, + ); + } catch (e) { + debugPrint('NotifOnKill error: $e'); + } + } + + @override + Future getTimeZone() async { + final String currentTimeZone = await FlutterTimezone.getLocalTimezone(); + return currentTimeZone; + } + + @override + Future saveFcmToken(String? token) async { + if (token == null) return; + String timeZone = await getTimeZone(); + if (FirebaseAuth.instance.currentUser != null && token.isNotEmpty) { + await saveFcmTokenServer(token: token, timeZone: timeZone); + + try { + await IntercomManager.instance.sendTokenToIntercom(token); + } catch (e) { + print(e); + } + } + } + + @override + void saveNotificationToken() async { + if (Platform.isIOS || Platform.isMacOS) { + String? apnsToken; + for (int i = 0; i < 10; i++) { + apnsToken = await _firebaseMessaging.getAPNSToken(); + if (apnsToken != null) break; + await Future.delayed(const Duration(seconds: 1)); + } + + if (apnsToken == null) { + debugPrint('APNS token not available yet, will retry on refresh'); + _firebaseMessaging.onTokenRefresh.listen(saveFcmToken); + return; + } + } + + String? token = await _firebaseMessaging.getToken(); + await saveFcmToken(token); + _firebaseMessaging.onTokenRefresh.listen(saveFcmToken); + } + + @override + Future hasNotificationPermissions() async { + return await _awesomeNotifications.isNotificationAllowed(); + } + + @override + Future createNotification({ + String title = '', + String body = '', + int notificationId = 1, + Map? payload, + }) async { + var allowed = await _awesomeNotifications.isNotificationAllowed(); + debugPrint('createNotification: $allowed'); + if (!allowed) return; + debugPrint('createNotification ~ Creating notification: $title'); + showNotification(id: notificationId, title: title, body: body, wakeUpScreen: true, payload: payload); + } + + @override + void clearNotification(int id) => _awesomeNotifications.cancel(id); + + // FIXME: Causes the different behavior on android and iOS + bool _shouldShowForegroundNotificationOnFCMMessageReceived() { + return Platform.isAndroid; + } + + @override + Future listenForMessages() async { + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + final data = message.data; + final noti = message.notification; + + // Plugin + if (data.isNotEmpty) { + late Map payload = {}; + payload.addAll({ + "navigate_to": data['navigate_to'] ?? "", + }); + + // Handle action item data messages + final messageType = data['type']; + if (messageType == 'action_item_reminder') { + ActionItemNotificationHandler.handleReminderMessage(data, channel.channelKey!); + return; + } else if (messageType == 'action_item_update') { + ActionItemNotificationHandler.handleUpdateMessage(data, channel.channelKey!); + return; + } else if (messageType == 'action_item_delete') { + ActionItemNotificationHandler.handleDeletionMessage(data); + return; + } else if (messageType == 'merge_completed') { + MergeNotificationHandler.handleMergeCompleted( + data, + channel.channelKey!, + isAppInForeground: true, + ); + return; + } else if (messageType == 'important_conversation') { + ImportantConversationNotificationHandler.handleImportantConversation( + data, + channel.channelKey!, + isAppInForeground: true, + ); + return; + } + + // plugin, daily summary + final notificationType = data['notification_type']; + if (notificationType == 'plugin' || notificationType == 'daily_summary') { + data['from_integration'] = data['from_integration'] == 'true'; + _serverMessageStreamController.add(ServerMessage.fromJson(data)); + } + if (noti != null && _shouldShowForegroundNotificationOnFCMMessageReceived()) { + _showForegroundNotification(noti: noti, payload: payload); + } + return; + } + + // Announcement likes + if (noti != null && _shouldShowForegroundNotificationOnFCMMessageReceived()) { + _showForegroundNotification(noti: noti, layout: NotificationLayout.BigText); + return; + } + }); + } + + final _serverMessageStreamController = StreamController.broadcast(); + + @override + Stream get listenForServerMessages => _serverMessageStreamController.stream; + + Future _showForegroundNotification( + {required RemoteNotification noti, + NotificationLayout layout = NotificationLayout.Default, + Map? payload}) async { + final id = Random().nextInt(10000); + showNotification(id: id, title: noti.title!, body: noti.body!, layout: layout, payload: payload); + } +} + +/// Factory function to create the FCM notification service +NotificationInterface createNotificationService() => _FCMNotificationService._(); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 556b62650a..0a729f53bf 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: pdf: ^3.11.0 intl: 0.20.2 permission_handler: ^12.0.0+1 + flutter_contacts: ^1.1.9+2 share_plus: 11.0.0 shared_preferences: 2.5.3 url_launcher: ^6.3.0 diff --git a/backend/database/redis_db.py b/backend/database/redis_db.py index 1e369a74e4..bdc7740c03 100644 --- a/backend/database/redis_db.py +++ b/backend/database/redis_db.py @@ -693,6 +693,21 @@ def has_silent_user_notification_been_sent(uid: str) -> bool: return r.exists(f'users:{uid}:silent_notification_sent') +# ****************************************************** +# ******* IMPORTANT CONVERSATION NOTIFICATIONS ********* +# ****************************************************** + + +def set_important_conversation_notification_sent(uid: str, conversation_id: str): + """Mark that important conversation notification was sent for this conversation (no expiry - one-time per conversation)""" + r.set(f'users:{uid}:important_conv_notif:{conversation_id}', '1') + + +def has_important_conversation_notification_been_sent(uid: str, conversation_id: str) -> bool: + """Check if important conversation notification was already sent for this conversation""" + return r.exists(f'users:{uid}:important_conv_notif:{conversation_id}') + + # ****************************************************** # ******** CONVERSATION SUMMARY APP IDS **************** # ****************************************************** diff --git a/backend/utils/conversations/process_conversation.py b/backend/utils/conversations/process_conversation.py index 356da99597..f196e36f5d 100644 --- a/backend/utils/conversations/process_conversation.py +++ b/backend/utils/conversations/process_conversation.py @@ -563,12 +563,56 @@ def process_conversation( # Update persona prompts with new conversation threading.Thread(target=update_personas_async, args=(uid,)).start() + # Send important conversation notification for long conversations (>30 minutes) + threading.Thread( + target=_send_important_conversation_notification_if_needed, + args=(uid, conversation), + ).start() + # TODO: trigger external integrations here too print('process_conversation completed conversation.id=', conversation.id) return conversation +def _send_important_conversation_notification_if_needed(uid: str, conversation: Conversation): + """ + Send notification for long conversations (>30 minutes) that just completed. + Only sends once per conversation using Redis deduplication. + """ + from utils.notifications import send_important_conversation_message + + # Skip if conversation is discarded + if conversation.discarded: + return + + # Check if we have valid timestamps to compute duration + if not conversation.started_at or not conversation.finished_at: + print(f"Cannot compute duration for conversation {conversation.id}: missing timestamps") + return + + # Calculate duration in seconds + duration_seconds = (conversation.finished_at - conversation.started_at).total_seconds() + + # Only notify for conversations longer than 30 minutes (1800 seconds) + if duration_seconds < 1800: + return + + # Check if notification was already sent for this conversation + if redis_db.has_important_conversation_notification_been_sent(uid, conversation.id): + print(f"Important conversation notification already sent for {conversation.id}") + return + + # Mark as sent before sending to prevent duplicates + redis_db.set_important_conversation_notification_sent(uid, conversation.id) + + # Send the notification + print( + f"Sending important conversation notification for {conversation.id} (duration: {duration_seconds/60:.1f} mins)" + ) + send_important_conversation_message(uid, conversation.id) + + def process_user_emotion(uid: str, language_code: str, conversation: Conversation, urls: [str]): print('process_user_emotion conversation.id=', conversation.id) diff --git a/backend/utils/notifications.py b/backend/utils/notifications.py index 5e0f7312ec..0d521b742b 100644 --- a/backend/utils/notifications.py +++ b/backend/utils/notifications.py @@ -371,7 +371,51 @@ def send_merge_completed_message(user_id: str, merged_conversation_id: str, remo response = messaging.send(message) print(f'Merge completed message sent to device: {response}') except Exception as e: - _handle_send_error(e, token) + print(f'FCM send error for merge completed: {e}') + + +def send_important_conversation_message(user_id: str, conversation_id: str): + """ + Sends a data-only FCM message when a long conversation (>30 min) completes. + + The app receives this and: + - Shows a local notification: "You just had an important convo, click to share summary" + - On tap: navigates to conversation detail with share sheet auto-open + + Args: + user_id: The user's Firebase UID + conversation_id: ID of the completed conversation + """ + tokens = notification_db.get_all_tokens(user_id) + if not tokens: + print(f"No notification tokens found for user {user_id} for important conversation notification") + return + + # FCM data values must be strings + data = { + 'type': 'important_conversation', + 'conversation_id': conversation_id, + 'navigate_to': f'/conversation/{conversation_id}?share=1', + } + + for token in tokens: + message = messaging.Message( + data=data, + token=token, + android=messaging.AndroidConfig(priority='high'), + apns=messaging.APNSConfig( + headers={ + 'apns-priority': '10', + }, + payload=messaging.APNSPayload(aps=messaging.Aps(content_available=True)), + ), + ) + + try: + response = messaging.send(message) + print(f'Important conversation message sent to device: {response}') + except Exception as e: + print(f'FCM send error for important conversation: {e}') def send_action_item_update_message(user_id: str, action_item_id: str, description: str, due_at: str): From b9694fa13215b55c8f5531f06be10d79b3ceeb53 Mon Sep 17 00:00:00 2001 From: Aarav Garg Date: Tue, 23 Dec 2025 21:48:54 +0800 Subject: [PATCH 2/4] mixpanel --- .../widgets/share_to_contacts_sheet.dart | 10 +++++++++ ...ant_conversation_notification_handler.dart | 4 ++++ app/lib/utils/analytics/mixpanel.dart | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart b/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart index 84aea85498..adfd9b564e 100644 --- a/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart +++ b/app/lib/pages/conversation_detail/widgets/share_to_contacts_sheet.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:omi/backend/http/api/conversations.dart'; import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:url_launcher/url_launcher.dart'; /// Contact with phone number for sharing @@ -54,6 +55,8 @@ class _ShareToContactsBottomSheetState extends State @override void initState() { super.initState(); + // Track sheet opened + MixpanelManager().shareToContactsSheetOpened(widget.conversation.id); _loadContacts(); } @@ -144,6 +147,11 @@ class _ShareToContactsBottomSheetState extends State setState(() { contact.isSelected = !contact.isSelected; }); + // Track selection changes + final selectedCount = _selectedContacts.length; + if (selectedCount > 0) { + MixpanelManager().shareToContactsSelected(widget.conversation.id, selectedCount); + } } List get _selectedContacts => _contacts.where((c) => c.isSelected).toList(); @@ -189,6 +197,8 @@ class _ShareToContactsBottomSheetState extends State // Launch native SMS app if (await canLaunchUrl(smsUri)) { + // Track SMS opened + MixpanelManager().shareToContactsSmsOpened(widget.conversation.id, selected.length); HapticFeedback.mediumImpact(); Navigator.of(context).pop(); await launchUrl(smsUri); diff --git a/app/lib/services/notifications/important_conversation_notification_handler.dart b/app/lib/services/notifications/important_conversation_notification_handler.dart index f55deae53d..031c161870 100644 --- a/app/lib/services/notifications/important_conversation_notification_handler.dart +++ b/app/lib/services/notifications/important_conversation_notification_handler.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:flutter/material.dart'; +import 'package:omi/utils/analytics/mixpanel.dart'; /// Event data for important conversation completion class ImportantConversationEvent { @@ -47,6 +48,9 @@ class ImportantConversationNotificationHandler { debugPrint('[ImportantConversationNotification] Important conversation completed: $conversationId'); debugPrint('[ImportantConversationNotification] Navigate to: $navigateTo'); + // Track notification received + MixpanelManager().importantConversationNotificationReceived(conversationId); + // Broadcast the event so providers can update their state _importantConversationController.add(ImportantConversationEvent( conversationId: conversationId, diff --git a/app/lib/utils/analytics/mixpanel.dart b/app/lib/utils/analytics/mixpanel.dart index aac1965263..fa842be891 100644 --- a/app/lib/utils/analytics/mixpanel.dart +++ b/app/lib/utils/analytics/mixpanel.dart @@ -447,6 +447,27 @@ class MixpanelManager { properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, ); + // Important Conversation Share Events + void importantConversationNotificationReceived(String conversationId) => track( + 'Important Conversation Notification Received', + properties: {'conversation_id': conversationId}, + ); + + void shareToContactsSheetOpened(String conversationId) => track( + 'Share To Contacts Sheet Opened', + properties: {'conversation_id': conversationId}, + ); + + void shareToContactsSelected(String conversationId, int contactCount) => track( + 'Share To Contacts Selected', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); + + void shareToContactsSmsOpened(String conversationId, int contactCount) => track( + 'Share To Contacts SMS Opened', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); + void chatMessageConversationClicked(ServerConversation conversation) => track('Chat Message Memory Clicked', properties: getConversationEventProperties(conversation)); From b0362ac5d9aeaabef649301e23aa2271f0b8f0d2 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 7 Jan 2026 14:21:53 +0530 Subject: [PATCH 3/4] use existing _send_to_user --- backend/utils/notifications.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/backend/utils/notifications.py b/backend/utils/notifications.py index 8b2b8f128a..cb4440337e 100644 --- a/backend/utils/notifications.py +++ b/backend/utils/notifications.py @@ -74,9 +74,7 @@ def _build_apns_config(tag: str, is_background: bool = False) -> messaging.APNSC return messaging.APNSConfig(headers=headers) -def _build_webpush_config( - tag: str, title: str = None, body: str = None, link: str = '/' -) -> messaging.WebpushConfig: +def _build_webpush_config(tag: str, title: str = None, body: str = None, link: str = '/') -> messaging.WebpushConfig: """Build WebPush configuration for browser notifications. Note: WebpushNotification must explicitly include title/body because @@ -408,24 +406,8 @@ def send_important_conversation_message(user_id: str, conversation_id: str): 'navigate_to': f'/conversation/{conversation_id}?share=1', } - for token in tokens: - message = messaging.Message( - data=data, - token=token, - android=messaging.AndroidConfig(priority='high'), - apns=messaging.APNSConfig( - headers={ - 'apns-priority': '10', - }, - payload=messaging.APNSPayload(aps=messaging.Aps(content_available=True)), - ), - ) - - try: - response = messaging.send(message) - print(f'Important conversation message sent to device: {response}') - except Exception as e: - print(f'FCM send error for important conversation: {e}') + tag = _generate_tag(f'{user_id}:important_conversation:{conversation_id}') + _send_to_user(user_id, tag, data=data, is_background=True, priority='high') def send_action_item_update_message(user_id: str, action_item_id: str, description: str, due_at: str): From ba8cc40676e7974133eee9aa4da28b28116aebe9 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 7 Jan 2026 14:26:33 +0530 Subject: [PATCH 4/4] misc --- .../utils/conversations/process_conversation.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/utils/conversations/process_conversation.py b/backend/utils/conversations/process_conversation.py index a496162076..0274bca102 100644 --- a/backend/utils/conversations/process_conversation.py +++ b/backend/utils/conversations/process_conversation.py @@ -30,6 +30,7 @@ CreateConversation, ConversationSource, ) +from utils.notifications import send_important_conversation_message from models.conversation import CalendarMeetingContext from models.other import Person from models.task import Task, TaskStatus, TaskAction, TaskActionProvider @@ -324,10 +325,10 @@ def _update_goal_progress(uid: str, conversation: Conversation): text = conversation.structured.overview elif conversation.transcript_segments: text = " ".join([s.text for s in conversation.transcript_segments[:20]]) - + if not text or len(text) < 10: return - + # Use utility function to extract and update goal progress extract_and_update_goal_progress(uid, text) except Exception as e: @@ -367,16 +368,16 @@ def _extract_memories(uid: str, conversation: Conversation): if len(parsed_memories) > 0: record_usage(uid, memories_created=len(parsed_memories)) - + try: from utils.llm.knowledge_graph import extract_knowledge_from_memory from database import users as users_db - + user = users_db.get_user_store_recording_permission(uid) user_name = user.get('name', 'User') if user else 'User' - + from database.memories import set_memory_kg_extracted - + for memory_db_obj in parsed_memories: if memory_db_obj.kg_extracted: continue @@ -655,7 +656,6 @@ def _send_important_conversation_notification_if_needed(uid: str, conversation: Send notification for long conversations (>30 minutes) that just completed. Only sends once per conversation using Redis deduplication. """ - from utils.notifications import send_important_conversation_message # Skip if conversation is discarded if conversation.discarded: