284 lines
10 KiB
Dart
284 lines
10 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:math';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import 'package:qr_flutter/qr_flutter.dart';
|
||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||
import 'package:cryptography/cryptography.dart';
|
||
|
||
import '../../core/constants.dart';
|
||
import '../../domain/services/crypto_service.dart';
|
||
import '../../domain/services/api_service.dart';
|
||
import '../../logic/auth_provider.dart';
|
||
import 'contacts_screen.dart';
|
||
|
||
class QrLoginScreen extends StatefulWidget {
|
||
const QrLoginScreen({super.key});
|
||
|
||
@override
|
||
State<QrLoginScreen> createState() => _QrLoginScreenState();
|
||
}
|
||
|
||
class _QrLoginScreenState extends State<QrLoginScreen> {
|
||
final _cryptoService = CryptoService();
|
||
String? _roomId;
|
||
SimpleKeyPair? _tempKeyPair;
|
||
String? _publicKeyBase64;
|
||
String? _deviceName;
|
||
WebSocketChannel? _channel;
|
||
bool _isInitializing = true;
|
||
String? _errorMessage;
|
||
StreamSubscription? _subscription;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initQrSession();
|
||
}
|
||
|
||
String _generateRandomRoomId() {
|
||
final rand = Random.secure();
|
||
final bytes = List<int>.generate(16, (i) => rand.nextInt(256));
|
||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||
}
|
||
|
||
Future<void> _initQrSession() async {
|
||
try {
|
||
setState(() {
|
||
_isInitializing = true;
|
||
_errorMessage = null;
|
||
});
|
||
|
||
// 1. Генерируем ID комнаты сопряжения
|
||
final roomId = _generateRandomRoomId();
|
||
|
||
// 2. Генерируем временные ключи X25519 для шифрования канала
|
||
final tempKeyPair = await _cryptoService.generateTempKeyPair();
|
||
final publicKey = await tempKeyPair.extractPublicKey();
|
||
final publicKeyBase64 = base64Encode(publicKey.bytes);
|
||
|
||
// Получаем имя текущего устройства
|
||
final apiService = ApiService();
|
||
final deviceName = await apiService.getDeviceName();
|
||
|
||
setState(() {
|
||
_roomId = roomId;
|
||
_tempKeyPair = tempKeyPair;
|
||
_publicKeyBase64 = publicKeyBase64;
|
||
_deviceName = deviceName;
|
||
_isInitializing = false;
|
||
});
|
||
|
||
// 3. Подключаемся к WebSocket серверу сопряжения
|
||
_connectWebSocket(roomId);
|
||
} catch (e) {
|
||
setState(() {
|
||
_isInitializing = false;
|
||
_errorMessage = 'Ошибка инициализации: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
void _connectWebSocket(String roomId) {
|
||
try {
|
||
final wsUri = Uri.parse('${AppConstants.wsUrl}/ws/qr?room_id=$roomId');
|
||
_channel = WebSocketChannel.connect(wsUri);
|
||
|
||
_subscription = _channel!.stream.listen(
|
||
(data) {
|
||
_handleWsMessage(data);
|
||
},
|
||
onError: (error) {
|
||
setState(() {
|
||
_errorMessage = 'Сбой подключения: $error. Переподключение...';
|
||
});
|
||
// Попытка переподключения через 3 секунды
|
||
Future.delayed(const Duration(seconds: 3), () {
|
||
if (mounted && _roomId == roomId) {
|
||
_connectWebSocket(roomId);
|
||
}
|
||
});
|
||
},
|
||
onDone: () {
|
||
print('QR WS соединение закрыто');
|
||
},
|
||
);
|
||
} catch (e) {
|
||
setState(() {
|
||
_errorMessage = 'Ошибка WebSocket: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _handleWsMessage(dynamic rawData) async {
|
||
try {
|
||
final data = jsonDecode(rawData) as Map<String, dynamic>;
|
||
if (data['type'] == 'qr_authorized') {
|
||
final accessToken = data['access_token'] as String;
|
||
final refreshToken = data['refresh_token'] as String;
|
||
final userId = data['user_id'].toString();
|
||
final phoneTempPub = data['phone_temp_pub'] as String;
|
||
final encPrivateKeyPayload = data['enc_private_key_payload'] as String;
|
||
|
||
if (_tempKeyPair == null) return;
|
||
|
||
// Расшифровываем основной E2EE private_key
|
||
final decryptedPrivateKey = await _cryptoService.decryptPrivateKeyWithEcdh(
|
||
tempKeyPair: _tempKeyPair!,
|
||
tempRemotePublicKeyBase64: phoneTempPub,
|
||
encryptedPayloadBase64: encPrivateKeyPayload,
|
||
);
|
||
|
||
// Завершаем авторизацию и сохраняем все данные
|
||
final authProvider = context.read<AuthProvider>();
|
||
await authProvider.loginWithQrData(
|
||
accessToken: accessToken,
|
||
refreshToken: refreshToken,
|
||
userId: userId,
|
||
privateKey: decryptedPrivateKey,
|
||
);
|
||
|
||
if (mounted) {
|
||
// Запускаем WebSocket клиент мессенджера
|
||
await authProvider.initRealtime();
|
||
|
||
// Перенаправляем на главный экран
|
||
Navigator.pushAndRemoveUntil(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const ContactsScreen()),
|
||
(route) => false,
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setState(() {
|
||
_errorMessage = 'Не удалось завершить авторизацию: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_subscription?.cancel();
|
||
_channel?.sink.close();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
|
||
// Содержимое QR-кода
|
||
final encodedPublicKey = Uri.encodeComponent(_publicKeyBase64 ?? '');
|
||
final encodedDeviceName = Uri.encodeComponent(_deviceName ?? 'Новое устройство');
|
||
final qrData = 'chepuhagram:qr_login?room_id=$_roomId&public_key=$encodedPublicKey&device_name=$encodedDeviceName';
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text('Вход по QR-коду', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
||
iconTheme: IconThemeData(color: Theme.of(context).colorScheme.onSurface),
|
||
backgroundColor: Colors.transparent,
|
||
elevation: 0,
|
||
),
|
||
body: Center(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24.0),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 400),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
if (_isInitializing)
|
||
const Column(
|
||
children: [
|
||
CircularProgressIndicator(),
|
||
SizedBox(height: 16),
|
||
Text('Генерация сессии авторизации...'),
|
||
],
|
||
)
|
||
else if (_errorMessage != null && _roomId == null)
|
||
Column(
|
||
children: [
|
||
Icon(Icons.error_outline, size: 60, color: colorScheme.error),
|
||
const SizedBox(height: 16),
|
||
Text(_errorMessage!, textAlign: TextAlign.center),
|
||
const SizedBox(height: 24),
|
||
ElevatedButton(
|
||
onPressed: _initQrSession,
|
||
child: const Text('Повторить попытку'),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Column(
|
||
children: [
|
||
Text(
|
||
'Войти в Чепухаграм',
|
||
style: TextStyle(
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.bold,
|
||
color: colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
const Text(
|
||
'Откройте Чепухаграм на вашем телефоне, зайдите в Настройки -> Устройства -> Подключить устройство и отсканируйте код.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
||
),
|
||
const SizedBox(height: 36),
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(24),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.1),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, 10),
|
||
),
|
||
],
|
||
),
|
||
child: QrImageView(
|
||
data: qrData,
|
||
version: QrVersions.auto,
|
||
size: 260.0,
|
||
gapless: false,
|
||
),
|
||
),
|
||
const SizedBox(height: 36),
|
||
if (_errorMessage != null)
|
||
Text(
|
||
_errorMessage!,
|
||
style: TextStyle(color: colorScheme.error, fontSize: 13),
|
||
textAlign: TextAlign.center,
|
||
)
|
||
else
|
||
const Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
height: 16,
|
||
width: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: 12),
|
||
Text(
|
||
'Ожидание сканирования...',
|
||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|