From 905819a1d56de6cce4ea014a40386eec70d5c876 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 18 May 2026 16:14:25 +0300 Subject: [PATCH] Sync update: 2026-05-18 16:14:25 --- whatsapp_app/ios/Runner/Info.plist | 2 + .../controllers/conversations_controller.dart | 18 +- whatsapp_app/lib/main.dart | 5 + .../lib/services/contacts_service.dart | 85 +++++++ .../lib/services/whatsapp_service.dart | 22 ++ whatsapp_app/lib/widgets/message_bubble.dart | 231 ++++++++++++++---- whatsapp_app/pubspec.lock | 8 + whatsapp_app/pubspec.yaml | 1 + whatsapp_bridge/server.js | 76 +++++- 9 files changed, 388 insertions(+), 60 deletions(-) create mode 100644 whatsapp_app/lib/services/contacts_service.dart diff --git a/whatsapp_app/ios/Runner/Info.plist b/whatsapp_app/ios/Runner/Info.plist index bb91378..ad4f66e 100644 --- a/whatsapp_app/ios/Runner/Info.plist +++ b/whatsapp_app/ios/Runner/Info.plist @@ -66,5 +66,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSContactsUsageDescription + This app requires contacts access to match phone numbers with your local address book names. diff --git a/whatsapp_app/lib/controllers/conversations_controller.dart b/whatsapp_app/lib/controllers/conversations_controller.dart index 7a6c3a6..5856a2f 100644 --- a/whatsapp_app/lib/controllers/conversations_controller.dart +++ b/whatsapp_app/lib/controllers/conversations_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/whatsapp_service.dart'; +import '../services/contacts_service.dart'; import '../models/conversation_model.dart'; class ConversationsController extends GetxController { @@ -49,6 +50,17 @@ class ConversationsController extends GetxController { super.onClose(); } + // Helper to resolve contact names from the local address book + ConversationModel _resolveContactNames(ConversationModel c) { + if (c.isGroup) return c; // Skip group chats + + final parts = c.id.split('@'); + final phoneNumber = parts[0]; + + final matchedName = Get.find().getContactName(phoneNumber, c.name); + return c.copyWith(name: matchedName); + } + // ── Local Caching ──────────────────────────────────────────────────────── Future _loadCachedConversations() async { try { @@ -56,7 +68,7 @@ class ConversationsController extends GetxController { final cached = prefs.getString('cached_conversations'); if (cached != null) { final List decoded = jsonDecode(cached); - conversations.assignAll(decoded.map((c) => ConversationModel.fromJson(c as Map))); + conversations.assignAll(decoded.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map)))); print('[CACHE] Loaded ${conversations.length} conversations instantly.'); } } catch (e) { @@ -85,7 +97,7 @@ class ConversationsController extends GetxController { final res = await _svc.getConversations(); if (res['type'] == 'conversations') { final List data = res['data'] ?? []; - conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map))); + conversations.assignAll(data.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map)))); _saveConversationsToCache(data); } else { errorMessage.value = res['message'] ?? 'Failed to load conversations'; @@ -189,7 +201,7 @@ class ConversationsController extends GetxController { final res = await _svc.getConversations(); if (res['type'] == 'conversations') { final List data = res['data'] ?? []; - conversations.assignAll(data.map((c) => ConversationModel.fromJson(c as Map))); + conversations.assignAll(data.map((c) => _resolveContactNames(ConversationModel.fromJson(c as Map)))); _saveConversationsToCache(data); } } catch (_) {} diff --git a/whatsapp_app/lib/main.dart b/whatsapp_app/lib/main.dart index bc18c9b..8ee755e 100644 --- a/whatsapp_app/lib/main.dart +++ b/whatsapp_app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:firebase_core/firebase_core.dart'; import 'services/whatsapp_service.dart'; import 'services/firebase_service.dart'; +import 'services/contacts_service.dart'; import 'screens/conversations_screen.dart'; import 'theme/app_theme.dart'; @@ -23,8 +24,12 @@ void main() async { )); // Register services before app starts + Get.put(ContactsService(), permanent: true); Get.put(WhatsAppService(), permanent: true); Get.put(FirebaseService(), permanent: true); + + // Initialize Contacts Service + await Get.find().init(); Get.find().init(); runApp(const WhatsAppApp()); diff --git a/whatsapp_app/lib/services/contacts_service.dart b/whatsapp_app/lib/services/contacts_service.dart new file mode 100644 index 0000000..197c641 --- /dev/null +++ b/whatsapp_app/lib/services/contacts_service.dart @@ -0,0 +1,85 @@ +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:get/get.dart'; + +class ContactInfo { + final String name; + final String? avatarPath; + ContactInfo({required this.name, this.avatarPath}); +} + +class ContactsService extends GetxService { + final RxMap _contactsMap = {}.obs; + final RxBool permissionGranted = false.obs; + + Future init() async { + await fetchContacts(); + return this; + } + + Future fetchContacts() async { + try { + // Check and request permission + bool permission = await FlutterContacts.requestPermission(readonly: true); + permissionGranted.value = permission; + + if (permission) { + // Fetch contacts with photos and phone numbers + final contacts = await FlutterContacts.getContacts(withProperties: true, withPhoto: true); + + final Map tempMap = {}; + for (var contact in contacts) { + final fullName = contact.displayName; + if (fullName.isEmpty) continue; + + for (var phone in contact.phones) { + final normalized = normalizePhoneNumber(phone.number); + if (normalized.isNotEmpty) { + tempMap[normalized] = ContactInfo( + name: fullName, + avatarPath: null, // Custom local avatar path can be handled if needed + ); + } + } + } + _contactsMap.assignAll(tempMap); + print('[CONTACTS] Successfully loaded ${_contactsMap.length} normalized phone contacts'); + } + } catch (e) { + print('[CONTACTS ERROR] Failed to fetch system contacts: $e'); + } + } + + // Normalizes numbers to match them easily (e.g., removes spaces, dashes, brackets, and leading zeros) + String normalizePhoneNumber(String number) { + String clean = number.replaceAll(RegExp(r'[\s\-\(\)\+]'), ''); + // If it starts with local country prefix or leading 0, we can do substring matches + if (clean.startsWith('00')) { + clean = clean.substring(2); + } + return clean; + } + + String getContactName(String rawNumber, String fallback) { + if (!permissionGranted.value || _contactsMap.isEmpty) return fallback; + + final clean = normalizePhoneNumber(rawNumber); + if (clean.isEmpty) return fallback; + + // Direct match + if (_contactsMap.containsKey(clean)) { + return _contactsMap[clean]!.name; + } + + // Partial match for varying country codes (match last 9 digits of the phone number) + if (clean.length >= 9) { + final suffix = clean.substring(clean.length - 9); + for (var key in _contactsMap.keys) { + if (key.endsWith(suffix)) { + return _contactsMap[key]!.name; + } + } + } + + return fallback; + } +} diff --git a/whatsapp_app/lib/services/whatsapp_service.dart b/whatsapp_app/lib/services/whatsapp_service.dart index f9091ed..a2e353a 100644 --- a/whatsapp_app/lib/services/whatsapp_service.dart +++ b/whatsapp_app/lib/services/whatsapp_service.dart @@ -215,6 +215,28 @@ class WhatsAppService extends GetxService { Future> sendFcmToken(String token) => _request({ 'type': 'register_fcm', 'token': token }); + Future> getMedia(String messageId) => + _request({ 'type': 'get_media', 'messageId': messageId }); + + // Cache downloaded media: messageId -> base64 + final RxMap mediaCache = {}.obs; + + Future downloadAndCacheMedia(String messageId) async { + if (mediaCache.containsKey(messageId)) return mediaCache[messageId]; + + try { + final res = await getMedia(messageId); + if (res['type'] == 'media' && res['data'] != null) { + final String base64Data = res['data']; + mediaCache[messageId] = base64Data; + return base64Data; + } + } catch (e) { + print('[MEDIA DOWNLOAD ERROR] Failed to download message media: $e'); + } + return null; + } + Future> ping() => _request({ 'type': 'ping' }); } diff --git a/whatsapp_app/lib/widgets/message_bubble.dart b/whatsapp_app/lib/widgets/message_bubble.dart index 223ae59..ab78c71 100644 --- a/whatsapp_app/lib/widgets/message_bubble.dart +++ b/whatsapp_app/lib/widgets/message_bubble.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:get/get.dart'; import '../models/message_model.dart'; import '../theme/app_theme.dart'; +import '../services/whatsapp_service.dart'; class MessageBubble extends StatelessWidget { final MessageModel message; @@ -58,9 +61,9 @@ class MessageBubble extends StatelessWidget { const SizedBox(height: 4), ], - // Media placeholder if it is media + // Interactive Media widget if message has media if (message.hasMedia) ...[ - _buildMediaPlaceholder(), + InteractiveMediaWidget(message: message), const SizedBox(height: 6), ], @@ -108,49 +111,6 @@ class MessageBubble extends StatelessWidget { ); } - Widget _buildMediaPlaceholder() { - IconData iconData = Icons.insert_drive_file; - String label = "File Attachment"; - - switch (message.type) { - case "image": - iconData = Icons.photo; - label = "Image"; - break; - case "video": - iconData = Icons.videocam; - label = "Video"; - break; - case "audio": - iconData = Icons.audiotrack; - label = "Audio File"; - break; - case "sticker": - iconData = Icons.emoji_emotions; - label = "Sticker"; - break; - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(iconData, color: AppTheme.textSecondary, size: 32), - const SizedBox(width: 12), - Text( - label, - style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - Widget _buildAckIcon(int ack) { switch (ack) { case 1: // Pending @@ -172,3 +132,184 @@ class MessageBubble extends StatelessWidget { return DateFormat('h:mm a').format(dt); } } + +class InteractiveMediaWidget extends StatefulWidget { + final MessageModel message; + + const InteractiveMediaWidget({super.key, required this.message}); + + @override + State createState() => _InteractiveMediaWidgetState(); +} + +class _InteractiveMediaWidgetState extends State { + final WhatsAppService _svc = Get.find(); + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + return Obx(() { + final cachedMedia = _svc.mediaCache[widget.message.id]; + + if (cachedMedia != null) { + return _buildDownloadedMedia(cachedMedia); + } + + if (_isLoading) { + return Container( + padding: const EdgeInsets.all(16), + width: 140, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary), + ), + ); + } + + // Tap to download media placeholder + return GestureDetector( + onTap: _downloadMedia, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getIcon(), color: AppTheme.textSecondary, size: 32), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getLabel(), + style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500, fontSize: 13), + ), + const SizedBox(height: 2), + const Text( + 'Tap to download', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 10), + ), + ], + ), + ], + ), + ), + ); + }); + } + + Future _downloadMedia() async { + setState(() => _isLoading = true); + await _svc.downloadAndCacheMedia(widget.message.id); + if (mounted) { + setState(() => _isLoading = false); + } + } + + Widget _buildDownloadedMedia(String base64Data) { + final bytes = base64Decode(base64Data); + + if (widget.message.type == "image" || widget.message.type == "sticker") { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + bytes, + fit: BoxFit.cover, + maxHeight: 250, + width: double.infinity, + ), + ); + } + + if (widget.message.type == "audio") { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.play_arrow, color: AppTheme.primary, size: 24), + onPressed: () { + Get.snackbar( + 'Audio Playback', + 'Playing voice note/audio file...', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: AppTheme.surfaceLight, + colorText: AppTheme.textPrimary, + ); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: LinearProgressIndicator( + value: 0.0, + backgroundColor: AppTheme.surfaceLight, + color: AppTheme.primary, + ), + ), + ), + const Text( + 'Voice Note', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 11), + ), + ], + ), + ); + } + + // Default download complete file placeholder + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle_outline, color: AppTheme.primary, size: 32), + const SizedBox(width: 12), + Text( + _getLabel(), + style: const TextStyle(color: AppTheme.textPrimary, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + IconData _getIcon() { + switch (widget.message.type) { + case "image": return Icons.photo_outlined; + case "video": return Icons.videocam_outlined; + case "audio": return Icons.audiotrack_outlined; + case "sticker": return Icons.emoji_emotions_outlined; + default: return Icons.insert_drive_file_outlined; + } + } + + String _getLabel() { + switch (widget.message.type) { + case "image": return "Image Attachment"; + case "video": return "Video Attachment"; + case "audio": return "Audio / Voice Note"; + case "sticker": return "Sticker Attachment"; + default: return "File Attachment"; + } + } +} diff --git a/whatsapp_app/pubspec.lock b/whatsapp_app/pubspec.lock index 3a6f901..4b47030 100644 --- a/whatsapp_app/pubspec.lock +++ b/whatsapp_app/pubspec.lock @@ -206,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_contacts: + dependency: "direct main" + description: + name: flutter_contacts + sha256: "388d32cd33f16640ee169570128c933b45f3259bddbfae7a100bb49e5ffea9ae" + url: "https://pub.dev" + source: hosted + version: "1.1.9+2" flutter_lints: dependency: "direct dev" description: diff --git a/whatsapp_app/pubspec.yaml b/whatsapp_app/pubspec.yaml index 56bab7d..bb0c84f 100644 --- a/whatsapp_app/pubspec.yaml +++ b/whatsapp_app/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: firebase_core: ^2.31.1 firebase_messaging: ^14.9.1 flutter_local_notifications: ^17.1.2 + flutter_contacts: ^1.1.7 dev_dependencies: flutter_test: diff --git a/whatsapp_bridge/server.js b/whatsapp_bridge/server.js index 4b7fe50..61c651f 100644 --- a/whatsapp_bridge/server.js +++ b/whatsapp_bridge/server.js @@ -59,18 +59,23 @@ function sendTo(ws, payload) { async function formatChat(chat) { let avatar = null; try { - // Ultra-fast memory-based avatar fetch with strict 300ms fallback to prevent hangs - avatar = await Promise.race([ - chat.client.pupPage.evaluate((chatId) => { - try { - const contact = window.Store.Contact.get(chatId); - return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null; - } catch (_) { - return null; - } - }, chat.id._serialized), - new Promise(resolve => setTimeout(() => resolve(null), 300)) - ]); + // 1. Try memory-based avatar lookup first (takes < 1ms) + avatar = await chat.client.pupPage.evaluate((chatId) => { + try { + const contact = window.Store.Contact.get(chatId); + return contact && contact.profilePicThumb ? (contact.profilePicThumb.imgFull || contact.profilePicThumb.img) : null; + } catch (_) { + return null; + } + }, chat.id._serialized); + + // 2. If memory has no avatar, fallback to strict-timeout network query (max 800ms) + if (!avatar) { + avatar = await Promise.race([ + chat.getProfilePicUrl().catch(() => null), + new Promise(resolve => setTimeout(() => resolve(null), 800)) + ]); + } } catch (_) {} // Last Message formatting @@ -355,6 +360,53 @@ async function handleMessage(ws, raw) { }); } + // ── Media ────────────────────────────────────────────────────────── + case 'get_media': { + if (!clientReady) { + return respond({ type: 'error', message: 'WhatsApp is not ready' }); + } + const { messageId } = payload; + if (!messageId) { + return respond({ type: 'error', message: 'messageId is required' }); + } + try { + // Extract chatId from messageId (format: true_447701407332@c.us_3EB0C8B196C5F354) + const parts = messageId.split('_'); + if (parts.length < 2) { + return respond({ type: 'error', message: 'Invalid messageId format' }); + } + const chatId = parts[1]; + const chat = await waClient.getChatById(chatId); + const messages = await chat.fetchMessages({ limit: 100 }); + const msg = messages.find(m => m.id._serialized === messageId); + + if (!msg) { + return respond({ type: 'error', message: 'Message not found in chat history' }); + } + if (!msg.hasMedia) { + return respond({ type: 'error', message: 'Message has no media attachments' }); + } + + console.log(`[WS] Downloading media for message: ${messageId}`); + const media = await msg.downloadMedia(); + if (!media) { + return respond({ type: 'error', message: 'Failed to download media file from WhatsApp servers' }); + } + + return respond({ + type: 'media', + messageId: messageId, + data: media.data, // base64 string + mimetype: media.mimetype, + filename: media.filename || 'file', + requestId + }); + } catch (err) { + console.error('[WS] get_media failed:', err.message); + return respond({ type: 'error', message: err.message || 'Failed to download media', requestId }); + } + } + // ── Send Message ─────────────────────────────────────────────────── case 'send_message': { if (!clientReady) {