Chepuhagram/lib/presentation/screens/qr_login_screen.dart

284 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
],
),
],
),
],
),
),
),
),
);
}
}