264 lines
9.1 KiB
Dart
264 lines
9.1 KiB
Dart
import 'dart:convert';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||
import 'package:local_auth/local_auth.dart';
|
||
|
||
import '../../domain/services/crypto_service.dart';
|
||
import '../../domain/services/api_service.dart';
|
||
|
||
class QrScanScreen extends StatefulWidget {
|
||
const QrScanScreen({super.key});
|
||
|
||
@override
|
||
State<QrScanScreen> createState() => _QrScanScreenState();
|
||
}
|
||
|
||
class _QrScanScreenState extends State<QrScanScreen> {
|
||
final _mobileScannerController = MobileScannerController();
|
||
final _localAuth = LocalAuthentication();
|
||
final _cryptoService = CryptoService();
|
||
bool _isProcessing = false;
|
||
String? _statusMessage;
|
||
|
||
@override
|
||
void dispose() {
|
||
_mobileScannerController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _handleQrDetected(BarcodeCapture capture) async {
|
||
if (_isProcessing) return;
|
||
|
||
final barcode = capture.barcodes.firstOrNull;
|
||
if (barcode == null || barcode.rawValue == null) return;
|
||
|
||
final scannedValue = barcode.rawValue!;
|
||
print('Сканирован QR: $scannedValue');
|
||
|
||
try {
|
||
final uri = Uri.parse(scannedValue);
|
||
if (uri.scheme != 'chepuhagram' || !scannedValue.contains('qr_login')) {
|
||
setState(() {
|
||
_statusMessage = 'Неверный формат QR-кода Чепухаграм';
|
||
});
|
||
return;
|
||
}
|
||
|
||
final roomId = uri.queryParameters['room_id'];
|
||
final tempPublicKeyBase64 = uri.queryParameters['public_key']?.replaceAll(' ', '+');
|
||
final connectingDeviceName = uri.queryParameters['device_name'];
|
||
|
||
if (roomId == null || tempPublicKeyBase64 == null) {
|
||
setState(() {
|
||
_statusMessage = 'Отсутствуют параметры сопряжения';
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Нашли валидный QR, останавливаем сканер и начинаем обработку
|
||
setState(() {
|
||
_isProcessing = true;
|
||
_statusMessage = 'Обработка запроса сопряжения...';
|
||
});
|
||
_mobileScannerController.stop();
|
||
|
||
// Запрашиваем подтверждение
|
||
final confirmed = await _showConfirmDialog(context);
|
||
if (!confirmed) {
|
||
_resetScanner();
|
||
return;
|
||
}
|
||
|
||
// Биометрическая аутентификация с ручным фолбеком при ошибке/отсутствии настроек
|
||
bool authenticated = false;
|
||
try {
|
||
authenticated = await _localAuth.authenticate(
|
||
localizedReason: 'Подтвердите личность для сопряжения нового устройства',
|
||
options: const AuthenticationOptions(
|
||
biometricOnly: false,
|
||
useErrorDialogs: true,
|
||
stickyAuth: false,
|
||
),
|
||
);
|
||
} catch (e) {
|
||
print('Исключение local_auth, пробуем ручное подтверждение: $e');
|
||
authenticated = await _showManualConfirmationDialog(context);
|
||
}
|
||
|
||
if (!authenticated) {
|
||
_resetScanner();
|
||
return;
|
||
}
|
||
|
||
// 1. Извлекаем локальный закрытый E2EE-ключ
|
||
final privateKey = await _cryptoService.getPrivateKey();
|
||
if (privateKey == null) {
|
||
throw Exception('Локальный закрытый ключ шифрования не найден');
|
||
}
|
||
|
||
// 2. Генерируем свои временные ключи сопряжения
|
||
final phoneKeyPair = await _cryptoService.generateTempKeyPair();
|
||
final phonePublicKey = await phoneKeyPair.extractPublicKey();
|
||
final phonePublicKeyBase64 = base64Encode(phonePublicKey.bytes);
|
||
|
||
// 3. Шифруем приватный ключ на временном общем секрете
|
||
final encPayload = await _cryptoService.encryptPrivateKeyWithEcdh(
|
||
tempKeyPair: phoneKeyPair,
|
||
tempRemotePublicKeyBase64: tempPublicKeyBase64,
|
||
privateKeyToEncrypt: privateKey,
|
||
);
|
||
|
||
// 4. Отправляем одобрение на сервер
|
||
final apiService = ApiService();
|
||
|
||
final success = await apiService.approveQrLogin(
|
||
roomId: roomId,
|
||
phoneTempPub: phonePublicKeyBase64,
|
||
encPrivateKeyPayload: encPayload,
|
||
deviceName: connectingDeviceName ?? 'Новое устройство',
|
||
);
|
||
|
||
if (success) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Новое устройство успешно авторизовано')),
|
||
);
|
||
Navigator.pop(context);
|
||
}
|
||
} else {
|
||
throw Exception('Сервер отклонил авторизацию сопряжения');
|
||
}
|
||
} catch (e) {
|
||
print(' Ошибка: $e');
|
||
setState(() {
|
||
_statusMessage = 'Ошибка: $e';
|
||
});
|
||
_resetScanner();
|
||
}
|
||
}
|
||
|
||
void _resetScanner() {
|
||
setState(() {
|
||
_isProcessing = false;
|
||
});
|
||
Future.delayed(const Duration(milliseconds: 500), () {
|
||
if (mounted) {
|
||
_mobileScannerController.start();
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<bool> _showConfirmDialog(BuildContext context) async {
|
||
return await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) {
|
||
return AlertDialog(
|
||
title: const Text('Подтверждение входа'),
|
||
content: const Text(
|
||
'Вы действительно хотите войти на новом устройстве? Это перенесет ваши зашифрованные ключи для доступа к чатам.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Войти'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
) ??
|
||
false;
|
||
}
|
||
|
||
Future<bool> _showManualConfirmationDialog(BuildContext context) async {
|
||
return await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (context) {
|
||
return AlertDialog(
|
||
title: const Text('Подтверждение сопряжения'),
|
||
content: const Text(
|
||
'Биометрическая защита или блокировка экрана не настроены на этом телефоне.\n\nВы действительно подтверждаете вход на новом устройстве?',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
child: const Text('Отмена'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||
child: const Text('Подтвердить'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
) ??
|
||
false;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Сканировать QR-код'),
|
||
backgroundColor: Colors.transparent,
|
||
elevation: 0,
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
// Сканер камеры
|
||
MobileScanner(
|
||
controller: _mobileScannerController,
|
||
onDetect: _handleQrDetected,
|
||
),
|
||
|
||
// Полупрозрачная рамка фокуса по центру
|
||
Center(
|
||
child: Container(
|
||
width: 250,
|
||
height: 250,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: colorScheme.primary, width: 3),
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.5),
|
||
spreadRadius: 2000,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// Текстовая подсказка
|
||
Positioned(
|
||
bottom: 60,
|
||
left: 20,
|
||
right: 20,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withOpacity(0.7),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Text(
|
||
_statusMessage ?? 'Наведите камеру на QR-код на новом устройстве',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|