feat: initial file transfer implementation

This commit is contained in:
Gringo
2026-02-28 23:08:47 +01:00
parent e5a0d746e5
commit 8bdf2100f1
28 changed files with 1353 additions and 185 deletions
+151
View File
@@ -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;
}
}
+116
View File
@@ -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,
);
}
}
+38
View File
@@ -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;
}
+31
View File
@@ -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;
}
+44
View File
@@ -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);
}
+24
View File
@@ -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;
}
+38
View File
@@ -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);
}
+58
View File
@@ -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);
}
+20
View File
@@ -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
View File
@@ -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());
}),
),
],
);
}
}
+9
View File
@@ -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});
}
+30
View File
@@ -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') ?? ''),
);
}
}
+6
View File
@@ -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';
+26
View File
@@ -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();
}
},
),
);
}
}
+137
View File
@@ -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,
),
],
);
}
}
+104
View File
@@ -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,
),
),
),
],
),
);
}
}
+2
View File
@@ -0,0 +1,2 @@
export 'upload_view.dart';
export 'share_link_view.dart';
+18
View File
@@ -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();
});
}
}
+10
View File
@@ -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);
}
}
-53
View File
@@ -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]);
}
}
-116
View File
@@ -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
View File
@@ -406,7 +406,7 @@ packages:
source: hosted
version: "1.17.0"
mime:
dependency: transitive
dependency: "direct main"
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
+1
View File
@@ -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
+190
View File
@@ -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));
});
});
}