diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 3cd06d9..b5832bb 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,8 +1,7 @@ plugins { id("com.android.application") + id("org.jetbrains.kotlin.android") id("dev.flutter.flutter-gradle-plugin") - // Restored because Flutter 3.13 still strictly requires it. - id("org.jetbrains.kotlin.android") } configurations.all { @@ -49,7 +48,6 @@ flutter { source = "../.." } -// Restored to support Flutter 3.13 Kotlin compilation tasks.withType().configureEach { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) @@ -67,8 +65,6 @@ androidComponents { val baseAbiCode = abiCodes[abi] if (baseAbiCode != null) { - // FIXED: We read flutter.versionCode directly instead of mapping output.versionCode to itself. - // This breaks the circular loop while still giving you the correct architecture suffix. val baseVersionCode = flutter.versionCode output.versionCode.set(baseVersionCode * 10 + baseAbiCode) } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 5beaaac..f05330a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index e96108c..d5da727 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template +# This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/mobile/android/settings.gradle.kts b/mobile/android/settings.gradle.kts index c21f0c5..ca7fe06 100644 --- a/mobile/android/settings.gradle.kts +++ b/mobile/android/settings.gradle.kts @@ -19,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/mobile/lib/controllers/auth_state.dart b/mobile/lib/controllers/auth_state.dart index 7bf2b26..15abf20 100644 --- a/mobile/lib/controllers/auth_state.dart +++ b/mobile/lib/controllers/auth_state.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mobile/services/db_services.dart'; import 'package:mobile/services/signal_service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:app_links/app_links.dart'; @@ -94,6 +96,8 @@ class AuthState extends ChangeNotifier { _token = await _authService.getToken(); if (_token != null) { + await SignalService().ensureIdentityInitialized(); + await SignalService().uploadPublicKeys(); await loadUserProfile(); if (_currentUser != null) { await SignalService().initializeAndUploadKeys(_currentUser!.id); @@ -133,7 +137,21 @@ class AuthState extends ChangeNotifier { await loadUserProfile(); if (_currentUser != null) { - await SignalService().initializeAndUploadKeys(_currentUser!.id); + final FlutterSecureStorage storage = const FlutterSecureStorage(); + final String? lastUserId = await storage.read(key: "last_user_id"); + + if (lastUserId != null && lastUserId != _currentUser!.id) { + final db = await DatabaseHelper.instance.database; + await db.delete('signal_local_keys'); + await db.delete('signal_identities'); + await db.delete('signal_sessions'); + await db.delete('signal_prekeys'); + await db.delete('signal_signed_prekeys'); + } + await storage.write(key: "last_user_id", value: _currentUser!.id); + + await SignalService().ensureIdentityInitialized(); + await SignalService().uploadPublicKeys(); } _isLoading = false; @@ -187,7 +205,8 @@ class AuthState extends ChangeNotifier { await loadUserProfile(); if (_currentUser != null) { - await SignalService().initializeAndUploadKeys(_currentUser!.id); + await SignalService().ensureIdentityInitialized(); + await SignalService().uploadPublicKeys(); } _isLoading = false; @@ -273,6 +292,8 @@ class AuthState extends ChangeNotifier { _token = null; _currentUser = null; await _authService.logout(); + final storage = const FlutterSecureStorage(); + await storage.delete(key: "last_user_id"); notifyListeners(); } @@ -281,6 +302,7 @@ class AuthState extends ChangeNotifier { _token = null; _currentUser = null; _authService.logout(); + const FlutterSecureStorage().delete(key: "last_user_id"); notifyListeners(); } } diff --git a/mobile/lib/controllers/chat/active_chat_controller.dart b/mobile/lib/controllers/chat/active_chat_controller.dart index f51757c..1016751 100644 --- a/mobile/lib/controllers/chat/active_chat_controller.dart +++ b/mobile/lib/controllers/chat/active_chat_controller.dart @@ -14,6 +14,9 @@ class ActiveChatController extends ChangeNotifier { final WebSocketService _ws = WebSocketService(); final Uuid _uuid = const Uuid(); + bool isLoadingMore = false; + bool hasMoreMessages = true; + int _currentRequestId = 0; List activeChat = []; String? currentChatUserId; bool isPeerTyping = false; @@ -26,12 +29,74 @@ class ActiveChatController extends ChangeNotifier { notifyListeners(); } + Future loadMoreMessages() async { + if (isLoadingMore || !hasMoreMessages || currentChatUserId == null || activeChat.isEmpty) return; + + isLoadingMore = true; + notifyListeners(); + + try { + final targetUid = currentChatUserId!; + final oldestMessageTime = activeChat.first.createdAt.toIso8601String(); + + final res = await _api.getChatHistory( + targetUid, + isGroup: isCurrentChatGroup, + before: oldestMessageTime + ); + + if (currentChatUserId != targetUid) return; + + final targetList = _extractDataList(res.data, ['messages']); + + if (targetList.isEmpty) { + hasMoreMessages = false; + return; + } + + List loadedOldMessages = []; + for (var json in targetList.reversed) { + Message parsedMsg = Message.fromJson(json); + Message decryptedMsg = await _decryptMessageIfNeeded(parsedMsg); + loadedOldMessages.add(decryptedMsg); + } + + activeChat = [...loadedOldMessages, ...activeChat]; + + final db = await DatabaseHelper.instance.database; + Batch batch = db.batch(); + for (var msg in loadedOldMessages) { + if (!msg.content.contains('ciphertext') && !msg.content.contains('🔒')) { + batch.insert('messages', { + 'id': msg.id, + 'chat_id': targetUid, + 'sender_id': msg.senderId, + 'content': msg.content, + 'created_at': msg.createdAt.millisecondsSinceEpoch, + 'is_read': msg.isRead ? 1 : 0, + 'reply_to_id': msg.replyToMessageId, + 'sync_status': 'synced', + }, conflictAlgorithm: ConflictAlgorithm.ignore); + } + } + await batch.commit(noResult: true); + + } catch (e) { + debugPrint("Failed to load older messages: $e"); + } finally { + isLoadingMore = false; + notifyListeners(); + } + } + Future openChat(String targetUid, {bool isGroup = false}) async { if (targetUid.isEmpty || targetUid == 'null') return; + final int requestId = ++_currentRequestId; if (currentChatUserId != targetUid) { activeChat.clear(); isChatHistoryLoading = true; + hasMoreMessages = true; chatOpenCount = 0; } @@ -82,7 +147,8 @@ class ActiveChatController extends ChangeNotifier { try { final res = await _api.getChatHistory(targetUid, isGroup: isGroup); - if (currentChatUserId != targetUid) return; + if (requestId != _currentRequestId || currentChatUserId != targetUid) + return; final targetList = _extractDataList(res.data, ['messages']); @@ -135,8 +201,9 @@ class ActiveChatController extends ChangeNotifier { final db = await DatabaseHelper.instance.database; Batch batch = db.batch(); for (var msg in loadedMessages) { - if (msg.content.contains('ciphertext') || msg.content.contains('🔒')) { - continue; + if (msg.content.contains('ciphertext') || + msg.content.contains('🔒')) { + continue; } batch.insert('messages', { @@ -253,8 +320,9 @@ class ActiveChatController extends ChangeNotifier { final db = await DatabaseHelper.instance.database; Batch batch = db.batch(); for (var msg in loadedMessages) { - if (msg.content.contains('ciphertext') || msg.content.contains('🔒')) { - continue; + if (msg.content.contains('ciphertext') || + msg.content.contains('🔒')) { + continue; } batch.insert('messages', { @@ -307,7 +375,9 @@ class ActiveChatController extends ChangeNotifier { final targetId = currentChatUserId!; if (!isCurrentChatGroup) { - final sessionReady = await SignalService().establishSessionIfNeeded(targetId); + final sessionReady = await SignalService().establishSessionIfNeeded( + targetId, + ); if (!sessionReady) { debugPrint("Send aborted: Target user has no E2EE keys on server."); return; @@ -355,10 +425,7 @@ class ActiveChatController extends ChangeNotifier { String securePayload; if (isCurrentChatGroup) { - securePayload = jsonEncode({ - 'type': 0, - 'ciphertext': cleanContent, - }); + securePayload = jsonEncode({'type': 0, 'ciphertext': cleanContent}); } else { final encryptedData = await SignalService().encryptMessage( targetId, @@ -506,13 +573,15 @@ class ActiveChatController extends ChangeNotifier { void addRealTimeMessage(Message incomingMsg) async { if (currentChatUserId == null) return; - Message newMsg = await _decryptMessageIfNeeded(incomingMsg); - bool belongsToCurrentChat = - (isCurrentChatGroup && newMsg.receiverId == currentChatUserId) || + (isCurrentChatGroup && incomingMsg.receiverId == currentChatUserId) || (!isCurrentChatGroup && - (newMsg.senderId == currentChatUserId || - newMsg.receiverId == currentChatUserId)); + (incomingMsg.senderId == currentChatUserId || + incomingMsg.receiverId == currentChatUserId)); + + if (!belongsToCurrentChat) return; + + Message newMsg = await _decryptMessageIfNeeded(incomingMsg); if (belongsToCurrentChat) { final existingIndex = activeChat.indexWhere((m) => m.id == newMsg.id); @@ -552,27 +621,30 @@ class ActiveChatController extends ChangeNotifier { final content = msg.content.trim(); if (content.startsWith('{') && content.contains('ciphertext')) { - - bool isSelfChat = msg.senderId == msg.receiverId; - bool isSentByMe = isSelfChat || (!isCurrentChatGroup && msg.senderId != currentChatUserId) || msg.senderId == 'me'; + try { + final db = await DatabaseHelper.instance.database; - if (isSentByMe) { - try { - final db = await DatabaseHelper.instance.database; - - final exactMatch = await db.query( - 'messages', - where: 'id = ?', - whereArgs: [msg.id], - ); + final exactMatch = await db.query( + 'messages', + where: 'id = ?', + whereArgs: [msg.id], + ); - if (exactMatch.isNotEmpty) { - final exactContent = exactMatch.first['content'].toString(); - if (!exactContent.contains('ciphertext') && !exactContent.contains('🔒')) { - return msg.copyWith(content: exactContent); - } - } + if (exactMatch.isNotEmpty) { + final exactContent = exactMatch.first['content'].toString(); + if (!exactContent.contains('ciphertext') && + !exactContent.contains('🔒')) { + return msg.copyWith(content: exactContent); + } + } + bool isSelfChat = msg.senderId == msg.receiverId; + bool isSentByMe = + isSelfChat || + (!isCurrentChatGroup && msg.senderId != currentChatUserId) || + msg.senderId == 'me'; + + if (isSentByMe) { final targetChatId = currentChatUserId ?? msg.receiverId; final fallbackRows = await db.query( 'messages', @@ -588,12 +660,16 @@ class ActiveChatController extends ChangeNotifier { if (rSender != 'me' && rSender != msg.senderId) continue; final rowContent = row['content'].toString(); - if (rowContent.contains('ciphertext') || rowContent.contains('🔒')) continue; + if (rowContent.contains('ciphertext') || + rowContent.contains('🔒')) { + continue; + } final localTime = row['created_at'] as int; - final diff = (localTime - msg.createdAt.millisecondsSinceEpoch).abs(); + final diff = (localTime - msg.createdAt.millisecondsSinceEpoch) + .abs(); - if (minDiff == -1 || diff < minDiff) { + if (diff < 5000 && (minDiff == -1 || diff < minDiff)) { minDiff = diff; closestPlaintext = rowContent; } @@ -601,12 +677,12 @@ class ActiveChatController extends ChangeNotifier { if (closestPlaintext != null) { return msg.copyWith(content: closestPlaintext); - } - } catch (e) { - debugPrint("Local sent-message lookup crashed: $e"); + } + + return msg.copyWith(content: "🔒 [Sent from another device]"); } - - return msg.copyWith(content: "🔒 [Sent from another device]"); + } catch (e) { + debugPrint("Local sent-message lookup crashed: $e"); } try { @@ -628,7 +704,8 @@ class ActiveChatController extends ChangeNotifier { if (errStr.contains('DuplicateMessageException')) { return msg.copyWith(content: "🔒 [Message already decrypted]"); - } else if (errStr.contains('NoSessionException') || errStr.contains('Bad Mac')) { + } else if (errStr.contains('NoSessionException') || + errStr.contains('Bad Mac')) { return msg.copyWith(content: "🔒 [Encrypted for past session]"); } @@ -637,4 +714,4 @@ class ActiveChatController extends ChangeNotifier { } return msg; } -} \ No newline at end of file +} diff --git a/mobile/lib/controllers/chat/chat_connection_controller.dart b/mobile/lib/controllers/chat/chat_connection_controller.dart index 44a58e4..3325682 100644 --- a/mobile/lib/controllers/chat/chat_connection_controller.dart +++ b/mobile/lib/controllers/chat/chat_connection_controller.dart @@ -34,8 +34,7 @@ class ChatConnectionController extends ChangeNotifier if (!isOffline) { connectWebSocket(); - eventHandler.inboxController - .loadInbox(); + eventHandler.inboxController.loadInbox(); } else { eventHandler.activeChatController.isPeerOnline = false; eventHandler.activeChatController.isPeerTyping = false; @@ -69,7 +68,10 @@ class ChatConnectionController extends ChangeNotifier return; } - _syncService.processOfflineQueue(eventHandler.currentUserId); + _syncService.processOfflineQueue( + eventHandler.currentUserId, + activeChatController: eventHandler.activeChatController, + ); final currentChatId = eventHandler.activeChatController.currentChatUserId; if (currentChatId != null && @@ -104,6 +106,12 @@ class ChatConnectionController extends ChangeNotifier } finally { _isWsConnecting = false; } + _ws.onConnectionLost = () { + debugPrint( + "ConnectionController: Caught immediate socket loss from watchdog.", + ); + _triggerReconnectLoop(); + }; } void _triggerReconnectLoop() { @@ -131,12 +139,18 @@ class ChatConnectionController extends ChangeNotifier @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - connectWebSocket(); _syncService.processOfflineQueue( eventHandler.currentUserId, + activeChatController: eventHandler.activeChatController, ); - eventHandler.inboxController - .loadInbox(); + + connectWebSocket(); + + Future.delayed(const Duration(milliseconds: 500), () { + if (!isOffline) { + eventHandler.inboxController.loadInbox(); + } + }); } else if (state == AppLifecycleState.paused) { _reconnectTimer?.cancel(); _ws.disconnect(); diff --git a/mobile/lib/controllers/chat/inbox_controller.dart b/mobile/lib/controllers/chat/inbox_controller.dart index bd8ec79..eb6e0d4 100644 --- a/mobile/lib/controllers/chat/inbox_controller.dart +++ b/mobile/lib/controllers/chat/inbox_controller.dart @@ -39,6 +39,28 @@ class InboxController extends ChangeNotifier { final response = await _api.getConversations(); final rawData = _parseResponse(response.data, ['conversations']); final db = await DatabaseHelper.instance.database; + final allChatIds = rawData.map((j) { + return j['is_group'] == true || j['type'] == 'group' + ? j['id'] + : (j['chat_user_id'] ?? j['user_id'] ?? j['id'] ?? j['partner_id']); + }).where((id) => id != null).toList(); + + Map localDecryptedMsgs = {}; + if (allChatIds.isNotEmpty) { + final placeholders = List.filled(allChatIds.length, '?').join(','); + final localMsgs = await db.rawQuery(''' + SELECT chat_id, content + FROM messages + WHERE chat_id IN ($placeholders) + AND content NOT LIKE '%ciphertext%' + AND content NOT LIKE '%🔒%' + GROUP BY chat_id HAVING MAX(created_at) + ''', allChatIds); + + for (var row in localMsgs) { + localDecryptedMsgs[row['chat_id'].toString()] = row['content'].toString(); + } + } List combinedInbox = []; for (var json in rawData) { @@ -55,17 +77,8 @@ class InboxController extends ChangeNotifier { if (item.lastMessage.contains('ciphertext') || item.lastMessage.contains('🔒')) { - final localMsg = await db.query( - 'messages', - where: - 'chat_id = ? AND content NOT LIKE ? AND content NOT LIKE ?', - whereArgs: [item.id, '%ciphertext%', '%🔒%'], - orderBy: 'created_at DESC', - limit: 1, - ); - - if (localMsg.isNotEmpty) { - item.lastMessage = localMsg.first['content'].toString(); + if (localDecryptedMsgs.containsKey(item.id)) { + item.lastMessage = localDecryptedMsgs[item.id]!; } else { item.lastMessage = "🔒 Encrypted Message"; } diff --git a/mobile/lib/pages/chat/chat_page.dart b/mobile/lib/pages/chat/chat_page.dart index 5b89f9c..c9e679a 100644 --- a/mobile/lib/pages/chat/chat_page.dart +++ b/mobile/lib/pages/chat/chat_page.dart @@ -63,8 +63,8 @@ class _ChatPageState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); _itemPositionsListener.itemPositions.addListener(_scrollListener); - _chatController = context.read(); + _chatController.addListener(_onChatStateChanged); _authState = context.read(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -83,9 +83,24 @@ class _ChatPageState extends State with WidgetsBindingObserver { }); } + void _onChatStateChanged() { + final currentCount = _chatController.activeChat.length; + if (currentCount > _previousMessageCount) { + _previousMessageCount = currentCount; + if (!_showScrollToBottom && _itemScrollController.isAttached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _itemScrollController.jumpTo(index: 0); + }); + } + } else { + _previousMessageCount = currentCount; + } + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _chatController.removeListener(_onChatStateChanged); _itemPositionsListener.itemPositions.removeListener(_scrollListener); @@ -113,17 +128,15 @@ class _ChatPageState extends State with WidgetsBindingObserver { } } - void _handleTypingChange(String text) { - if (text.isNotEmpty && !_isCurrentlyTyping) { - _isCurrentlyTyping = true; - context.read().sendTypingNotification(true); - } else if (text.isEmpty && _isCurrentlyTyping) { - _isCurrentlyTyping = false; - context.read().sendTypingNotification(false); + void _handleTypingChange(bool isTyping) { + if (isTyping != _isCurrentlyTyping) { + _isCurrentlyTyping = isTyping; + context.read().sendTypingNotification(isTyping); } _typingDebounce?.cancel(); - if (text.isNotEmpty) { + + if (isTyping) { _typingDebounce = Timer(const Duration(seconds: 2), () { if (mounted && _isCurrentlyTyping) { _isCurrentlyTyping = false; @@ -469,23 +482,10 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), body: Stack( children: [ - Builder( - builder: (context) { + Consumer( + builder: (context, chatController, child) { final activeChat = chatState.activeChat; - if (activeChat.length > _previousMessageCount) { - _previousMessageCount = activeChat.length; - - if (!_showScrollToBottom && - _itemScrollController.isAttached) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _itemScrollController.jumpTo(index: 0); - }); - } - } else { - _previousMessageCount = activeChat.length; - } - return AnimatedSwitcher( duration: const Duration(milliseconds: 400), switchInCurve: Curves.easeOutCubic, @@ -963,34 +963,7 @@ class _ChatPageState extends State with WidgetsBindingObserver { ), ChatInputArea( onSendMessage: _sendMessage, - onTypingChanged: (isTyping) { - if (isTyping && !_isCurrentlyTyping) { - _isCurrentlyTyping = true; - context - .read() - .sendTypingNotification(true); - } - - _typingDebounce?.cancel(); - if (isTyping) { - _typingDebounce = Timer( - const Duration(seconds: 2), - () { - if (mounted && _isCurrentlyTyping) { - _isCurrentlyTyping = false; - context - .read() - .sendTypingNotification(false); - } - }, - ); - } else if (!isTyping && _isCurrentlyTyping) { - _isCurrentlyTyping = false; - context - .read() - .sendTypingNotification(false); - } - }, + onTypingChanged: _handleTypingChange, ), ], ), diff --git a/mobile/lib/pages/home/home_page.dart b/mobile/lib/pages/home/home_page.dart index 0aa86f6..4f3f3dd 100644 --- a/mobile/lib/pages/home/home_page.dart +++ b/mobile/lib/pages/home/home_page.dart @@ -81,6 +81,7 @@ class _HomePageState extends State { @override void dispose() { + _searchDebounce?.cancel(); _searchController.dispose(); _scrollController.dispose(); super.dispose(); diff --git a/mobile/lib/pages/settings/help_about.dart b/mobile/lib/pages/settings/help_about.dart index 2fb3301..1c0071c 100644 --- a/mobile/lib/pages/settings/help_about.dart +++ b/mobile/lib/pages/settings/help_about.dart @@ -1,10 +1,279 @@ import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; class HelpAboutPage extends StatelessWidget { const HelpAboutPage({super.key}); + final String githubUrl = "https://github.com/commandlinecoding/elephant"; + + Future _launchGitHub() async { + final Uri url = Uri.parse(githubUrl); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + debugPrint('Could not launch $url'); + } + } + @override Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text('Help & About'))); + final theme = Theme.of(context); + + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + appBar: AppBar( + title: const Text('Help & About'), + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + ), + body: ListView( + padding: const EdgeInsets.all(16.0), + physics: const BouncingScrollPhysics(), + children: [ + // --- HEADER SECTION --- + const SizedBox(height: 20), + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock_person_rounded, + size: 64, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Elephant', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + letterSpacing: 1.2, + ), + ), + ), + Center( + child: Text( + 'Version 0.3.0 - Secure Messaging', + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 32), + + // --- ABOUT & PRIVACY SECTION --- + _buildSectionHeader(context, 'Privacy & Security', Icons.shield), + Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.4, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFaqItem( + context, + 'Are my messages private?', + 'Yes. 1-on-1 chats use the Signal Protocol for true End-to-End Encryption (E2EE). Your private keys never leave your device, meaning the server cannot read your messages.', + ), + const Divider(), + _buildFaqItem( + context, + 'Why do I see "Sent from another device"?', + 'Because of forward secrecy, messages are locked cryptographically. If you uninstall the app or clear your local database, you lose the local keys needed to unlock old messages, ensuring past conversations remain secure even if your device is compromised.', + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // --- KNOWN BUGS SECTION --- + _buildSectionHeader( + context, + 'Known Bugs & V0.3.0 Limitations', + Icons.bug_report, + ), + Card( + elevation: 0, + color: theme.colorScheme.errorContainer.withValues(alpha: 0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBugItem( + context, + 'Group Chats are not fully E2EE yet', + 'Currently, 1-on-1 chats are fully encrypted using LibSignal. However, Group Chats currently use transport-level encryption (WSS/TLS) and temporary plaintext payloads. Full Signal "Sender Key" encryption for groups is planned for V2.', + ), + const SizedBox(height: 12), + _buildBugItem( + context, + 'Empty Group Scroll Glitch', + 'Opening a completely empty group chat for the first time may cause a minor visual glitch (RangeError) in the UI until the first message is sent.', + ), + const SizedBox(height: 12), + _buildBugItem( + context, + 'Database Sync Overwrites', + 'While heavy guards are in place, forcing app closes during heavy network syncs might occasionally drop offline read-receipts.', + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // --- GITHUB & SUPPORT SECTION --- + _buildSectionHeader(context, 'Support & Contribution', Icons.code), + Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.4, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: Icon( + Icons.open_in_new, + color: theme.colorScheme.primary, + ), + title: const Text('Report an Issue on GitHub'), + subtitle: const Text( + 'Found a bug? Help us improve Elephant by opening an issue on our repository.', + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: _launchGitHub, + ), + ), + const SizedBox(height: 40), + + Center( + child: Text( + 'Made with 🩵 by CommandLineCoding', + style: TextStyle( + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.7, + ), + fontSize: 12, + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, + String title, + IconData icon, + ) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Row( + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ); + } + + Widget _buildFaqItem(BuildContext context, String question, String answer) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + question, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + Text( + answer, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ); + } + + Widget _buildBugItem(BuildContext context, String title, String description) { + final theme = Theme.of(context); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Icon(Icons.circle, size: 8, color: theme.colorScheme.error), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + height: 1.4, + ), + ), + ], + ), + ), + ], + ); } } diff --git a/mobile/lib/services/api_services.dart b/mobile/lib/services/api_services.dart index d2860ab..85b5fef 100644 --- a/mobile/lib/services/api_services.dart +++ b/mobile/lib/services/api_services.dart @@ -24,7 +24,11 @@ class ApiService { return handler.next(response); }, onError: (DioException e, handler) async { - if (e.response?.statusCode == 401) { + final isRefreshEndpoint = e.requestOptions.path.contains( + "/auth/refresh", + ); + + if (e.response?.statusCode == 401 && !isRefreshEndpoint) { try { _refreshFuture ??= _refreshToken(); await _refreshFuture; @@ -42,6 +46,11 @@ class ApiService { _refreshFuture = null; } } + + if (e.response?.statusCode == 401 && isRefreshEndpoint) { + AuthState.onGlobalUnauthorized?.call(); + } + return handler.next(e); }, ), diff --git a/mobile/lib/services/chat/chat_event_handler.dart b/mobile/lib/services/chat/chat_event_handler.dart index 31b567e..1f2af5b 100644 --- a/mobile/lib/services/chat/chat_event_handler.dart +++ b/mobile/lib/services/chat/chat_event_handler.dart @@ -26,25 +26,18 @@ class ChatEventHandler { if (type == null) return; final String? cleanCurrentChat = activeChatController.currentChatUserId - ?.trim() - .toLowerCase(); + ?.trim(); bool isCurrentChat = false; if (cleanCurrentChat != null) { if (activeChatController.isCurrentChatGroup) { - final String? eventGroupId = data['group_id'] - ?.toString() - .trim() - .toLowerCase(); + final String? eventGroupId = data['group_id']?.toString().trim(); isCurrentChat = (eventGroupId == cleanCurrentChat); } else { final String? eventSenderId = - data['sender_id']?.toString().trim().toLowerCase() ?? - data['sender']?.toString().trim().toLowerCase(); - final String? eventReceiverId = data['receiver_id'] - ?.toString() - .trim() - .toLowerCase(); + data['sender_id']?.toString().trim() ?? + data['sender']?.toString().trim(); + final String? eventReceiverId = data['receiver_id']?.toString().trim(); isCurrentChat = (eventSenderId == cleanCurrentChat || @@ -53,13 +46,18 @@ class ChatEventHandler { } switch (type) { + case 'pong': + _ws.registerPong(); + break; case 'user_status': case 'status': final String? eventUserId = (data['user_id'] ?? data['id']) ?.toString() .trim() .toLowerCase(); - if (eventUserId == cleanCurrentChat && + final String? safeCurrentChat = cleanCurrentChat?.toLowerCase(); + + if (eventUserId == safeCurrentChat && !activeChatController.isCurrentChatGroup) { activeChatController.isPeerOnline = data['online'] == true || data['content'] == 'online'; @@ -79,13 +77,11 @@ class ChatEventHandler { final String cleanSenderId = (data['sender_id'] ?? data['sender'] ?? '') .toString() - .trim() - .toLowerCase(); + .trim(); final String cleanReceiverId = (data['receiver_id'] ?? '') .toString() - .trim() - .toLowerCase(); - final String myId = currentUserId.trim().toLowerCase(); + .trim(); + final String myId = currentUserId.trim(); final bool isMe = (cleanSenderId == 'me' || cleanSenderId == myId); final String dbChatId = data['group_id'] != null @@ -103,8 +99,7 @@ class ChatEventHandler { payload['ciphertext'], payload['type'], ); - data['content'] = - decryptedText; + data['content'] = decryptedText; } } catch (e) { debugPrint( @@ -265,17 +260,11 @@ class ChatEventHandler { break; case 'read_receipt': - final String payloadSender = (data['sender_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadReceiver = (data['receiver_id'] ?? '') - .toString() - .toLowerCase(); - final String payloadGroup = (data['group_id'] ?? '') - .toString() - .toLowerCase(); - final String safeChatId = (activeChatController.currentChatUserId ?? '') - .toLowerCase(); + final String payloadSender = (data['sender_id'] ?? '').toString(); + final String payloadReceiver = (data['receiver_id'] ?? '').toString(); + final String payloadGroup = (data['group_id'] ?? '').toString(); + final String safeChatId = + (activeChatController.currentChatUserId ?? ''); bool isRelevantToThisChat = false; String dbTargetChatId = ""; @@ -311,7 +300,7 @@ class ChatEventHandler { activeChatController.activeChat = activeChatController.activeChat.map( (msg) { - final String msgSenderId = msg.senderId.trim().toLowerCase(); + final String msgSenderId = msg.senderId.trim(); if (!msg.isRead && (msgSenderId == 'me' || msgSenderId != safeChatId)) { updated = true; diff --git a/mobile/lib/services/chat/chat_sync_service.dart b/mobile/lib/services/chat/chat_sync_service.dart index 08c26a4..51db6f2 100644 --- a/mobile/lib/services/chat/chat_sync_service.dart +++ b/mobile/lib/services/chat/chat_sync_service.dart @@ -1,56 +1,85 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import '../../controllers/chat/active_chat_controller.dart'; import '../../services/ws_service.dart'; import '../db_services.dart'; class ChatSyncService { final WebSocketService _ws = WebSocketService(); + bool _isSyncing = false; - Future processOfflineQueue(String currentUserId) async { - final db = await DatabaseHelper.instance.database; - - final pendingActions = await db.query( - 'action_queue', - orderBy: 'created_at ASC', - ); - - if (pendingActions.isEmpty) return; - - debugPrint("Processing ${pendingActions.length} queued actions..."); - - for (var action in pendingActions) { - final actionId = action['id'] as String; - final type = action['action_type'] as String; - final payload = jsonDecode(action['payload'] as String); - - try { - if (type == 'send_chat' || type == 'send_group_chat') { - if (_ws.isConnected) { - if (type == 'send_group_chat') { - _ws.sendGroupChat( - messageId: payload['messageId'], - groupId: payload['groupId'], - content: payload['content'], - // senderId: currentUserId.isNotEmpty ? currentUserId : 'me', - replyToMessageId: payload['replyToMessageId'], - ); - } else { - _ws.sendChat( - messageId: payload['messageId'], - receiverId: payload['receiverId'], - content: payload['content'], - replyToMessageId: payload['replyToMessageId'], - ); - } + Future processOfflineQueue( + String currentUserId, { + ActiveChatController? activeChatController, + }) async { + if (_isSyncing || !_ws.isConnected) return; + _isSyncing = true; + + try { + final db = await DatabaseHelper.instance.database; + + final pendingActions = await db.query( + 'action_queue', + where: 'retry_count < ?', + whereArgs: [5], + orderBy: 'created_at ASC', + ); + + if (pendingActions.isEmpty) return; + + debugPrint("Processing ${pendingActions.length} queued offline actions..."); + + for (var action in pendingActions) { + if (!_ws.isConnected) break; + + final actionId = action['id'] as String; + final type = action['action_type'] as String; + final payload = jsonDecode(action['payload'] as String); + + try { + if (type == 'send_chat') { + _ws.sendChat( + messageId: payload['messageId'], + receiverId: payload['receiverId'], + content: payload['content'], + replyToMessageId: payload['replyToMessageId'], + ); + } else if (type == 'send_group_chat') { + _ws.sendGroupChat( + messageId: payload['messageId'], + groupId: payload['groupId'], + content: payload['content'], + replyToMessageId: payload['replyToMessageId'], + ); } + + await Future.delayed(const Duration(milliseconds: 50)); + + await db.update( + 'messages', + {'sync_status': 'synced'}, + where: 'id = ?', + whereArgs: [actionId], + ); + + await db.delete( + 'action_queue', + where: 'id = ?', + whereArgs: [actionId], + ); + + activeChatController?.markMessageAsSynced(actionId); + + } catch (e) { + debugPrint("Failed to flush queue action $actionId: $e"); + await db.rawUpdate( + 'UPDATE action_queue SET retry_count = retry_count + 1 WHERE id = ?', + [actionId], + ); } - } catch (e) { - debugPrint("Failed to process queue action $actionId: $e"); - await db.rawUpdate( - 'UPDATE action_queue SET retry_count = retry_count + 1 WHERE id = ?', - [actionId], - ); } + } finally { + _isSyncing = false; } } -} +} \ No newline at end of file diff --git a/mobile/lib/services/db_services.dart b/mobile/lib/services/db_services.dart index fabd74e..20ad8a7 100644 --- a/mobile/lib/services/db_services.dart +++ b/mobile/lib/services/db_services.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/services.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path/path.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; @@ -7,24 +9,47 @@ class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + + static const String _dbName = 'secure_chat.db'; DatabaseHelper._init(); Future get database async { if (_database != null) return _database!; - _database = await _initDB('secure_chat.db'); + _database = await _initDB(_dbName); return _database!; } Future _getEncryptionKey() async { const keyName = 'db_encryption_key'; - String? key = await _secureStorage.read(key: keyName); + String? key; + + try { + key = await _secureStorage.read(key: keyName); + } on PlatformException catch (e) { + if (e.message?.contains('BAD_DECRYPT') == true || e.code == 'Exception encountered') { + print('CRITICAL: Keystore corrupted. Wiping secure storage and resetting DB.'); + + await _secureStorage.deleteAll(); + + final dbPath = join(await getDatabasesPath(), _dbName); + await deleteDatabase(dbPath); + + key = null; + } else { + rethrow; + } + } if (key == null) { - final secureKey = base64Url.encode(List.generate(32, (i) => i + 1)); + final random = Random.secure(); + final secureBytes = List.generate(32, (_) => random.nextInt(256)); + final secureKey = base64Url.encode(secureBytes); + await _secureStorage.write(key: keyName, value: secureKey); key = secureKey; } + return key; } @@ -55,6 +80,13 @@ class DatabaseHelper { ) '''); + await db.execute(''' + CREATE TABLE signal_sender_keys ( + sender_key_name TEXT PRIMARY KEY, + record TEXT NOT NULL + ) + '''); + await db.execute(''' CREATE TABLE action_queue ( id TEXT PRIMARY KEY, @@ -169,4 +201,4 @@ class DatabaseHelper { 'created_at': DateTime.now().millisecondsSinceEpoch, }); } -} +} \ No newline at end of file diff --git a/mobile/lib/services/signal_service.dart b/mobile/lib/services/signal_service.dart index e29f39f..dceb802 100644 --- a/mobile/lib/services/signal_service.dart +++ b/mobile/lib/services/signal_service.dart @@ -56,22 +56,24 @@ class SignalService { final publicPreKeys = preKeys .map( (k) => { - 'id': k.id, - 'content': base64Encode(k.getKeyPair().publicKey.serialize()), + 'id': k.id, + 'content': base64Encode(k.getKeyPair().publicKey.serialize()), }, ) .toList(); final payload = { - 'device_id': 'main', + 'device_id': 'main', 'identity_key': base64Encode(identityKeyPair.getPublicKey().serialize()), - 'signed_prekey': base64Encode(signedPreKey.getKeyPair().publicKey.serialize()), + 'signed_prekey': base64Encode( + signedPreKey.getKeyPair().publicKey.serialize(), + ), 'signature': base64Encode(signedPreKey.signature), 'one_time_prekeys': publicPreKeys, }; try { - await _api.post("/e2ee/keys", data: payload); + await _api.post("/e2ee/keys", data: payload); debugPrint("E2EE Keys uploaded successfully"); } catch (e) { debugPrint("Failed to upload E2EE keys: $e"); @@ -82,10 +84,12 @@ class SignalService { await initStore(); final address = _getAddress(remoteUserId); - if (await _store.containsSession(address)) return true; + if (await _store.containsSession(address)) return true; try { - final response = await _api.get("/e2ee/bundle/$remoteUserId?device_id=main"); + final response = await _api.get( + "/e2ee/bundle/$remoteUserId?device_id=main", + ); final data = response.data['data'] ?? response.data; final identityKeyStr = data['identity_key']; @@ -94,38 +98,50 @@ class SignalService { final otpBodyStr = data['one_time_prekey_body']; final otpId = data['one_time_prekey_id']; - if (identityKeyStr == null || signedPreKeyStr == null || signatureStr == null || otpBodyStr == null) { + if (identityKeyStr == null || + signedPreKeyStr == null || + signatureStr == null || + otpBodyStr == null) { debugPrint("Receiver bundle is missing required cryptographic keys."); return false; } - final identityKey = IdentityKey(Curve.decodePoint(base64Decode(identityKeyStr), 0)); + final identityKey = IdentityKey( + Curve.decodePoint(base64Decode(identityKeyStr), 0), + ); final signedPreKey = Curve.decodePoint(base64Decode(signedPreKeyStr), 0); final signature = base64Decode(signatureStr); final preKey = Curve.decodePoint(base64Decode(otpBodyStr), 0); final bundle = PreKeyBundle( - 0, - 1, - (otpId as int?) ?? 1, - preKey, - 0, - signedPreKey, - signature, - identityKey, + 0, + 1, + (otpId as int?) ?? 1, + preKey, + 0, + signedPreKey, + signature, + identityKey, + ); + + final sessionBuilder = SessionBuilder( + _store, + _store, + _store, + _store, + address, ); - final sessionBuilder = SessionBuilder(_store, _store, _store, _store, address); - await sessionBuilder.processPreKeyBundle(bundle); debugPrint("E2EE Session established perfectly with $remoteUserId!"); return true; - } on DioException catch (e) { if (e.response?.statusCode == 404) { debugPrint("Receiver $remoteUserId has not registered E2EE keys yet."); } else { - debugPrint("Network error fetching bundle for $remoteUserId: ${e.message}"); + debugPrint( + "Network error fetching bundle for $remoteUserId: ${e.message}", + ); } return false; } catch (e) { @@ -133,7 +149,7 @@ class SignalService { return false; } } - + Future> encryptMessage( String remoteUserId, String plaintext, @@ -151,7 +167,9 @@ class SignalService { final plaintextBytes = Uint8List.fromList(utf8.encode(plaintext)); final ciphertextMessage = await sessionCipher.encrypt(plaintextBytes); - debugPrint("SignalService: Encryption successful (Type ${ciphertextMessage.getType()})."); + debugPrint( + "SignalService: Encryption successful (Type ${ciphertextMessage.getType()}).", + ); return { 'type': ciphertextMessage.getType(), 'ciphertext': base64Encode(ciphertextMessage.serialize()), @@ -220,4 +238,141 @@ class SignalService { final db = await DatabaseHelper.instance.database; await db.delete('signal_sessions'); } -} \ No newline at end of file + + Future ensureIdentityInitialized() async { + await initStore(); + final db = await DatabaseHelper.instance.database; + final existingKeys = await db.query('signal_local_keys', where: 'id = 1'); + + if (existingKeys.isNotEmpty) { + debugPrint("E2EE: Keys already exist. Skipping generation."); + return; + } + + debugPrint("E2EE: Generating new local identity keys..."); + final identityKeyPair = generateIdentityKeyPair(); + final registrationId = generateRegistrationId(false); + await _store.storeLocalData(identityKeyPair, registrationId); + + final preKeys = generatePreKeys(0, 100); + final signedPreKey = generateSignedPreKey(identityKeyPair, 0); + + for (var preKey in preKeys) { + await _store.storePreKey(preKey.id, preKey); + } + await _store.storeSignedPreKey(signedPreKey.id, signedPreKey); + } + + Future uploadPublicKeys() async { + await initStore(); + + try { + final identityKeyPair = await _store.getIdentityKeyPair(); + + final signedPreKey = await _store.loadSignedPreKey(0); + + final db = await DatabaseHelper.instance.database; + final preKeyRows = await db.query('signal_prekeys'); + + final publicPreKeys = preKeyRows.map((row) { + final preKeyId = row['key_id'] as int; + final recordBytes = base64Decode(row['record'] as String); + final preKeyRecord = PreKeyRecord.fromBuffer(recordBytes); + + return { + 'id': preKeyId, + 'content': base64Encode( + preKeyRecord.getKeyPair().publicKey.serialize(), + ), + }; + }).toList(); + + final payload = { + 'device_id': 'main', + 'identity_key': base64Encode( + identityKeyPair.getPublicKey().serialize(), + ), + 'signed_prekey': base64Encode( + signedPreKey.getKeyPair().publicKey.serialize(), + ), + 'signature': base64Encode(signedPreKey.signature), + 'one_time_prekeys': publicPreKeys, + }; + + await _api.post("/e2ee/keys", data: payload); + debugPrint("E2EE: Keys successfully synced to server for this session."); + } catch (e) { + debugPrint("Failed to sync E2EE keys: $e"); + } + } + + Future> encryptGroupMessage( + String groupId, + String currentUserId, + String plaintext, + ) async { + await initStore(); + final senderKeyName = SenderKeyName(groupId, _getAddress(currentUserId)); + + final record = await _store.loadSenderKey(senderKeyName); + bool needsDistribution = false; + + String? distMessageBase64; + + if (record.isEmpty) { + final builder = GroupSessionBuilder(_store); + + final distMessage = await builder.create(senderKeyName); + + needsDistribution = true; + distMessageBase64 = base64Encode(distMessage.serialize()); + } + + final groupCipher = GroupCipher(_store, senderKeyName); + final plaintextBytes = Uint8List.fromList(utf8.encode(plaintext)); + final ciphertextBytes = await groupCipher.encrypt(plaintextBytes); + + return { + 'type': 3, + 'ciphertext': base64Encode(ciphertextBytes), + 'needs_distribution': needsDistribution, + 'distribution_message': distMessageBase64, + }; + } + + Future decryptGroupMessage( + String groupId, + String senderUserId, + String base64Ciphertext, + ) async { + await initStore(); + final senderKeyName = SenderKeyName(groupId, _getAddress(senderUserId)); + final groupCipher = GroupCipher(_store, senderKeyName); + + final ciphertextBytes = base64Decode(base64Ciphertext); + final plaintextBytes = await groupCipher.decrypt(ciphertextBytes); + + return utf8.decode(plaintextBytes); + } + + Future processSenderKeyDistribution( + String groupId, + String senderUserId, + String base64DistributionMessage, + ) async { + await initStore(); + final senderKeyName = SenderKeyName(groupId, _getAddress(senderUserId)); + final builder = GroupSessionBuilder(_store); + + final messageBytes = base64Decode(base64DistributionMessage); + + final distMessage = SenderKeyDistributionMessageWrapper.fromSerialized( + messageBytes, + ); + + await builder.process(senderKeyName, distMessage); + debugPrint( + "E2EE: Processed new SenderKey for Group: $groupId from User: $senderUserId", + ); + } +} diff --git a/mobile/lib/services/sqlite_signal_store.dart b/mobile/lib/services/sqlite_signal_store.dart index 4695aed..aa7b0c1 100644 --- a/mobile/lib/services/sqlite_signal_store.dart +++ b/mobile/lib/services/sqlite_signal_store.dart @@ -4,7 +4,7 @@ import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:sqflite_sqlcipher/sqflite.dart'; import 'db_services.dart'; -class SQLiteSignalStore implements SignalProtocolStore { +class SQLiteSignalStore implements SignalProtocolStore, SenderKeyStore { String _toB64(Uint8List bytes) => base64Encode(bytes); Uint8List _fromB64(String b64) => base64Decode(b64); @@ -255,4 +255,33 @@ class SQLiteSignalStore implements SignalProtocolStore { final db = await DatabaseHelper.instance.database; await db.delete('signal_sessions', where: 'address = ?', whereArgs: [name]); } + + @override + Future storeSenderKey(SenderKeyName senderKeyName, SenderKeyRecord record) async { + final db = await DatabaseHelper.instance.database; + final keyNameStr = "${senderKeyName.groupId}::${senderKeyName.sender.getName()}"; + + await db.insert('signal_sender_keys', { + 'sender_key_name': keyNameStr, + 'record': _toB64(record.serialize()), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + @override + Future loadSenderKey(SenderKeyName senderKeyName) async { + final db = await DatabaseHelper.instance.database; + final keyNameStr = "${senderKeyName.groupId}::${senderKeyName.sender.getName()}"; + + final res = await db.query( + 'signal_sender_keys', + where: 'sender_key_name = ?', + whereArgs: [keyNameStr], + ); + + if (res.isEmpty) { + return SenderKeyRecord(); + } + return SenderKeyRecord.fromSerialized(_fromB64(res.first['record'] as String)); + } + } diff --git a/mobile/lib/services/ws_service.dart b/mobile/lib/services/ws_service.dart index 306cb4d..3a3355f 100644 --- a/mobile/lib/services/ws_service.dart +++ b/mobile/lib/services/ws_service.dart @@ -6,28 +6,28 @@ import '../core/constants.dart'; class WebSocketService { static final WebSocketService _instance = WebSocketService._internal(); - - factory WebSocketService() { - return _instance; - } - + factory WebSocketService() => _instance; WebSocketService._internal(); WebSocketChannel? _channel; bool _isConnected = false; Timer? _heartbeatTimer; + Timer? _pongWatchdog; - Stream? get stream => _channel?.stream; + Stream? _broadcastStream; + Stream? get stream => _broadcastStream; bool get isConnected => _isConnected; + VoidCallback? onConnectionLost; + Future connect(String token) async { if (_isConnected) return true; try { final wsUrl = Uri.parse("${Env.wsBaseUrl}?token=$token"); _channel = WebSocketChannel.connect(wsUrl); - await _channel!.ready; + _broadcastStream = _channel!.stream.asBroadcastStream(); _isConnected = true; debugPrint("WebSocket Pipeline Connected straight to: ${Env.wsBaseUrl}"); @@ -42,17 +42,43 @@ class WebSocketService { void _startHeartbeat() { _heartbeatTimer?.cancel(); - _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) { - if (_isConnected) { - emit({"type": "ping"}); + _pongWatchdog?.cancel(); + + _heartbeatTimer = Timer.periodic(const Duration(seconds: 15), (timer) { + if (!_isConnected) { + timer.cancel(); + return; } + + emit({"type": "ping"}); + + _pongWatchdog?.cancel(); + _pongWatchdog = Timer(const Duration(seconds: 6), () { + debugPrint("🚨 WS: Watchdog timed out. Half-open socket detected."); + disconnect(); + onConnectionLost?.call(); + }); }); } + void registerPong() { + _pongWatchdog?.cancel(); + } + void emit(Map payload) { - if (!_isConnected || _channel == null) return; - debugPrint("Sending Payload to WS: ${jsonEncode(payload)}"); - _channel?.sink.add(jsonEncode(payload)); + if (!_isConnected || _channel == null) { + debugPrint("⚠️ WS: Attempted to emit frame while disconnected."); + return; + } + try { + final encoded = jsonEncode(payload); + debugPrint("Sending Payload to WS: $encoded"); + _channel?.sink.add(encoded); + } catch (e) { + debugPrint("🚨 WS: Write error on sink (Socket broken): $e"); + disconnect(); + onConnectionLost?.call(); + } } void sendChat({ @@ -112,9 +138,12 @@ class WebSocketService { void disconnect() { _heartbeatTimer?.cancel(); - _channel?.sink.close(); + _pongWatchdog?.cancel(); + try { + _channel?.sink.close(); + } catch (_) {} _isConnected = false; _channel = null; debugPrint("WebSocket Pipeline Terminated Cleanly."); } -} +} \ No newline at end of file diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 664a0c4..5d604d8 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -201,14 +201,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -266,50 +258,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" url: "https://pub.dev" source: hosted - version: "11.0.0" - flutter_secure_storage_darwin: + version: "9.2.4" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_secure_storage_darwin - sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 url: "https://pub.dev" source: hosted - version: "0.4.0" - flutter_secure_storage_linux: + version: "1.2.3" + flutter_secure_storage_macos: dependency: transitive description: - name: flutter_secure_storage_linux - sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.1.3" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "1.1.2" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "1.2.1" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "3.1.2" flutter_shaders: dependency: transitive description: @@ -392,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -945,10 +945,10 @@ packages: dependency: transitive description: name: win32 - sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.4.0" + version: "5.15.0" x25519: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 67e3dde..4e566b6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: provider: ^6.1.5+1 launcher_name: ^1.0.2 flutter_launcher_icons: ^0.14.4 - flutter_secure_storage: ^11.0.0 + flutter_secure_storage: ^9.2.2 dio: ^5.11.0 flutter_markdown_plus: ^1.0.12 intl: ^0.20.3