Skip to content

Commit 5e45db9

Browse files
committed
feat: Implement chat functionality with message encryption and decryption
- Added MessageView model to represent messages in the chat. - Created ChatViewScreen for displaying chat messages and sending new messages. - Integrated message sending and receiving with encryption using KeyProvider. - Implemented message storage and retrieval in SecureStorage. - Enhanced NodeService to fetch current user and device IDs. - Added MessageService to handle message operations including fetching pending messages and sending messages. - Updated ChooseUsernameScreen to navigate to ConversationsScreen upon completion. - Refactored ConversationsScreen to display chat views and manage conversation selection. - Introduced session management with ratchet keys for secure messaging. - Updated dependencies in pubspec.yaml and pubspec.lock for uuid and other packages.
1 parent 65ca4db commit 5e45db9

11 files changed

Lines changed: 899 additions & 119 deletions

lib/data/node/sessions/create_session.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,59 @@ Future<bool> createSession(String nodeUrl, String recipientUserId) async {
129129
nonce: utf8.encode('HushNet-Salt'), // facultatif mais conseillé
130130
info: utf8.encode('X3DH Root Key'),
131131
);
132+
final sendChainKey = await hkdf.deriveKey(
133+
secretKey: rootKey,
134+
nonce: utf8.encode('HushNet-Salt'),
135+
info: utf8.encode('HushNet-Send-Chain'),
136+
);
137+
138+
final recvChainKey = await hkdf.deriveKey(
139+
secretKey: rootKey,
140+
nonce: utf8.encode('HushNet-Salt'),
141+
info: utf8.encode('HushNet-Recv-Chain'),
142+
);
143+
144+
// 🧩 Génère ton propre ratchet keypair (X25519)
145+
final ratchetAlgo = X25519();
146+
final ratchetPair = await ratchetAlgo.newKeyPair();
147+
final ratchetPub = await ratchetPair.extractPublicKey();
148+
final ratchetPriv = await ratchetPair.extractPrivateKeyBytes();
149+
150+
// 🗄️ Sauvegarde dans SecureStorage
151+
final rootB64 = base64Encode(await rootKey.extractBytes());
152+
final sendB64 = base64Encode(await sendChainKey.extractBytes());
153+
final recvB64 = base64Encode(await recvChainKey.extractBytes());
154+
final ratchetPubB64 = base64Encode(ratchetPub.bytes);
155+
final ratchetPrivB64 = base64Encode(ratchetPriv);
156+
157+
await keyProvider.secureStorage.write(
158+
key: "session_${device.deviceId}_root",
159+
value: rootB64,
160+
);
161+
await keyProvider.secureStorage.write(
162+
key: "session_${device.deviceId}_send_chain",
163+
value: sendB64,
164+
);
165+
await keyProvider.secureStorage.write(
166+
key: "session_${device.deviceId}_recv_chain",
167+
value: recvB64,
168+
);
169+
await keyProvider.secureStorage.write(
170+
key: "session_${device.deviceId}_ratchet_pub",
171+
value: ratchetPubB64,
172+
);
173+
await keyProvider.secureStorage.write(
174+
key: "session_${device.deviceId}_ratchet_priv",
175+
value: ratchetPrivB64,
176+
);
177+
await keyProvider.secureStorage.write(
178+
key: "session_${device.deviceId}_send_counter",
179+
value: "0",
180+
);
181+
await keyProvider.secureStorage.write(
182+
key: "session_${device.deviceId}_recv_counter",
183+
value: "0",
184+
);
132185

133186
// 6. encrypt initial plaintext with AES-GCM-256 using rootKey
134187
final nonce = aes.newNonce();

lib/models/message_view.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
class MessageView {
2+
final String id;
3+
final String logicalMsgId;
4+
final String chatId;
5+
final String fromUserId;
6+
final String fromDeviceId; // ✅ ajouté
7+
final String ciphertext;
8+
DateTime createdAt;
9+
final bool pending;
10+
11+
MessageView({
12+
required this.id,
13+
required this.logicalMsgId,
14+
required this.chatId,
15+
required this.fromUserId,
16+
required this.fromDeviceId,
17+
required this.ciphertext,
18+
required this.createdAt,
19+
this.pending = false,
20+
});
21+
22+
factory MessageView.fromJson(Map<String, dynamic> json) {
23+
return MessageView(
24+
id: json['id'],
25+
logicalMsgId: json['logical_msg_id'],
26+
chatId: json['chat_id'],
27+
fromUserId: json['from_user_id'],
28+
fromDeviceId: json['from_device_id'], // ✅
29+
ciphertext: json['ciphertext'],
30+
createdAt: json['from_device_id'] == "SELF_DEVICE"
31+
? DateTime.parse(json['created_at'])
32+
: DateTime.parse(json['created_at']),
33+
);
34+
}
35+
}

