feat: initial file transfer implementation
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import 'package:file_transfer/functions/fetch_blob.dart';
|
||||
import 'package:file_transfer/functions/fetch_file_metdata.dart';
|
||||
import 'package:file_transfer/models/file_metadata.dart';
|
||||
import 'package:file_transfer/models/shared_file.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
|
||||
class FileShareController extends GetxController {
|
||||
final _isLoading = false.obs;
|
||||
final _isFetchingMetadata = false.obs;
|
||||
final _error = RxnString();
|
||||
final _decryptedData = Rxn<Uint8List>();
|
||||
final _metadata = Rxn<FileMetadata>();
|
||||
final _filename = RxnString();
|
||||
final _hasStarted = false.obs;
|
||||
|
||||
bool get isLoading => _isLoading.value;
|
||||
bool get isFetchingMetadata => _isFetchingMetadata.value;
|
||||
String? get error => _error.value;
|
||||
Uint8List? get decryptedData => _decryptedData.value;
|
||||
FileMetadata? get metadata => _metadata.value;
|
||||
String? get filename => _filename.value;
|
||||
bool get hasStarted => _hasStarted.value;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_fetchMetadata();
|
||||
}
|
||||
|
||||
Future<void> _fetchMetadata() async {
|
||||
final nevent = Get.parameters['nevent'] ?? '';
|
||||
final encodedPrivateKey = Get.parameters['encodedPrivateKey'] ?? '';
|
||||
|
||||
if (nevent.isEmpty || encodedPrivateKey.isEmpty) {
|
||||
_error.value = 'Invalid link: missing nevent or private key';
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
_isFetchingMetadata.value = true;
|
||||
_error.value = null;
|
||||
update();
|
||||
|
||||
try {
|
||||
final ndk = Get.find<Ndk>();
|
||||
|
||||
final sharedFile = SharedFile(
|
||||
nevent: nevent,
|
||||
encodedPrivateKey: encodedPrivateKey,
|
||||
);
|
||||
|
||||
final metadata = await fetchFileMetadata(
|
||||
ndk: ndk,
|
||||
sharedFile: sharedFile,
|
||||
);
|
||||
|
||||
_metadata.value = metadata;
|
||||
_isFetchingMetadata.value = false;
|
||||
update();
|
||||
} catch (e) {
|
||||
_error.value = e.toString();
|
||||
_isFetchingMetadata.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startDecrypt() async {
|
||||
if (_hasStarted.value) return;
|
||||
_hasStarted.value = true;
|
||||
update();
|
||||
|
||||
final nevent = Get.parameters['nevent'] ?? '';
|
||||
final encodedPrivateKey = Get.parameters['encodedPrivateKey'] ?? '';
|
||||
|
||||
if (nevent.isEmpty || encodedPrivateKey.isEmpty) {
|
||||
_error.value = 'Invalid link: missing nevent or private key';
|
||||
_isLoading.value = false;
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading.value = true;
|
||||
_error.value = null;
|
||||
update();
|
||||
|
||||
try {
|
||||
final ndk = Get.find<Ndk>();
|
||||
|
||||
final sharedFile = SharedFile(
|
||||
nevent: nevent,
|
||||
encodedPrivateKey: encodedPrivateKey,
|
||||
);
|
||||
|
||||
final metadata = await fetchFileMetadata(
|
||||
ndk: ndk,
|
||||
sharedFile: sharedFile,
|
||||
);
|
||||
|
||||
final decryptedBytes = await fetchBlob(ndk: ndk, fileMetadata: metadata);
|
||||
|
||||
_decryptedData.value = decryptedBytes;
|
||||
_metadata.value = metadata;
|
||||
_isLoading.value = false;
|
||||
update();
|
||||
|
||||
// Auto-save after decrypt
|
||||
await saveFile();
|
||||
} catch (e) {
|
||||
_error.value = e.toString();
|
||||
_isLoading.value = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveFile() async {
|
||||
if (_decryptedData.value == null) return;
|
||||
|
||||
try {
|
||||
final mimeType = _metadata.value?.fileType ?? 'application/octet-stream';
|
||||
await FileSaver.instance.saveFile(
|
||||
name: _filename.value ?? 'downloaded_file',
|
||||
bytes: _decryptedData.value!,
|
||||
fileExtension: mimeType.split('/').last,
|
||||
mimeType: MimeType.other,
|
||||
customMimeType: mimeType,
|
||||
);
|
||||
|
||||
Get.snackbar(
|
||||
'Success',
|
||||
'File saved successfully',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
} catch (e) {
|
||||
Get.snackbar(
|
||||
'Error',
|
||||
'Failed to save file: $e',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_decryptedData.value = null;
|
||||
_metadata.value = null;
|
||||
_error.value = null;
|
||||
_hasStarted.value = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_transfer/functions/share_file.dart';
|
||||
import 'package:file_transfer/models/shared_file.dart';
|
||||
import 'package:file_transfer/routes.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
class HomePageController extends GetxController {
|
||||
final _sharedFile = Rxn<SharedFile>();
|
||||
final _isUploading = false.obs;
|
||||
final _error = RxnString();
|
||||
|
||||
SharedFile? get sharedFile => _sharedFile.value;
|
||||
bool get isUploading => _isUploading.value;
|
||||
String? get error => _error.value;
|
||||
|
||||
Future<void> pickAndShareFile() async {
|
||||
_isUploading.value = true;
|
||||
_error.value = null;
|
||||
|
||||
try {
|
||||
final pickResult = await FilePicker.platform.pickFiles(
|
||||
type: FileType.any,
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (pickResult == null || pickResult.files.isEmpty) {
|
||||
_isUploading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final file = pickResult.files.first;
|
||||
if (file.bytes == null) {
|
||||
_error.value = 'Unable to read file data';
|
||||
_isUploading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final mimeType = lookupMimeType(
|
||||
file.name,
|
||||
headerBytes: file.bytes?.take(8).toList(),
|
||||
);
|
||||
|
||||
final sharedFile = await shareFile(
|
||||
bytes: file.bytes!,
|
||||
contentType: mimeType,
|
||||
);
|
||||
|
||||
_sharedFile.value = sharedFile;
|
||||
_isUploading.value = false;
|
||||
} catch (e) {
|
||||
_error.value = e.toString();
|
||||
_isUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void copyToClipboard(String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
Get.snackbar(
|
||||
'Copied',
|
||||
'$label copied to clipboard',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
void copyShareLink() {
|
||||
if (_sharedFile.value == null) return;
|
||||
final link =
|
||||
'https://example.com/f/${_sharedFile.value!.nevent}/${_sharedFile.value!.encodedPrivateKey}';
|
||||
copyToClipboard(link, 'Share link');
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_sharedFile.value = null;
|
||||
_error.value = null;
|
||||
}
|
||||
|
||||
Future<void> pasteAndOpenLink() async {
|
||||
final clipboardData = await Clipboard.getData('text/plain');
|
||||
final link = clipboardData?.text;
|
||||
|
||||
if (link == null || link.isEmpty) {
|
||||
Get.snackbar(
|
||||
'No Link',
|
||||
'No link found in clipboard',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse link: https://example.com/f/:nevent/:nsec
|
||||
try {
|
||||
final uri = Uri.parse(link);
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
if (segments.length >= 3 && segments[0] == 'f') {
|
||||
final nevent = segments[1];
|
||||
final nsec = segments[2];
|
||||
|
||||
// Navigate to file share page with values in URL
|
||||
Get.toNamed(AppRoutes.fileShareRoute(nevent, nsec));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
Get.snackbar(
|
||||
'Invalid Link',
|
||||
'Please copy a valid share link (https://example.com/f/...)',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:ndk/shared/nips/nip01/bip340.dart';
|
||||
|
||||
Future<Nip01Event> createEvent({
|
||||
required Ndk ndk,
|
||||
required BlobDescriptor descriptor,
|
||||
required String recipientPubkey,
|
||||
required String key,
|
||||
required String nonce,
|
||||
}) async {
|
||||
final keyPair = Bip340.generatePrivateKey();
|
||||
final signer = Bip340EventSigner(
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
);
|
||||
|
||||
final rumor = Nip01Event(
|
||||
pubKey: signer.publicKey,
|
||||
kind: 15,
|
||||
tags: [
|
||||
if (descriptor.type != null) ["file-type", descriptor.type!],
|
||||
["encryption-algorithm", "aes-gcm"],
|
||||
["decryption-key", key],
|
||||
["decryption-nonce", nonce],
|
||||
["x", descriptor.sha256],
|
||||
if (descriptor.size != null) ["size", descriptor.size.toString()],
|
||||
],
|
||||
content: descriptor.url,
|
||||
);
|
||||
|
||||
final giftwrap = await ndk.giftWrap.toGiftWrap(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
customSigner: signer,
|
||||
);
|
||||
|
||||
return giftwrap;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
/// Decrypts data using AES-256-GCM
|
||||
/// [encryptedBytes] - the encrypted data
|
||||
/// [key] - base64 encoded encryption key
|
||||
/// [nonce] - base64 encoded nonce (salt)
|
||||
Future<Uint8List> decryptBlob({
|
||||
required Uint8List encryptedBytes,
|
||||
required String key,
|
||||
required String nonce,
|
||||
}) async {
|
||||
// Decode base64 key and nonce
|
||||
final keyBytes = base64Decode(key);
|
||||
final nonceBytes = base64Decode(nonce);
|
||||
|
||||
// Set up AES-GCM parameters
|
||||
final keyParam = KeyParameter(keyBytes);
|
||||
final params = ParametersWithIV(keyParam, nonceBytes);
|
||||
|
||||
// Create and initialize the cipher for decryption (false = decrypt)
|
||||
final aesGcm = GCMBlockCipher(AESEngine());
|
||||
aesGcm.init(false, params);
|
||||
|
||||
// Decrypt the data
|
||||
final decryptedData = aesGcm.process(encryptedBytes);
|
||||
|
||||
return decryptedData;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_transfer/models/encrypted_blob.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
/// Encrypts data using AES-256-GCM
|
||||
/// Returns the encrypted blob with key, nonce, and auth tag
|
||||
Future<EncryptedBlob> encryptBlob(Uint8List bytes) async {
|
||||
// Generate random 256-bit key (32 bytes)
|
||||
final key = _generateRandomBytes(32);
|
||||
|
||||
// Generate random 12-byte nonce (96 bits - recommended for GCM)
|
||||
final nonce = _generateRandomBytes(12);
|
||||
|
||||
// Set up AES-GCM parameters
|
||||
final keyParam = KeyParameter(key);
|
||||
final params = ParametersWithIV(keyParam, nonce);
|
||||
|
||||
// Create and initialize the cipher for encryption (true = encrypt)
|
||||
final aesGcm = GCMBlockCipher(AESEngine());
|
||||
aesGcm.init(true, params);
|
||||
|
||||
// Encrypt the data
|
||||
final encryptedData = aesGcm.process(bytes);
|
||||
|
||||
return EncryptedBlob(
|
||||
bytes: encryptedData,
|
||||
key: base64Encode(key),
|
||||
nonce: base64Encode(nonce),
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List _generateRandomBytes(int length) {
|
||||
final secureRandom = SecureRandom('Fortuna');
|
||||
// Seed with platform entropy
|
||||
final seed = Uint8List(32);
|
||||
for (var i = 0; i < 32; i++) {
|
||||
seed[i] = (DateTime.now().microsecondsSinceEpoch >> (i % 32)) & 0xFF;
|
||||
}
|
||||
secureRandom.seed(KeyParameter(seed));
|
||||
|
||||
return secureRandom.nextBytes(length);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_transfer/constants.dart';
|
||||
import 'package:file_transfer/functions/decrypt_blob.dart';
|
||||
import 'package:file_transfer/models/file_metadata.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
|
||||
Future<Uint8List> fetchBlob({
|
||||
required Ndk ndk,
|
||||
required FileMetadata fileMetadata,
|
||||
}) async {
|
||||
final blobResponse = await ndk.blossom.getBlob(
|
||||
sha256: fileMetadata.x,
|
||||
serverUrls: Constants.blossomServers,
|
||||
);
|
||||
|
||||
final decryptedBytes = await decryptBlob(
|
||||
encryptedBytes: blobResponse.data,
|
||||
key: fileMetadata.key,
|
||||
nonce: fileMetadata.nonce,
|
||||
);
|
||||
|
||||
return decryptedBytes;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:file_transfer/models/file_metadata.dart';
|
||||
import 'package:file_transfer/models/shared_file.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:ndk/shared/nips/nip01/bip340.dart';
|
||||
|
||||
Future<FileMetadata> fetchFileMetadata({
|
||||
required Ndk ndk,
|
||||
required SharedFile sharedFile,
|
||||
}) async {
|
||||
final decodedNevent = Nip19.decodeNevent(sharedFile.nevent);
|
||||
|
||||
final query = ndk.requests.query(
|
||||
filter: Filter(ids: [decodedNevent.eventId]),
|
||||
);
|
||||
|
||||
Nip01Event? giftWrap;
|
||||
await for (var event in query.stream) {
|
||||
if (event.id != decodedNevent.eventId) continue;
|
||||
giftWrap = event;
|
||||
break;
|
||||
}
|
||||
|
||||
if (giftWrap == null) throw Exception("Event not found");
|
||||
|
||||
final privateKey = Nip19.decode(sharedFile.encodedPrivateKey);
|
||||
final publicKey = Bip340.getPublicKey(privateKey);
|
||||
final signer = Bip340EventSigner(
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
);
|
||||
|
||||
final event = await ndk.giftWrap.fromGiftWrap(
|
||||
giftWrap: giftWrap,
|
||||
customSigner: signer,
|
||||
);
|
||||
|
||||
return FileMetadata.fromEvent(event);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:file_transfer/constants.dart';
|
||||
import 'package:file_transfer/functions/create_event.dart';
|
||||
import 'package:file_transfer/functions/encrypt_blob.dart';
|
||||
import 'package:file_transfer/functions/upload_blob.dart';
|
||||
import 'package:file_transfer/models/shared_file.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
import 'package:ndk/shared/nips/nip01/bip340.dart';
|
||||
import 'package:ndk_flutter/ndk_flutter.dart';
|
||||
|
||||
Future<SharedFile> shareFile({
|
||||
required Uint8List bytes,
|
||||
String? contentType,
|
||||
}) async {
|
||||
final ndk = Ndk(
|
||||
NdkConfig(
|
||||
eventVerifier: kIsWeb ? WebEventVerifier() : Bip340EventVerifier(),
|
||||
cache: MemCacheManager(),
|
||||
),
|
||||
);
|
||||
|
||||
final keyPair = Bip340.generatePrivateKey();
|
||||
ndk.accounts.loginPrivateKey(
|
||||
pubkey: keyPair.publicKey,
|
||||
privkey: keyPair.privateKey!,
|
||||
);
|
||||
|
||||
final recipientKeyPair = Bip340.generatePrivateKey();
|
||||
|
||||
final encryptedBlob = await encryptBlob(bytes);
|
||||
|
||||
final blobDescriptor = await uploadBlob(
|
||||
ndk: ndk,
|
||||
data: encryptedBlob.bytes,
|
||||
contentType: contentType,
|
||||
);
|
||||
|
||||
final event = await createEvent(
|
||||
ndk: ndk,
|
||||
descriptor: blobDescriptor!,
|
||||
recipientPubkey: recipientKeyPair.publicKey,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
await ndk.broadcast
|
||||
.broadcast(nostrEvent: event, specificRelays: Constants.relays)
|
||||
.broadcastDoneFuture;
|
||||
|
||||
final nevent = Nip19.encodeNevent(
|
||||
eventId: event.id,
|
||||
relays: Constants.relays,
|
||||
);
|
||||
|
||||
final nsec = Nip19.encodePrivateKey(recipientKeyPair.privateKey!);
|
||||
|
||||
return SharedFile(nevent: nevent, encodedPrivateKey: nsec);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_transfer/constants.dart';
|
||||
import 'package:ndk/ndk.dart';
|
||||
|
||||
Future<BlobDescriptor?> uploadBlob({
|
||||
required Ndk ndk,
|
||||
required Uint8List data,
|
||||
String? contentType,
|
||||
}) async {
|
||||
final serverUrls = Constants.blossomServers;
|
||||
|
||||
final blobUploadResults = await ndk.blossom.uploadBlob(
|
||||
data: data,
|
||||
serverUrls: serverUrls,
|
||||
contentType: contentType,
|
||||
);
|
||||
|
||||
return blobUploadResults.firstWhere((e) => e.success).descriptor;
|
||||
}
|
||||
+24
-15
@@ -1,4 +1,8 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_transfer/controllers/file_share_controller.dart';
|
||||
import 'package:file_transfer/controllers/home_controller.dart';
|
||||
import 'package:file_transfer/pages/file_share_page.dart';
|
||||
import 'package:file_transfer/pages/home_page.dart';
|
||||
import 'package:file_transfer/routes.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -7,7 +11,7 @@ import 'package:ndk_flutter/ndk_flutter.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
final ndk = Ndk(
|
||||
NdkConfig(
|
||||
eventVerifier: kIsWeb ? WebEventVerifier() : Bip340EventVerifier(),
|
||||
@@ -26,20 +30,25 @@ class MainApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetMaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles();
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
|
||||
},
|
||||
child: Text("data"),
|
||||
),
|
||||
title: 'File Transfer',
|
||||
theme: ThemeData.light(),
|
||||
darkTheme: ThemeData.dark(),
|
||||
getPages: [
|
||||
GetPage(
|
||||
name: AppRoutes.home,
|
||||
page: () => const HomePage(),
|
||||
binding: BindingsBuilder(() {
|
||||
Get.put(HomePageController());
|
||||
}),
|
||||
),
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.fileShare,
|
||||
page: () => const FileSharePage(),
|
||||
binding: BindingsBuilder(() {
|
||||
Get.put(FileShareController());
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class EncryptedBlob {
|
||||
final Uint8List bytes;
|
||||
final String key;
|
||||
final String nonce;
|
||||
|
||||
EncryptedBlob({required this.bytes, required this.key, required this.nonce});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:ndk/ndk.dart';
|
||||
|
||||
class FileMetadata {
|
||||
final String? fileType;
|
||||
final String key;
|
||||
final String nonce;
|
||||
final String x;
|
||||
final String? ox;
|
||||
final int? size;
|
||||
|
||||
FileMetadata({
|
||||
required this.fileType,
|
||||
required this.key,
|
||||
required this.nonce,
|
||||
required this.x,
|
||||
required this.ox,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
factory FileMetadata.fromEvent(Nip01Event event) {
|
||||
return FileMetadata(
|
||||
fileType: event.getFirstTag('file-type'),
|
||||
key: event.getFirstTag('decryption-key') ?? '',
|
||||
nonce: event.getFirstTag('decryption-nonce') ?? '',
|
||||
x: event.getFirstTag('x') ?? '',
|
||||
ox: event.getFirstTag('ox'),
|
||||
size: int.tryParse(event.getFirstTag('size') ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
class SharedFile {
|
||||
final String nevent;
|
||||
final String encodedPrivateKey;
|
||||
|
||||
SharedFile({required this.nevent, required this.encodedPrivateKey});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class LoadingView extends GetView {
|
||||
const LoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSmallScreen = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 16 : 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
SizedBox(height: isSmallScreen ? 12 : 16),
|
||||
Text(
|
||||
'Fetching file metadata...',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 14 : 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'package:file_transfer/controllers/file_share_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ReadyView extends GetView<FileShareController> {
|
||||
const ReadyView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final metadata = controller.metadata;
|
||||
final isSmallScreen = MediaQuery.of(context).size.width < 600;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 16 : 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
Icon(
|
||||
Icons.file_download_rounded,
|
||||
size: isSmallScreen ? 56 : 72,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 20 : 24),
|
||||
Text(
|
||||
'File Ready to Download',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (metadata != null) ...[
|
||||
SizedBox(height: isSmallScreen ? 16 : 24),
|
||||
_buildMetadataCard(
|
||||
context,
|
||||
isSmallScreen,
|
||||
metadata,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
SizedBox(height: isSmallScreen ? 24 : 32),
|
||||
Obx(
|
||||
() => FilledButton.icon(
|
||||
onPressed: controller.hasStarted
|
||||
? null
|
||||
: controller.startDecrypt,
|
||||
icon: controller.hasStarted
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.download),
|
||||
label: Text(
|
||||
controller.hasStarted ? 'Downloading...' : 'Download',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.error != null) ...[
|
||||
SizedBox(height: isSmallScreen ? 12 : 16),
|
||||
_buildErrorBox(context, isSmallScreen, colorScheme),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataCard(
|
||||
BuildContext context,
|
||||
bool isSmallScreen,
|
||||
dynamic metadata,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 12 : 16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (metadata.fileType != null) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Type:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
metadata.fileType!,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
],
|
||||
if (metadata.size != null && metadata.size! > 0) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Size:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatBytes(metadata.size!),
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorBox(
|
||||
BuildContext context,
|
||||
bool isSmallScreen,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 10 : 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
|
||||
SizedBox(width: isSmallScreen ? 8 : 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: isSmallScreen ? 12 : 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:file_transfer/controllers/file_share_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class SuccessView extends GetView<FileShareController> {
|
||||
const SuccessView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final metadata = controller.metadata;
|
||||
final decryptedData = controller.decryptedData!;
|
||||
final isSmallScreen = MediaQuery.of(context).size.width < 600;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 16 : 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: isSmallScreen ? 56 : 72,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 20 : 24),
|
||||
Text(
|
||||
'File downloaded successfully!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 6 : 8),
|
||||
Text(
|
||||
'Size: ${_formatBytes(metadata?.size ?? decryptedData.length)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export 'loading_view.dart';
|
||||
export 'success_view.dart';
|
||||
export 'ready_view.dart';
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:file_transfer/pages/file_share/widgets/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:file_transfer/controllers/file_share_controller.dart';
|
||||
|
||||
class FileSharePage extends GetView<FileShareController> {
|
||||
const FileSharePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Download File')),
|
||||
body: GetBuilder<FileShareController>(
|
||||
builder: (controller) {
|
||||
if (controller.isFetchingMetadata) {
|
||||
return const LoadingView();
|
||||
} else if (controller.decryptedData != null) {
|
||||
return const SuccessView();
|
||||
} else {
|
||||
return ReadyView();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'package:file_transfer/controllers/home_controller.dart';
|
||||
import 'package:file_transfer/models/shared_file.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ShareLinkView extends GetView<HomePageController> {
|
||||
const ShareLinkView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sharedFile = controller.sharedFile!;
|
||||
final isSmallScreen = MediaQuery.of(context).size.width < 600;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Share Link Ready'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: controller.reset,
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 16 : 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: isSmallScreen ? 64 : 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 20 : 24),
|
||||
Text(
|
||||
'File ready to share!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 16 : 24),
|
||||
_buildInfoCard(context, isSmallScreen, sharedFile, colorScheme),
|
||||
SizedBox(height: isSmallScreen ? 16 : 24),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.copyShareLink,
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Copy Share Link'),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 6 : 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.reset,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Share Another File'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
bool isSmallScreen,
|
||||
SharedFile sharedFile,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 12 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
'Share Link:',
|
||||
'https://example.com/f/${sharedFile.nevent}/${sharedFile.encodedPrivateKey}',
|
||||
isSmallScreen,
|
||||
colorScheme,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 12 : 16),
|
||||
_buildInfoRow(
|
||||
'Event (nevent):',
|
||||
sharedFile.nevent,
|
||||
isSmallScreen,
|
||||
colorScheme,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 12 : 16),
|
||||
_buildInfoRow(
|
||||
'Private Key (nsec):',
|
||||
sharedFile.encodedPrivateKey,
|
||||
isSmallScreen,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value,
|
||||
bool isSmallScreen,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isSmallScreen ? 13 : 14,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 6 : 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 11 : 12,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:file_transfer/controllers/home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class UploadView extends GetView<HomePageController> {
|
||||
const UploadView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isSmallScreen = MediaQuery.of(context).size.width < 600;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('File Transfer')),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 16 : 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||
Icon(
|
||||
Icons.upload_file_rounded,
|
||||
size: isSmallScreen ? 64 : 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 20 : 24),
|
||||
Text(
|
||||
'Select a file to share',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: isSmallScreen ? 16 : 18,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 24 : 32),
|
||||
Obx(
|
||||
() => FilledButton.icon(
|
||||
onPressed: controller.isUploading
|
||||
? null
|
||||
: controller.pickAndShareFile,
|
||||
icon: controller.isUploading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: Text(
|
||||
controller.isUploading ? 'Uploading...' : 'Select File',
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: isSmallScreen ? 10 : 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.pasteAndOpenLink,
|
||||
icon: const Icon(Icons.paste),
|
||||
label: const Text('Paste Share Link'),
|
||||
),
|
||||
Obx(() {
|
||||
if (controller.error != null) {
|
||||
return _buildErrorBox(context, isSmallScreen, colorScheme);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorBox(
|
||||
BuildContext context,
|
||||
bool isSmallScreen,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isSmallScreen ? 10 : 12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
|
||||
SizedBox(width: isSmallScreen ? 8 : 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.error!,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: isSmallScreen ? 12 : 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'upload_view.dart';
|
||||
export 'share_link_view.dart';
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:file_transfer/pages/home/widgets/widgets.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:file_transfer/controllers/home_controller.dart';
|
||||
|
||||
class HomePage extends GetView<HomePageController> {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.sharedFile != null) {
|
||||
return const ShareLinkView();
|
||||
}
|
||||
return const UploadView();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
class AppRoutes {
|
||||
static const home = '/';
|
||||
static const fileShare = '/f/:nevent/:encodedPrivateKey';
|
||||
|
||||
static String fileShareRoute(String nevent, String encodedPrivateKey) {
|
||||
return fileShare
|
||||
.replaceFirst(':nevent', nevent)
|
||||
.replaceFirst(':encodedPrivateKey', encodedPrivateKey);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:ndk/ndk.dart';
|
||||
|
||||
class BlossomService {
|
||||
final Ndk _ndk;
|
||||
|
||||
BlossomService(this._ndk);
|
||||
|
||||
/// Upload un blob sur un serveur Blossom en utilisant NDK
|
||||
///
|
||||
/// Retourne les informations du fichier uploadé
|
||||
Future<BlobUploadResult> upload(Uint8List data, {List<String>? servers}) async {
|
||||
final results = await _ndk.blossom.uploadBlob(
|
||||
data: data,
|
||||
serverUrls: servers,
|
||||
);
|
||||
|
||||
if (results.isEmpty) {
|
||||
throw Exception('Upload failed: no results returned');
|
||||
}
|
||||
|
||||
return results.first;
|
||||
}
|
||||
|
||||
/// Upload avec stratégie de miroir
|
||||
Future<List<BlobUploadResult>> uploadWithMirror(
|
||||
Uint8List data, {
|
||||
List<String>? servers,
|
||||
}) async {
|
||||
return await _ndk.blossom.uploadBlob(
|
||||
data: data,
|
||||
serverUrls: servers,
|
||||
strategy: UploadStrategy.mirrorAfterSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// Télécharge un blob par son hash SHA-256
|
||||
Future<Uint8List> downloadByHash(String sha256, {List<String>? servers}) async {
|
||||
final result = await _ndk.blossom.getBlob(
|
||||
sha256: sha256,
|
||||
serverUrls: servers,
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/// Upload sur un serveur spécifique
|
||||
Future<BlobUploadResult> uploadToServer(
|
||||
Uint8List data, {
|
||||
required String serverUrl,
|
||||
}) async {
|
||||
return await upload(data, servers: [serverUrl]);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
class EncryptionService {
|
||||
static final math.Random _random = math.Random.secure();
|
||||
|
||||
/// Chiffre des données avec AES-GCM (256 bits)
|
||||
///
|
||||
/// Retourne un map contenant:
|
||||
/// - encryptedData: les données chiffrées
|
||||
/// - key: la clé utilisée (32 bytes)
|
||||
/// - iv: le vecteur d'initialisation (12 bytes pour GCM)
|
||||
/// - authTag: le tag d'authentification (16 bytes)
|
||||
static Map<String, Uint8List> encrypt(Uint8List data) {
|
||||
// Générer une clé aléatoire 256 bits (32 bytes)
|
||||
final keyBytes = Uint8List(32);
|
||||
for (var i = 0; i < keyBytes.length; i++) {
|
||||
keyBytes[i] = _random.nextInt(256);
|
||||
}
|
||||
|
||||
// Générer un IV aléatoire 96 bits (12 bytes) - recommandé pour GCM
|
||||
final ivBytes = Uint8List(12);
|
||||
for (var i = 0; i < ivBytes.length; i++) {
|
||||
ivBytes[i] = _random.nextInt(256);
|
||||
}
|
||||
|
||||
// Chiffreur AES-GCM
|
||||
final cipher = GCMBlockCipher(AESEngine())
|
||||
..init(
|
||||
true,
|
||||
AEADParameters(
|
||||
KeyParameter(keyBytes),
|
||||
128, // Tag size en bits (16 bytes)
|
||||
ivBytes,
|
||||
Uint8List(0), // Données additionnelles vides
|
||||
),
|
||||
);
|
||||
|
||||
// Chiffrer les données
|
||||
final encryptedData = _processBytes(cipher, data);
|
||||
|
||||
// GCM produit un tag de 16 bytes qui est appendu à la fin
|
||||
// On le sépare pour le retour
|
||||
final authTag = encryptedData.sublist(encryptedData.length - 16);
|
||||
final ciphertext = encryptedData.sublist(0, encryptedData.length - 16);
|
||||
|
||||
return {
|
||||
'encryptedData': ciphertext,
|
||||
'key': keyBytes,
|
||||
'iv': ivBytes,
|
||||
'authTag': authTag,
|
||||
};
|
||||
}
|
||||
|
||||
/// Déchiffre des données avec AES-GCM
|
||||
static Uint8List decrypt({
|
||||
required Uint8List encryptedData,
|
||||
required Uint8List key,
|
||||
required Uint8List iv,
|
||||
required Uint8List authTag,
|
||||
}) {
|
||||
// Combiner ciphertext + authTag pour le déchiffrement
|
||||
final combined = Uint8List(encryptedData.length + authTag.length);
|
||||
combined.setRange(0, encryptedData.length, encryptedData);
|
||||
combined.setRange(encryptedData.length, combined.length, authTag);
|
||||
|
||||
final cipher = GCMBlockCipher(AESEngine())
|
||||
..init(
|
||||
false,
|
||||
AEADParameters(
|
||||
KeyParameter(key),
|
||||
128, // Tag size en bits
|
||||
iv,
|
||||
Uint8List(0), // Données additionnelles vides
|
||||
),
|
||||
);
|
||||
|
||||
return _processBytes(cipher, combined);
|
||||
}
|
||||
|
||||
static Uint8List _processBytes(BlockCipher cipher, Uint8List data) {
|
||||
final out = Uint8List(data.length + cipher.blockSize);
|
||||
var offset = 0;
|
||||
|
||||
for (var i = 0; i < data.length; i += cipher.blockSize) {
|
||||
offset += cipher.processBlock(data, i, out, offset);
|
||||
}
|
||||
|
||||
return out.sublist(0, offset);
|
||||
}
|
||||
|
||||
/// Génère un hash SHA-256 des données
|
||||
static Uint8List hash(Uint8List data) {
|
||||
final digest = sha256.convert(data);
|
||||
return Uint8List.fromList(digest.bytes);
|
||||
}
|
||||
|
||||
/// Convertit Uint8List en hex string
|
||||
static String toHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Convertit hex string en Uint8List
|
||||
static Uint8List fromHex(String hex) {
|
||||
if (hex.length % 2 != 0) {
|
||||
throw ArgumentError('Hex string must have even length');
|
||||
}
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -406,7 +406,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
get: ^4.7.3
|
||||
mime: ^2.0.0
|
||||
ndk: ^0.7.1-dev.19
|
||||
ndk_flutter: ^0.0.2-dev.13
|
||||
nip49: ^1.1.2
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:file_transfer/functions/decrypt_blob.dart';
|
||||
import 'package:file_transfer/functions/encrypt_blob.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('Encrypt/Decrypt Roundtrip Tests', () {
|
||||
test('should encrypt and decrypt small text data', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List.fromList('Hello, World!'.codeUnits);
|
||||
|
||||
// Act
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
final decryptedData = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decryptedData, equals(originalData));
|
||||
expect(String.fromCharCodes(decryptedData), equals('Hello, World!'));
|
||||
});
|
||||
|
||||
test('should encrypt and decrypt binary data (image-like)', () async {
|
||||
// Arrange - create some binary data that looks like a small image
|
||||
final originalData = Uint8List.fromList(
|
||||
List.generate(1024, (i) => i % 256),
|
||||
);
|
||||
|
||||
// Act
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
final decryptedData = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decryptedData, equals(originalData));
|
||||
expect(decryptedData.length, equals(1024));
|
||||
});
|
||||
|
||||
test('should encrypt and decrypt empty data', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List(0);
|
||||
|
||||
// Act
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
final decryptedData = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decryptedData, equals(originalData));
|
||||
expect(decryptedData.isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('should encrypt and decrypt large data', () async {
|
||||
// Arrange - 1MB of random data
|
||||
final originalData = Uint8List.fromList(
|
||||
List.generate(1024 * 1024, (i) => i % 256),
|
||||
);
|
||||
|
||||
// Act
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
final decryptedData = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(decryptedData, equals(originalData));
|
||||
expect(decryptedData.length, equals(1024 * 1024));
|
||||
});
|
||||
|
||||
test('should produce different ciphertext for same plaintext', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List.fromList('Test data'.codeUnits);
|
||||
|
||||
// Act - encrypt twice
|
||||
final encryptedBlob1 = await encryptBlob(originalData);
|
||||
final encryptedBlob2 = await encryptBlob(originalData);
|
||||
|
||||
// Assert - ciphertext should be different due to random key/nonce
|
||||
expect(encryptedBlob1.bytes, isNot(equals(encryptedBlob2.bytes)));
|
||||
expect(encryptedBlob1.key, isNot(equals(encryptedBlob2.key)));
|
||||
expect(encryptedBlob1.nonce, isNot(equals(encryptedBlob2.nonce)));
|
||||
|
||||
// But both should decrypt to the same plaintext
|
||||
final decrypted1 = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob1.bytes,
|
||||
key: encryptedBlob1.key,
|
||||
nonce: encryptedBlob1.nonce,
|
||||
);
|
||||
final decrypted2 = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob2.bytes,
|
||||
key: encryptedBlob2.key,
|
||||
nonce: encryptedBlob2.nonce,
|
||||
);
|
||||
expect(decrypted1, equals(decrypted2));
|
||||
});
|
||||
|
||||
test('should fail decryption with wrong key', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List.fromList('Secret message'.codeUnits);
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
|
||||
// Generate a wrong key
|
||||
final wrongBlob = await encryptBlob(
|
||||
Uint8List.fromList('other'.codeUnits),
|
||||
);
|
||||
|
||||
// Act & Assert - should throw exception
|
||||
expect(
|
||||
() => decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: wrongBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail decryption with wrong nonce', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List.fromList('Secret message'.codeUnits);
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
|
||||
// Generate a wrong nonce
|
||||
final wrongBlob = await encryptBlob(
|
||||
Uint8List.fromList('other'.codeUnits),
|
||||
);
|
||||
|
||||
// Act & Assert - should throw exception
|
||||
expect(
|
||||
() => decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: wrongBlob.nonce,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should fail decryption with tampered ciphertext', () async {
|
||||
// Arrange
|
||||
final originalData = Uint8List.fromList('Secret message'.codeUnits);
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
|
||||
// Tamper with the ciphertext
|
||||
final tamperedBytes = Uint8List.fromList(encryptedBlob.bytes);
|
||||
if (tamperedBytes.isNotEmpty) {
|
||||
tamperedBytes[0] = (tamperedBytes[0] + 1) % 256;
|
||||
}
|
||||
|
||||
// Act & Assert - should throw exception due to auth tag mismatch
|
||||
expect(
|
||||
() => decryptBlob(
|
||||
encryptedBytes: tamperedBytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle UTF-8 data correctly', () async {
|
||||
// Arrange - UTF-8 text with special characters
|
||||
const originalText = 'Hello 世界!🌍 Привет!';
|
||||
final originalData = utf8.encode(originalText);
|
||||
|
||||
// Act
|
||||
final encryptedBlob = await encryptBlob(originalData);
|
||||
final decryptedData = await decryptBlob(
|
||||
encryptedBytes: encryptedBlob.bytes,
|
||||
key: encryptedBlob.key,
|
||||
nonce: encryptedBlob.nonce,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(utf8.decode(decryptedData), equals(originalText));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user