diff --git a/android/app/src/main/kotlin/com/nankai/openpeerchat_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/nankai/openpeerchat_flutter/MainActivity.kt deleted file mode 100644 index d9d9e99..0000000 --- a/android/app/src/main/kotlin/com/nankai/openpeerchat_flutter/MainActivity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.nankai.openpeerchat_flutter - -import io.flutter.embedding.android.FlutterFragmentActivity - -class MainActivity: FlutterFragmentActivity() { - // ... -} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 70a1de5..9bb29bc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,16 @@ buildscript { ext.kotlin_version = '2.0.10' + + repositories { + google() + mavenCentral() + } + + dependencies { + // Add the Android Gradle Plugin classpath + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } } allprojects { diff --git a/android/gradle.properties b/android/gradle.properties index 3b5b324..5a505fa 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e1ca574..9f7d524 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip + diff --git a/lib/classes/global.dart b/lib/classes/global.dart index 332078a..ba28d6f 100644 --- a/lib/classes/global.dart +++ b/lib/classes/global.dart @@ -11,7 +11,6 @@ import '../database/database_helper.dart'; import '../p2p/adhoc_housekeeping.dart'; import 'msg.dart'; - class Global extends ChangeNotifier { static RSAPrivateKey? myPrivateKey; static RSAPublicKey? myPublicKey; @@ -28,16 +27,19 @@ class Global extends ChangeNotifier { static Map cache = {}; static final GlobalKey scaffoldKey = GlobalKey(); + static double fileTransferProgress = 0.0; // Progress percentage + static double transferSpeed = 0.0; // Speed in bytes/second + static int estimatedTimeRemaining = 0; // Time in seconds + static bool isTransferActive = false; - void sentToConversations(Msg msg, String converser, {bool addToTable = true}) { - + static var profileNameStream; + void sentToConversations(Msg msg, String converser, {bool addToTable = true}) { conversations.putIfAbsent(converser, () => {}); conversations[converser]![msg.id] = msg; if (addToTable) { insertIntoConversationsTable(msg, converser); } - notifyListeners(); broadcast(scaffoldKey.currentContext!); } @@ -49,6 +51,11 @@ class Global extends ChangeNotifier { print("Received Message: $message"); } + + if (message['type'] == 'file') { + String filePath = await decodeAndStoreFile( + message['data'], message['fileName']); + //file decoding and saving if (message['type'] == 'voice' || message['type'] == 'file') { final String filePath = await decodeAndStoreFile( @@ -56,6 +63,7 @@ class Global extends ChangeNotifier { message['fileName'], isVoice: message['type'] == 'voice', ); + conversations.putIfAbsent(sender, () => {}); if (!conversations[sender]!.containsKey(decodedMessage['id'])) { print("Adding to conversations"); @@ -72,8 +80,7 @@ class Global extends ChangeNotifier { insertIntoConversationsTable(msg, sender); notifyListeners(); } - } - else { + } else { conversations.putIfAbsent(sender, () => {}); if (!conversations[sender]!.containsKey(decodedMessage['id'])) { var msg = Msg( @@ -89,14 +96,10 @@ class Global extends ChangeNotifier { Future decodeAndStoreFile(String encodedFile, String fileName, {bool isVoice = false}) async { Uint8List fileBytes = base64.decode(encodedFile); - //to send files encrypted using RSA - // Uint8List fileData = rsaDecrypt(Global.myPrivateKey!, fileBytes); - - Directory documents ; + Directory documents; if (Platform.isAndroid) { documents = (await getExternalStorageDirectory())!; - } - else { + } else { documents = await getApplicationDocumentsDirectory(); } @@ -115,15 +118,33 @@ class Global extends ChangeNotifier { print("File saved at: $path"); } return path; - } - else { + } else { throw const FileSystemException('Storage permission not granted'); } } + void updateFileTransferProgress(int bytesTransferred, int totalBytes) { + fileTransferProgress = (bytesTransferred / totalBytes) * 100; - void updateDevices(List devices) { + // Calculate transfer speed + transferSpeed = bytesTransferred / 1024; // Speed in KB/s + + // Estimate time remaining + estimatedTimeRemaining = + ((totalBytes - bytesTransferred) / transferSpeed).round(); + + notifyListeners(); + } + void resetFileTransferProgress() { + fileTransferProgress = 0.0; + transferSpeed = 0.0; + estimatedTimeRemaining = 0; + isTransferActive = false; + notifyListeners(); + } + + void updateDevices(List devices) { this.devices = devices; notifyListeners(); } diff --git a/lib/components/message_panel.dart b/lib/components/message_panel.dart index c9aef41..10fc45b 100644 --- a/lib/components/message_panel.dart +++ b/lib/components/message_panel.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_sound/flutter_sound.dart'; // ADDED import 'package:flutter/services.dart'; import 'package:nanoid/nanoid.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -15,10 +16,6 @@ import '../encyption/rsa.dart'; import 'audio_service.dart'; import 'view_file.dart'; -/// This component is used in the ChatPage. -/// It is the message bar where the message is typed on and sent to -/// connected devices. - class MessagePanel extends StatefulWidget { const MessagePanel({Key? key, required this.converser, this.onMessageSent}) : super(key: key); final String converser;final VoidCallback? onMessageSent; @@ -30,13 +27,36 @@ class MessagePanel extends StatefulWidget { class _MessagePanelState extends State { TextEditingController myController = TextEditingController(); File _selectedFile = File(''); + + FlutterSoundRecorder? _recorder; // ADDED + bool _isRecording = false; // ADDED + String? _recordedFilePath; // ADDED + late final AudioService _audioService; bool _isRecording = false; String? _currentRecordingPath; + @override void initState() { super.initState(); + _recorder = FlutterSoundRecorder(); // ADDED + _initializeRecorder(); // ADDED + } + + @override + void dispose() { + _recorder?.closeRecorder(); // ADDED + _recorder = null; // ADDED + super.dispose(); + } + + // ADDED: Initialize audio recorder + Future _initializeRecorder() async { + await _recorder?.openRecorder(); + await _recorder?.setSubscriptionDuration(const Duration(milliseconds: 100)); + } + _audioService = AudioService(); _initializeAudio(); } @@ -102,10 +122,40 @@ class _MessagePanelState extends State { } + @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(8.0), + + child: TextFormField( + maxLines: null, + controller: myController, + decoration: InputDecoration( + icon: const Icon(Icons.person), + hintText: 'Send Message?', + labelText: 'Send Message', + suffixIcon: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => _navigateToFilePreviewPage(context), + icon: const Icon(Icons.attach_file), + ), + IconButton( + onPressed: _toggleRecording, // ADDED + icon: Icon( + _isRecording ? Icons.mic_off : Icons.mic, // ADDED + color: _isRecording ? Colors.red : null, // ADDED + ), + ), + IconButton( + onPressed: () => _sendMessage(context), + icon: const Icon( + Icons.send, + ), + decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, boxShadow: [ @@ -140,6 +190,7 @@ class _MessagePanelState extends State { decoration: BoxDecoration( color: _isRecording ? Colors.red.withOpacity(0.1) : null, shape: BoxShape.circle, + ), child: Icon( _isRecording ? Icons.mic : Icons.mic_none, @@ -161,7 +212,6 @@ class _MessagePanelState extends State { if (myController.text.isEmpty) { return; } - // Encode the message to base64 String data = jsonEncode({ "sender": Global.myName, @@ -189,7 +239,6 @@ class _MessagePanelState extends State { ); RSAPublicKey publicKey = Global.myPublicKey!; - // Encrypt the message Uint8List encryptedMessage = rsaEncrypt( publicKey, Uint8List.fromList(utf8.encode(myController.text))); @@ -204,21 +253,76 @@ class _MessagePanelState extends State { widget.converser, ); - // refreshMessages(); myController.clear(); } - /// This function is used to navigate to the file preview page and check the file size. + // ADDED: Start and stop audio recording + Future _toggleRecording() async { + if (_isRecording) { + final path = await _recorder?.stopRecorder(); + setState(() { + _isRecording = false; + _recordedFilePath = path; + }); + if (path != null) { + _sendAudioMessage(context, path); // Send the recorded audio + } + } else { + await _recorder?.startRecorder( + codec: Codec.aacMP4, + toFile: 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a', + ); + setState(() { + _isRecording = true; + }); + } + } + + // ADDED: Send recorded audio as a message + void _sendAudioMessage(BuildContext context, String filePath) { + var msgId = nanoid(21); + String fileName = filePath.split('/').last; + + String data = jsonEncode({ + "sender": Global.myName, + "type": "audio", + "fileName": fileName, + "filePath": filePath, + }); + + String date = DateTime.now().toUtc().toString(); + Global.cache[msgId] = Payload( + msgId, + Global.myName, + widget.converser, + data, + date, + ); + insertIntoMessageTable( + Payload( + msgId, + Global.myName, + widget.converser, + data, + date, + ), + ); + + Provider.of(context, listen: false).sentToConversations( + Msg(data, "sent", date, msgId), + widget.converser, + ); + } + + // Existing file picker and sender logic void _navigateToFilePreviewPage(BuildContext context) async { - //max size of file is 30 MB double sizeKbs = 0; const int maxSizeKbs = 30 * 1024; FilePickerResult? result = await FilePicker.platform.pickFiles(); - if(result != null) { + if (result != null) { sizeKbs = result.files.single.size / 1024; } - if (sizeKbs > maxSizeKbs) { if (!context.mounted) return; showDialog( @@ -230,10 +334,8 @@ class _MessagePanelState extends State { mainAxisSize: MainAxisSize.min, children: [ ListTile( - //file size in MB title: Text('File Size: ${(sizeKbs / 1024).ceil()} MB'), - subtitle: const Text( - 'File size should not exceed 30 MB'), + subtitle: const Text('File size should not exceed 30 MB'), ), ], ), @@ -251,7 +353,6 @@ class _MessagePanelState extends State { return; } -//this function is used to open the file preview dialog if (result != null) { setState(() { _selectedFile = File(result.files.single.path!); @@ -266,12 +367,11 @@ class _MessagePanelState extends State { mainAxisSize: MainAxisSize.min, children: [ ListTile( - - title: Text('File Name: ${_selectedFile.path - .split('/') - .last}', overflow: TextOverflow.ellipsis,), - subtitle: Text( - 'File Size: ${(sizeKbs / 1024).floor()} MB'), + title: Text( + 'File Name: ${_selectedFile.path.split('/').last}', + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('File Size: ${(sizeKbs / 1024).floor()} MB'), ), ElevatedButton( onPressed: () => FilePreview.openFile(_selectedFile.path), @@ -280,7 +380,6 @@ class _MessagePanelState extends State { ], ), actions: [ - TextButton( onPressed: () { Navigator.of(context).pop(); @@ -289,10 +388,9 @@ class _MessagePanelState extends State { ), IconButton( onPressed: () { - Navigator.pop(context); - _sendFileMessage(context, _selectedFile); - - }, + Navigator.pop(context); + _sendFileMessage(context, _selectedFile); + }, icon: const Icon( Icons.send, ), @@ -305,6 +403,9 @@ class _MessagePanelState extends State { } + void _sendFileMessage(BuildContext context, File file) async { + + void _sendVoiceMessage(File audioFile) async { final String msgId = nanoid(21); final String fileName = 'voice_${DateTime.now().millisecondsSinceEpoch}.aac'; @@ -331,6 +432,7 @@ class _MessagePanelState extends State { /// This function is used to send the file message. void _sendFileMessage(BuildContext context, File file) async{ + var msgId = nanoid(21); String fileName = _selectedFile.path.split('/').last; @@ -365,7 +467,5 @@ class _MessagePanelState extends State { Msg(data, "sent", date, msgId), widget.converser, ); - } - } diff --git a/lib/components/view_file.dart b/lib/components/view_file.dart index 2e491c3..962c251 100644 --- a/lib/components/view_file.dart +++ b/lib/components/view_file.dart @@ -2,79 +2,78 @@ import 'dart:io'; import 'package:open_filex/open_filex.dart'; import 'package:permission_handler/permission_handler.dart'; - class FilePreview { - + /// Opens a file based on the platform and file path. static Future openFile(String path) async { if (Platform.isIOS) { - _openIOSFile(path); + await _openFileIOS(path); } else if (Platform.isAndroid) { if (path.contains("Android/data")) { - _openAndroidPrivateFile(path); + await _openAndroidPrivateFile(path); } else if (path.contains("DCIM")) { - if (path.contains(".jpg")) { - _openAndroidExternalImage(path); - } else if (path.contains(".mp4")) { - _openAndroidExternalVideo(path); - } else if (path.contains(".mp3")) { - _openAndroidExternalAudio(path); + if (path.endsWith(".jpg") || path.endsWith(".jpeg") || path.endsWith(".png")) { + await _openAndroidExternalImage(path); + } else if (path.endsWith(".mp4")) { + await _openAndroidExternalVideo(path); + } else if (path.endsWith(".mp3") || path.endsWith(".wav")) { + await _openAndroidExternalAudio(path); } else { - _openAndroidExternalFile(path); + await _openAndroidExternalFile(path); } } else { - _openAndroidOtherAppFile(path); + await _openAndroidOtherAppFile(path); } } } - // ignore: unused_element - static _openIOSFile(String path) async { - await OpenFilex.open( path); - + /// Opens a file on iOS. + static Future _openFileIOS(String path) async { + await OpenFilex.open(path); } - // ignore: unused_element - static _openAndroidPrivateFile( String path) async { - await OpenFilex.open( path); - - + /// Opens a private file on Android (e.g., within `Android/data`). + static Future _openAndroidPrivateFile(String path) async { + await OpenFilex.open(path); } - static _openAndroidOtherAppFile(String path) async { - if (await Permission.manageExternalStorage.request().isGranted) { - await OpenFilex.open(path); + /// Opens a file in another app on Android (if outside private folders). + static Future _openAndroidOtherAppFile(String path) async { + if (await _requestPermission(Permission.manageExternalStorage)) { + await OpenFilex.open(path); } } - // ignore: unused_element - static _openAndroidExternalImage(String path) async { - if (await Permission.photos.request().isGranted) { - await OpenFilex.open(path); - + /// Opens an external image file on Android. + static Future _openAndroidExternalImage(String path) async { + if (await _requestPermission(Permission.photos)) { + await OpenFilex.open(path); } } - // ignore: unused_element - static _openAndroidExternalVideo(String path) async { - if (await Permission.videos.request().isGranted) { + /// Opens an external video file on Android. + static Future _openAndroidExternalVideo(String path) async { + if (await _requestPermission(Permission.videos)) { await OpenFilex.open(path); - } } - // ignore: unused_element - static _openAndroidExternalAudio(String path) async { - if (await Permission.audio.request().isGranted) { -await OpenFilex.open(path); - + /// Opens an external audio file on Android. + static Future _openAndroidExternalAudio(String path) async { + if (await _requestPermission(Permission.audio)) { + await OpenFilex.open(path); } } - // ignore: unused_element - static _openAndroidExternalFile(String path) async { - if (await Permission.manageExternalStorage.request().isGranted) { + /// Opens any external file on Android (requires manage storage permission). + static Future _openAndroidExternalFile(String path) async { + if (await _requestPermission(Permission.manageExternalStorage)) { await OpenFilex.open(path); - } } -} \ No newline at end of file + + /// Requests a specific permission and returns whether it is granted. + static Future _requestPermission(Permission permission) async { + final status = await permission.request(); + return status.isGranted; + } +} diff --git a/lib/connections/connection_handler.dart b/lib/connections/connection_handler.dart new file mode 100644 index 0000000..766a057 --- /dev/null +++ b/lib/connections/connection_handler.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:flutter_nearby_connections/flutter_nearby_connections.dart'; + +class ConnectionHandler { + final NearbyService nearbyService = NearbyService(); + final List devices = []; + final List connectedDevices = []; + StreamSubscription? deviceSubscription; + StreamSubscription? receivedDataSubscription; + + /// Initialize the Nearby Service + Future initialize({required String userName}) async { + await nearbyService.init( + serviceType: 'your_service_type', // Replace with your service type. + deviceName: userName, + strategy: Strategy.P2P_CLUSTER, callback: null, + ); + await nearbyService.startAdvertisingPeer(); + await nearbyService.startBrowsingForPeers(); + + _listenToDeviceChanges(); + _listenToReceivedData(); + } + + /// Listen for changes in available devices. + void _listenToDeviceChanges() { + deviceSubscription = nearbyService.stateChangedSubscription(callback: (devicesList) { + devices + ..clear() + ..addAll(devicesList); + + // Filter connected devices + connectedDevices + ..clear() + ..addAll(devicesList.where((device) => device.state == SessionState.connected)); + }); + } + + /// Listen for received data from peers. + void _listenToReceivedData() { + receivedDataSubscription = nearbyService.dataReceivedSubscription(callback: (data) { + // Handle received data here + print("Data received: ${data['message']}"); + }); + } + + /// Send a message to a specific device. + Future sendMessage(Device device, String message) async { + await nearbyService.sendMessage(device.deviceId, message); + } + + /// Disconnect from a device. + Future disconnectDevice(Device device) async { + await nearbyService.disconnectPeer(deviceID: device.deviceId); + } + + /// Cleanup resources when done. + Future dispose() async { + await deviceSubscription?.cancel(); + await receivedDataSubscription?.cancel(); + await nearbyService.stopAdvertisingPeer(); + await nearbyService.stopBrowsingForPeers(); + } +} diff --git a/lib/encyption/rsa.dart b/lib/encyption/rsa.dart index 042b2f3..bac4556 100644 --- a/lib/encyption/rsa.dart +++ b/lib/encyption/rsa.dart @@ -1,19 +1,24 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; -import 'package:pointycastle/src/platform_check/platform_check.dart'; -import "package:pointycastle/export.dart"; - -AsymmetricKeyPair generateRSAkeyPair( - SecureRandom secureRandom, - {int bitLength = 2048}) { +import 'package:pointycastle/api.dart'; +import 'package:pointycastle/export.dart'; +import 'package:pointycastle/random/fortuna_random.dart'; +import 'dart:math'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +AsymmetricKeyPair generateRSAkeyPair(SecureRandom secureRandom, {int bitLength = 2048}) { final keyGen = RSAKeyGenerator() ..init(ParametersWithRandom( RSAKeyGeneratorParameters(BigInt.parse('65537'), bitLength, 64), secureRandom)); final pair = keyGen.generateKeyPair(); - final myPublic = pair.publicKey as RSAPublicKey; final myPrivate = pair.privateKey as RSAPrivateKey; @@ -21,53 +26,49 @@ AsymmetricKeyPair generateRSAkeyPair( } SecureRandom exampleSecureRandom() { - final secureRandom = FortunaRandom() - ..seed(KeyParameter( - Platform.instance.platformEntropySource().getBytes(32))); + final secureRandom = FortunaRandom(); + final seedSource = Random.secure(); + final seed = List.generate(32, (_) => seedSource.nextInt(256)); + secureRandom.seed(KeyParameter(Uint8List.fromList(seed))); return secureRandom; } - Uint8List rsaEncrypt(RSAPublicKey myPublic, Uint8List dataToEncrypt) { final encryptor = OAEPEncoding(RSAEngine()) - ..init(true, PublicKeyParameter(myPublic)); // true=encrypt + ..init(true, PublicKeyParameter(myPublic)); return _processInBlocks(encryptor, dataToEncrypt); } Uint8List rsaDecrypt(RSAPrivateKey myPrivate, Uint8List cipherText) { final decryptor = OAEPEncoding(RSAEngine()) - ..init(false, PrivateKeyParameter(myPrivate)); // false=decrypt + ..init(false, PrivateKeyParameter(myPrivate)); return _processInBlocks(decryptor, cipherText); } - Uint8List _processInBlocks(AsymmetricBlockCipher engine, Uint8List input) { - final numBlocks = input.length ~/ engine.inputBlockSize + - ((input.length % engine.inputBlockSize != 0) ? 1 : 0); - - final output = Uint8List(numBlocks * engine.outputBlockSize); + final inputBlockSize = engine.inputBlockSize; + final outputBlockSize = engine.outputBlockSize; + final numBlocks = (input.length / inputBlockSize).ceil(); + final output = Uint8List(numBlocks * outputBlockSize); var inputOffset = 0; var outputOffset = 0; + while (inputOffset < input.length) { - final chunkSize = (inputOffset + engine.inputBlockSize <= input.length) - ? engine.inputBlockSize + final chunkSize = (input.length - inputOffset > inputBlockSize) + ? inputBlockSize : input.length - inputOffset; outputOffset += engine.processBlock( input, inputOffset, chunkSize, output, outputOffset); - inputOffset += chunkSize; } - return (output.length == outputOffset) - ? output - : output.sublist(0, outputOffset); + return output.sublist(0, outputOffset); } - String encodePrivateKeyToPem(RSAPrivateKey privateKey) { final topLevel = ASN1Sequence(); topLevel.add(ASN1Integer(BigInt.from(0))); @@ -93,29 +94,78 @@ String encodePublicKeyToPem(RSAPublicKey publicKey) { return "-----BEGIN PUBLIC KEY-----\r\n$dataBase64\r\n-----END PUBLIC KEY-----"; } -//parsePrivateKeyFromPem RSAPrivateKey parsePrivateKeyFromPem(String pem) { - final data = pem.split(RegExp(r'\r?\n')); - final raw = base64.decode(data.sublist(1, data.length - 1).join('')); + final data = pem.split(RegExp(r'\r?\n')).where((line) => !line.contains('-----')).join(''); + final raw = base64.decode(data); final topLevel = ASN1Sequence.fromBytes(raw); - final n = topLevel.elements[1] as ASN1Integer; - final d = topLevel.elements[3] as ASN1Integer; - final p = topLevel.elements[4] as ASN1Integer; - final q = topLevel.elements[5] as ASN1Integer; + final n = (topLevel.elements[1] as ASN1Integer).valueAsBigInteger; + final d = (topLevel.elements[3] as ASN1Integer).valueAsBigInteger; + final p = (topLevel.elements[4] as ASN1Integer).valueAsBigInteger; + final q = (topLevel.elements[5] as ASN1Integer).valueAsBigInteger; - return RSAPrivateKey( - n.valueAsBigInteger, d.valueAsBigInteger, p.valueAsBigInteger, q.valueAsBigInteger); + return RSAPrivateKey(n, d, p, q); } RSAPublicKey parsePublicKeyFromPem(String pem) { - final data = pem.split(RegExp(r'\r?\n')); - final raw = base64.decode(data.sublist(1, data.length - 1).join('')); + final data = pem.split(RegExp(r'\r?\n')).where((line) => !line.contains('-----')).join(''); + final raw = base64.decode(data); final topLevel = ASN1Sequence.fromBytes(raw); - final modulus = topLevel.elements[0] as ASN1Integer; - final exponent = topLevel.elements[1] as ASN1Integer; + final modulus = (topLevel.elements[0] as ASN1Integer).valueAsBigInteger; + final exponent = (topLevel.elements[1] as ASN1Integer).valueAsBigInteger; - return RSAPublicKey(modulus.valueAsBigInteger, exponent.valueAsBigInteger); + return RSAPublicKey(modulus, exponent); } +Future initDatabase() async { + final databasePath = await getDatabasesPath(); + final path = join(databasePath, 'chat_database.db'); + + return openDatabase( + path, + onCreate: (db, version) { + return db.execute( + 'CREATE TABLE messages(id INTEGER PRIMARY KEY, content TEXT, mediaPath TEXT)', + ); + }, + version: 1, + ); +} + +Future saveMessage(String message, {String? mediaPath}) async { + final db = await initDatabase(); + await db.insert( + 'messages', + {'content': message, 'mediaPath': mediaPath}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); +} + +Future>> retrieveMessages() async { + final db = await initDatabase(); + return db.query('messages'); +} + +Future exportChatHistory() async { + final pdf = pw.Document(); + final messages = await retrieveMessages(); + + for (var message in messages) { + pdf.addPage(pw.Page(build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(message['content']), + if (message['mediaPath'] != null) + pw.Text('Media: ${message['mediaPath']}'), + ], + ); + })); + } + final directory = await getApplicationDocumentsDirectory(); + final filePath = '${directory.path}/chat_history.pdf'; + final file = File(filePath); + await file.writeAsBytes(await pdf.save()); + print('Chat history exported to: $filePath'); +} diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index e3cce21..260665f 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,5 +1,4 @@ import 'dart:typed_data'; - import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -14,16 +13,20 @@ import '../encyption/rsa.dart'; import 'package:audioplayers/audioplayers.dart'; class ChatPage extends StatefulWidget { - const ChatPage({Key? key, required this.converser}) : super(key: key); - - final String converser; + String converser; + ChatPage({Key? key, required this.converser}) : super(key: key); @override - ChatPageState createState() => ChatPageState(); + _ChatPageState createState() => _ChatPageState(); } -class ChatPageState extends State { +class _ChatPageState extends State { + final ScrollController _scrollController = ScrollController(); List messageList = []; + + double fileTransferProgress = 0.0; + bool isTransferring = false; + TextEditingController myController = TextEditingController(); final AudioPlayer _audioPlayer = AudioPlayer(); String? _currentlyPlayingId; @@ -31,6 +34,7 @@ class ChatPageState extends State { final ScrollController _scrollController = ScrollController(); bool _isFirstBuild = true; // Add this flag + String _formatDuration(Duration duration) { String twoDigits(int n) => n.toString().padLeft(2, '0'); String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); @@ -40,6 +44,29 @@ class ChatPageState extends State { @override void initState() { super.initState(); + + _subscribeToProfileUpdates(); + } + + void _subscribeToProfileUpdates() { + Global.profileNameStream.listen((updatedName) { + if (widget.converser == Global.myName) { + setState(() { + widget.converser = updatedName; + }); + } + }); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + _audioPlayer.setReleaseMode(ReleaseMode.stop); // Stop when completed _audioPlayer.onPlayerComplete.listen((event) { if (mounted) { @@ -91,30 +118,161 @@ class ChatPageState extends State { _scrollToBottom(); _isFirstBuild = false; }); - } - } - Map> groupedMessages = {}; - for (var msg in messageList) { - String date = DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); - if (groupedMessages[date] == null) { - groupedMessages[date] = []; } - groupedMessages[date]!.add(msg); + }); + } + + Future _startFileTransfer(String filePath) async { + setState(() { + isTransferring = true; + fileTransferProgress = 0.0; + }); + + // Simulating file transfer with a loop for progress update + for (int i = 0; i <= 100; i++) { + await Future.delayed(const Duration(milliseconds: 50)); // Simulate transfer delay + setState(() { + fileTransferProgress = i / 100.0; + }); } + setState(() { + isTransferring = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File transfer completed successfully!')), + ); + } + + @override + Widget build(BuildContext context) { + messageList = _getMessageList(context); + + Map> groupedMessages = _groupMessagesByDate(messageList); + return Scaffold( appBar: AppBar( title: Text(widget.converser), + actions: [ + IconButton( + icon: const Icon(Icons.download), + onPressed: () async { + await exportChatHistory(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Chat history exported successfully!')), + ); + }, + ), + ], ), body: Column( children: [ Expanded( child: messageList.isEmpty - ? const Center( - child: Text('No messages yet'), - ) + ? const Center(child: Text('No messages yet')) : ListView.builder( + + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: groupedMessages.keys.length, + itemBuilder: (context, index) { + String date = groupedMessages.keys.elementAt(index); + return _buildMessageGroup(date, groupedMessages[date]!); + }, + ), + ), + if (isTransferring) + LinearProgressIndicator( + value: fileTransferProgress, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation(Colors.blue), + ), + MessagePanel(converser: widget.converser), + ], + ), + ); + } + + List _getMessageList(BuildContext context) { + var conversation = Provider.of(context).conversations[widget.converser]; + if (conversation == null) return []; + return conversation.values.toList(); + } + + Map> _groupMessagesByDate(List messages) { + Map> groupedMessages = {}; + for (var msg in messages) { + String date = DateFormat('dd/MM/yyyy').format(DateTime.parse(msg.timestamp)); + groupedMessages.putIfAbsent(date, () => []).add(msg); + } + return groupedMessages; + } + + Widget _buildMessageGroup(String date, List messages) { + return Column( + children: [ + _buildDateHeader(date), + ...messages.map((msg) => _buildMessageBubble(msg)), + ], + ); + } + + Widget _buildDateHeader(String date) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + date, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _buildMessageBubble(Msg msg) { + String displayMessage = msg.message; + if (msg.msgtype == 'text' && Global.myPrivateKey != null) { + displayMessage = _decryptMessage(msg.message); + } + return Column( + crossAxisAlignment: msg.msgtype == 'sent' + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Align( + alignment: msg.msgtype == 'sent' + ? Alignment.centerRight + : Alignment.centerLeft, + child: Bubble( + padding: const BubbleEdges.all(12), + margin: const BubbleEdges.only(top: 10), + style: BubbleStyle( + elevation: 3, + shadowColor: Colors.black.withOpacity(0.5), + ), + radius: const Radius.circular(10), + color: msg.msgtype == 'sent' + ? const Color(0xffd1c4e9) + : const Color(0xff80DEEA), + child: msg.message.contains('file') + ? _buildFileBubble(msg) + : Text(displayMessage, style: const TextStyle(color: Colors.black87)), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 10), + child: Text( + dateFormatter(timeStamp: msg.timestamp), + style: const TextStyle(color: Colors.black54, fontSize: 10), + ), + ), + ], + ); + } + + controller: _scrollController, padding: const EdgeInsets.all(8), itemCount: groupedMessages.keys.length, @@ -328,6 +486,7 @@ class ChatPageState extends State { ); } } + Widget _buildFileBubble(Msg msg) { dynamic data = jsonDecode(msg.message); if (data['type'] == 'voice') { @@ -342,26 +501,32 @@ class ChatPageState extends State { Flexible( child: Text( fileName, - style: const TextStyle( - color: Colors.black87, - ), - overflow: TextOverflow.visible, - + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.file_open, color: Colors.black87), - onPressed: () { - FilePreview.openFile(filePath); - }, + onPressed: () => _startFileTransfer(filePath), ), ], ); } + + String _decryptMessage(String message) { + try { + RSAPrivateKey privateKey = Global.myPrivateKey!; + dynamic data = jsonDecode(message); + Uint8List encryptedBytes = base64Decode(data['data']); + Uint8List decryptedBytes = rsaDecrypt(privateKey, encryptedBytes); + return utf8.decode(decryptedBytes); + } catch (e) { + return "[Error decrypting message]"; + } + } } String dateFormatter({required String timeStamp}) { DateTime dateTime = DateTime.parse(timeStamp); - String formattedTime = DateFormat('hh:mm aa').format(dateTime); - return formattedTime; + return DateFormat('hh:mm aa').format(dateTime); } diff --git a/lib/pages/file_transfer_logic.dart b/lib/pages/file_transfer_logic.dart new file mode 100644 index 0000000..79ece22 --- /dev/null +++ b/lib/pages/file_transfer_logic.dart @@ -0,0 +1,81 @@ +import 'dart:async'; +import 'dart:io'; + +class FileTransferService { + /// StreamController for transfer progress + final StreamController _progressController = StreamController.broadcast(); + + /// Get the progress stream + Stream get progressStream => _progressController.stream; + + /// Upload a file with progress tracking + Future uploadFile({ + required File file, + required Function onComplete, + required Function(String error) onError, + }) async { + try { + final totalBytes = await file.length(); + int bytesTransferred = 0; + + // Simulating chunked file upload + final chunkSize = 1024 * 512; // 512 KB + final fileStream = file.openRead(); + + await for (final chunk in fileStream) { + bytesTransferred += chunk.length; + + // Update progress + final progress = bytesTransferred / totalBytes; + _progressController.add(progress); + + // Simulate sending the chunk + await Future.delayed(const Duration(milliseconds: 100)); + } + + // Notify completion + onComplete(); + } catch (e) { + // Notify error + onError(e.toString()); + } finally { + _progressController.close(); + } + } + + /// Download a file with progress tracking + Future downloadFile({ + required String savePath, + required int totalBytes, + required Stream> incomingStream, + required Function onComplete, + required Function(String error) onError, + }) async { + try { + final file = File(savePath).openWrite(); + int bytesReceived = 0; + + // Write incoming chunks to the file + await for (final chunk in incomingStream) { + bytesReceived += chunk.length; + file.add(chunk); + + // Update progress + final progress = bytesReceived / totalBytes; + _progressController.add(progress); + } + + await file.close(); + onComplete(); + } catch (e) { + onError(e.toString()); + } finally { + _progressController.close(); + } + } + + /// Dispose the StreamController + void dispose() { + _progressController.close(); + } +} diff --git a/lib/pages/profile.dart b/lib/pages/profile.dart index d6863eb..7a53795 100644 --- a/lib/pages/profile.dart +++ b/lib/pages/profile.dart @@ -4,9 +4,13 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nanoid/nanoid.dart'; import '../classes/global.dart'; + +import '../services/communication_service.dart'; + import '../providers/theme_provider.dart'; import 'home_screen.dart'; + class Profile extends StatefulWidget { final bool onLogin; const Profile({Key? key, required this.onLogin}) : super(key: key); @@ -218,6 +222,20 @@ class _ProfileState extends State with SingleTickerProviderStateMixin { ), ), ), + + ElevatedButton( + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + // saving the name and id to shared preferences + prefs.setString('p_name', myName.text); + prefs.setString('p_id', customLengthId); + CommunicationService.broadcastProfileUpdate(customLengthId, myName.text); + // On pressing, move to the home screen + navigateToHomeScreen(); + }, + child: const Text("Save"), + ) + ], ), ), diff --git a/lib/services/communication_service.dart b/lib/services/communication_service.dart new file mode 100644 index 0000000..3681a17 --- /dev/null +++ b/lib/services/communication_service.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +// ignore: depend_on_referenced_packages +import 'package:web_socket_channel/web_socket_channel.dart'; + +class CommunicationService { + static final WebSocketChannel _channel = + WebSocketChannel.connect(Uri.parse('ws://your-websocket-server-url')); + + /// Broadcasts a profile update to all connected peers + static void broadcastProfileUpdate(String userId, String newName) { + final message = { + 'type': 'profile_update', + 'userId': userId, + 'newName': newName, + }; + + _channel.sink.add(jsonEncode(message)); + } + + /// Listens for incoming messages + static void listen(void Function(Map) onMessage) { + _channel.stream.listen((data) { + final decodedData = jsonDecode(data); + onMessage(decodedData); + }); + } + + /// Closes the WebSocket connection + static void closeConnection() { + _channel.sink.close(); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1515165..cfc77e8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,8 +6,10 @@ import FlutterMacOS import Foundation import audio_session + import audioplayers_darwin import device_info_plus + import flutter_secure_storage_macos import just_audio import local_auth_darwin @@ -18,8 +20,10 @@ import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) diff --git a/pubspec.yaml b/pubspec.yaml index 70cb06c..d7d893e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,6 @@ dependencies: flutter_nearby_connections: ^1.1.2 cupertino_icons: ^1.0.8 bubble: ^1.2.1 - nanoid: ^1.0.0 sqflite: ^2.3.3+1 intl: ^0.19.0 pointycastle: ^3.9.1 @@ -32,16 +31,27 @@ dependencies: open_filex: ^4.5.0 permission_handler: ^11.3.1 path_provider: ^2.1.4 + + web_socket_channel: ^2.2.0 + nanoid: ^1.0.0 + flutter_sound: any + just_audio: ^0.9.14 + pdf: ^3.11.1 + just_audio: ^0.9.42 record: ^4.4.4 audioplayers: ^6.1.0 device_info_plus: ^11.2.0 + dev_dependencies: flutter_lints: flutter_test: sdk: flutter + + + flutter: uses-material-design: true \ No newline at end of file