lib/screens/chat_view_screen.dart

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import 'dart:async';
2+
import 'package:flutter/material.dart';
3+
import 'package:hushnet_frontend/data/node/sessions/create_session.dart';
4+
import 'package:hushnet_frontend/models/chat_view.dart';
5+
import 'package:hushnet_frontend/models/message_view.dart';
6+
import 'package:hushnet_frontend/services/key_provider.dart';
7+
import 'package:hushnet_frontend/services/message_service.dart';
8+
import 'package:hushnet_frontend/services/node_service.dart';
9+
10+
class ChatViewScreen extends StatefulWidget {
11+
final String chatId;
12+
final String displayName;
13+
final bool embedded; // 👈 si affiché à droite (desktop)
14+
final ChatView chatView;
15+
16+
const ChatViewScreen({
17+
super.key,
18+
required this.chatId,
19+
required this.displayName,
20+
this.embedded = false, required this.chatView,
21+
});
22+
23+
@override
24+
State<ChatViewScreen> createState() => _ChatViewScreenState();
25+
}
26+
27+
class _ChatViewScreenState extends State<ChatViewScreen> {
28+
final MessageService messageService = MessageService();
29+
final TextEditingController _controller = TextEditingController();
30+
List<MessageView> _messages = [];
31+
bool _loading = true;
32+
late Timer _refreshTimer;
33+
final NodeService _nodeService = NodeService();
34+
String? _currentUserId;
35+
36+
@override
37+
void initState() {
38+
super.initState();
39+
_nodeService.getCurrentUserId().then((id) {
40+
setState(() {
41+
_currentUserId = id;
42+
});
43+
});
44+
_loadMessages();
45+
// auto refresh toutes les 10 secondes
46+
_refreshTimer = Timer.periodic(const Duration(seconds: 10), (_) {
47+
_loadMessages();
48+
});
49+
}
50+
51+
@override
52+
void dispose() {
53+
_refreshTimer.cancel();
54+
_controller.dispose();
55+
super.dispose();
56+
}
57+
58+
Future<void> _loadMessages() async {
59+
try {
60+
setState(() => _loading = true);
61+
final all = await messageService.getAllMessagesForChat(widget.chatId);
62+
// 🕒 tri croissant (vieux → récents)
63+
DateTime normalize(DateTime d) {
64+
final s = d.toIso8601String();
65+
print("normalizing date string: $s");
66+
// Si la date n'a pas de "Z" ni d'offset, on la traite comme locale et on force en UTC
67+
if (!s.endsWith('Z') && !s.contains('+')) {
68+
print("manque zone info, normalizing to UTC for $s");
69+
final res = DateTime.utc(
70+
d.year,
71+
d.month,
72+
d.day,
73+
d.hour,
74+
d.minute,
75+
d.second,
76+
d.millisecond,
77+
d.microsecond,
78+
).toUtc();
79+
print("normalized date: ${res.toIso8601String()}");
80+
return res;
81+
}
82+
return d.toUtc();
83+
}
84+
for (final msg in all) {
85+
msg.createdAt = normalize(msg.createdAt);
86+
}
87+
all.sort((a, b) => a.createdAt.compareTo(b.createdAt));
88+
for (final msg in all) {
89+
print('Message from ${msg.fromUserId}: ${msg.ciphertext} at ${msg.createdAt}');
90+
}
91+
92+
setState(() {
93+
_messages = all;
94+
_loading = false;
95+
});
96+
} catch (e) {
97+
debugPrint("Error loading messages: $e");
98+
setState(() => _loading = false);
99+
}
100+
}
101+
102+
Future<void> _sendMessage() async {
103+
final text = _controller.text.trim();
104+
if (text.isEmpty || _currentUserId == null) return;
105+
106+
try {
107+
final keyProvider = KeyProvider();
108+
109+
// 1️⃣ Identifier le destinataire
110+
final recipientUserId = widget.chatView.partnerUserId!;
111+
112+
// 2️⃣ Récupérer les devices actifs du destinataire
113+
final devices = await keyProvider.getUserDevicesKeys(recipientUserId);
114+
if (devices.isEmpty) {
115+
debugPrint('No devices for recipient');
116+
return;
117+
}
118+
119+
// 5️⃣ Envoi du message
120+
await messageService.sendMessage(
121+
chatId: widget.chatId,
122+
plaintext: text,
123+
recipientUserId: recipientUserId,
124+
recipientDeviceIds: devices.map((d) => d.deviceId).toList(),
125+
);
126+
127+
// 6️⃣ Reset input et refresh UI
128+
_controller.clear();
129+
await _loadMessages();
130+
131+
} catch (e) {
132+
debugPrint("❌ Error sending message: $e");
133+
}
134+
}
135+
136+
@override
137+
Widget build(BuildContext context) {
138+
final isDesktop = MediaQuery.of(context).size.width > 800;
139+
140+
final chatBody = Column(
141+
children: [
142+
if (!widget.embedded)
143+
AppBar(
144+
backgroundColor: const Color(0xFF1C1C1C),
145+
title: Text(widget.displayName),
146+
actions: [
147+
IconButton(
148+
icon: const Icon(Icons.refresh, color: Colors.greenAccent),
149+
onPressed: _loadMessages,
150+
),
151+
],
152+
),
153+
Expanded(
154+
child: _loading
155+
? const Center(
156+
child:
157+
CircularProgressIndicator(color: Colors.greenAccent))
158+
: _messages.isEmpty
159+
? const Center(
160+
child: Text("No messages yet 💬",
161+
style: TextStyle(color: Colors.grey)),
162+
)
163+
: ListView.builder(
164+
reverse: false,
165+
padding: const EdgeInsets.all(12),
166+
itemCount: _messages.length,
167+
itemBuilder: (context, index) {
168+
final msg = _messages[index];
169+
final isMe = msg.fromUserId == _currentUserId;
170+
return Align(
171+
alignment: isMe
172+
? Alignment.centerRight
173+
: Alignment.centerLeft,
174+
child: Container(
175+
margin: const EdgeInsets.symmetric(vertical: 4),
176+
padding: const EdgeInsets.symmetric(
177+
horizontal: 14, vertical: 10),
178+
decoration: BoxDecoration(
179+
color: isMe
180+
? Colors.greenAccent
181+
: const Color(0xFF2A2A2A),
182+
borderRadius: BorderRadius.circular(16),
183+
),
184+
child: Text(
185+
msg.ciphertext,
186+
style: TextStyle(
187+
color: isMe ? Colors.black : Colors.white,
188+
),
189+
),
190+
),
191+
);
192+
},
193+
),
194+
),
195+
Container(
196+
padding:
197+
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
198+
decoration: const BoxDecoration(
199+
color: Color(0xFF1C1C1C),
200+
border: Border(
201+
top: BorderSide(color: Color(0xFF2F2F2F), width: 0.5)),
202+
),
203+
child: Row(
204+
children: [
205+
Expanded(
206+
child: TextField(
207+
controller: _controller,
208+
style: const TextStyle(color: Colors.white),
209+
decoration: InputDecoration(
210+
hintText: "Type a message...",
211+
hintStyle: TextStyle(color: Colors.grey[500]),
212+
border: InputBorder.none,
213+
),
214+
),
215+
),
216+
IconButton(
217+
icon: const Icon(Icons.send, color: Colors.greenAccent),
218+
onPressed: _sendMessage,
219+
),
220+
],
221+
),
222+
),
223+
],
224+
);
225+
226+
if (widget.embedded) {
227+
return Container(
228+
color: const Color(0xFF101010),
229+
child: chatBody,
230+
);
231+
}
232+
233+
return Scaffold(
234+
backgroundColor: const Color(0xFF101010),
235+
body: SafeArea(child: chatBody),
236+
);
237+
}
238+
}

0 commit comments

Comments
 (0)