diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index 4b27c10..7822ab3 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -1060,6 +1060,25 @@ class AccountModule { logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен'); } + Future loginWithToken(String token) async { + await TokenStorage.clearActiveAccount(); + try { + await _api.disconnect(); + } catch (_) {} + + ContactCache.clear(); + TranscriptionCache.clear(); + ChatsModule.resetForAccountSwitch(); + + await _api.connect(); + if (_api.state != SessionState.online) { + throw StateError('loginWithToken: нет соединения с сервером'); + } + + logger.i('Вход по токену: сессия поднята со спуфом, выполняю login'); + return login(token: token); + } + Future switchAccount(int accountId) async { final profile = await AppDatabase.loadProfile(accountId); if (profile == null) { diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 15b037d..ad18758 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -9,6 +9,7 @@ import 'package:komet/l10n/app_localizations.dart'; import 'package:komet/l10n/terms_of_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'code_confirmation_screen.dart'; +import 'token_login_screen.dart'; import 'select_country_screen.dart'; import 'proxy_settings_sheet.dart'; import 'server_settings_sheet.dart'; @@ -654,6 +655,14 @@ class _LoginScreenState extends State { ), onTap: () { Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TokenLoginScreen( + returnToAccountId: widget.returnToAccountId, + ), + ), + ); }, ), ListTile( diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart new file mode 100644 index 0000000..77ef864 --- /dev/null +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../core/storage/spoofing_service.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../../main.dart'; +import '../../../models/spoof_profile.dart'; +import '../../widgets/adaptive_shell.dart'; +import '../../widgets/custom_notification.dart'; +import '../../widgets/section_header.dart'; + +class TokenLoginScreen extends StatefulWidget { + final int? returnToAccountId; + + const TokenLoginScreen({super.key, this.returnToAccountId}); + + @override + State createState() => _TokenLoginScreenState(); +} + +class _TokenLoginScreenState extends State { + final _tokenController = TextEditingController(); + final _deviceNameController = TextEditingController(); + final _osVersionController = TextEditingController(); + final _screenController = TextEditingController(); + final _timezoneController = TextEditingController(); + final _localeController = TextEditingController(); + final _deviceLocaleController = TextEditingController(); + final _deviceIdController = TextEditingController(); + final _appVersionController = TextEditingController( + text: SpoofingService.hardcodedAppVersion, + ); + final _buildNumberController = TextEditingController( + text: '${SpoofingService.hardcodedBuildNumber}', + ); + final _pushDeviceTypeController = TextEditingController(text: 'GCM'); + final _instanceIdController = TextEditingController(); + final _clientSessionIdController = TextEditingController(); + final _userAgentController = TextEditingController(); + + String _selectedDeviceType = 'ANDROID'; + String _selectedArch = 'arm64-v8a'; + bool _isLoading = false; + + @override + void dispose() { + _tokenController.dispose(); + _deviceNameController.dispose(); + _osVersionController.dispose(); + _screenController.dispose(); + _timezoneController.dispose(); + _localeController.dispose(); + _deviceLocaleController.dispose(); + _deviceIdController.dispose(); + _appVersionController.dispose(); + _buildNumberController.dispose(); + _pushDeviceTypeController.dispose(); + _instanceIdController.dispose(); + _clientSessionIdController.dispose(); + _userAgentController.dispose(); + super.dispose(); + } + + bool get _isValid => + _tokenController.text.trim().isNotEmpty && + _deviceNameController.text.trim().isNotEmpty && + _osVersionController.text.trim().isNotEmpty && + _deviceIdController.text.trim().isNotEmpty; + + Future _login() async { + final l10n = AppLocalizations.of(context)!; + if (!_isValid) { + showCustomNotification(context, l10n.tokenLoginError); + return; + } + + setState(() => _isLoading = true); + + final profile = SpoofProfile( + enabled: true, + deviceName: _deviceNameController.text.trim(), + osVersion: _osVersionController.text.trim(), + screen: _screenController.text.trim(), + timezone: _timezoneController.text.trim(), + locale: _localeController.text.trim(), + deviceLocale: _deviceLocaleController.text.trim(), + deviceId: _deviceIdController.text.trim(), + deviceType: _selectedDeviceType, + arch: _selectedArch, + appVersion: _appVersionController.text.trim(), + buildNumber: int.tryParse(_buildNumberController.text.trim()) ?? + SpoofingService.hardcodedBuildNumber, + pushDeviceType: _pushDeviceTypeController.text.trim(), + instanceId: _instanceIdController.text.trim(), + clientSessionId: int.tryParse(_clientSessionIdController.text.trim()), + userAgent: _userAgentController.text.trim(), + ); + + try { + await SpoofingService.saveProfile(SpoofingService.pendingScope, profile); + await accountModule.loginWithToken(_tokenController.text.trim()); + if (!mounted) return; + await Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const AdaptiveShell()), + (route) => false, + ); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + showCustomNotification(context, '${l10n.tokenLoginFailed}: $e'); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(l10n.tokenLoginTitle), centerTitle: true), + body: AbsorbPointer( + absorbing: _isLoading, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildNoteCard(l10n), + const SizedBox(height: 16), + _buildTokenCard(l10n), + const SizedBox(height: 16), + _buildDeviceCard(l10n), + ], + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FilledButton( + onPressed: _isLoading ? null : _login, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.tokenLoginButton), + ), + ), + ); + } + + Widget _buildNoteCard(AppLocalizations l10n) { + final cs = Theme.of(context).colorScheme; + return Card( + color: cs.secondaryContainer.withValues(alpha: 0.5), + elevation: 0, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Symbols.warning, size: 20, color: cs.onSecondaryContainer), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.tokenLoginNote, + style: TextStyle(fontSize: 13, color: cs.onSecondaryContainer), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTokenCard(AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _tokenController, + minLines: 1, + maxLines: 3, + onChanged: (_) => setState(() {}), + decoration: _decoration(l10n.tokenLoginTokenLabel, Symbols.key), + ), + ), + ); + } + + Widget _buildDeviceCard(AppLocalizations l10n) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionHeader( + l10n.spoofMainSectionTitle, + padding: const EdgeInsets.only(bottom: 16, top: 4), + fontSize: 20, + ), + Text(l10n.spoofDeviceTypeTitle), + const SizedBox(height: 8), + _chips( + const [ + _Opt('ANDROID', 'Android', Symbols.android), + _Opt('IOS', 'iOS', Symbols.phone_iphone), + ], + _selectedDeviceType, + (v) => setState(() => _selectedDeviceType = v), + ), + const SizedBox(height: 16), + _field(_deviceNameController, l10n.spoofFieldDeviceName, + Symbols.smartphone), + const SizedBox(height: 16), + _field(_osVersionController, l10n.spoofFieldOsVersion, + Symbols.layers), + const SizedBox(height: 16), + _field(_screenController, l10n.spoofFieldScreen, Symbols.fullscreen), + const SizedBox(height: 16), + _field(_timezoneController, l10n.spoofFieldTimezone, Symbols.public), + const SizedBox(height: 16), + _field(_localeController, l10n.spoofFieldLocale, Symbols.language), + const SizedBox(height: 16), + _field(_deviceLocaleController, l10n.spoofFieldDeviceLocale, + Symbols.translate), + const SizedBox(height: 24), + SectionHeader( + l10n.spoofIdentifiersSectionTitle, + padding: const EdgeInsets.only(bottom: 16, top: 4), + fontSize: 20, + ), + _field(_deviceIdController, l10n.spoofFieldDeviceId, Symbols.tag, + onChanged: true), + const SizedBox(height: 16), + _field(_instanceIdController, l10n.spoofFieldInstanceId, + Symbols.fingerprint), + const SizedBox(height: 16), + _field(_clientSessionIdController, l10n.spoofFieldClientSessionId, + Symbols.vpn_key, + number: true), + const SizedBox(height: 16), + _field(_appVersionController, l10n.spoofFieldAppVersion, + Symbols.info), + const SizedBox(height: 16), + _field(_buildNumberController, l10n.spoofFieldBuildNumber, + Symbols.numbers, + number: true), + const SizedBox(height: 16), + _field(_pushDeviceTypeController, l10n.spoofFieldPushDeviceType, + Symbols.notifications), + const SizedBox(height: 16), + Text(l10n.spoofFieldArchitecture), + const SizedBox(height: 8), + _chips( + const [ + _Opt('arm64-v8a', 'arm64-v8a', Symbols.memory), + _Opt('armeabi-v7a', 'armeabi-v7a', Symbols.memory), + _Opt('arm64', 'arm64', Symbols.memory), + _Opt('x86_64', 'x86_64', Symbols.memory), + _Opt('x86', 'x86', Symbols.memory), + ], + _selectedArch, + (v) => setState(() => _selectedArch = v), + ), + ], + ), + ), + ); + } + + Widget _field( + TextEditingController controller, + String label, + IconData icon, { + bool number = false, + bool onChanged = false, + }) { + return TextField( + controller: controller, + keyboardType: number ? TextInputType.number : null, + onChanged: onChanged ? (_) => setState(() {}) : null, + decoration: _decoration(label, icon), + ); + } + + InputDecoration _decoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } + + Widget _chips( + List<_Opt> options, + String selected, + ValueChanged onSelected, + ) { + final cs = Theme.of(context).colorScheme; + return Wrap( + spacing: 8, + runSpacing: 8, + children: options.map((opt) { + final isSelected = opt.value == selected; + return ChoiceChip( + label: Text(opt.label), + avatar: isSelected + ? Icon(Icons.check, size: 18, color: cs.onSecondaryContainer) + : Icon(opt.icon, size: 18, color: cs.onSurfaceVariant), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onSelected(opt.value), + backgroundColor: cs.surfaceContainerHighest, + selectedColor: cs.secondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : cs.outlineVariant, + ), + ); + }).toList(), + ); + } +} + +class _Opt { + final String value; + final String label; + final IconData icon; + + const _Opt(this.value, this.label, this.icon); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b7323e5..e00dab0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -25,6 +25,12 @@ "serverReconnectFailed": "Could not connect to the server", "loginSignInWithQr": "Sign in with QR code", "loginSignInWithToken": "Sign in with token", + "tokenLoginTitle": "Token login", + "tokenLoginTokenLabel": "Token", + "tokenLoginNote": "Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.", + "tokenLoginButton": "Sign in", + "tokenLoginError": "Fill in the token, device name, OS version and Device ID", + "tokenLoginFailed": "Sign in failed", "loginSignInWithSessionFile": "Sign in with session file", "loginLanguage": "Language", "languageNameRu": "Русский", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6f8ded8..fe413a5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -248,6 +248,42 @@ abstract class AppLocalizations { /// **'Sign in with token'** String get loginSignInWithToken; + /// No description provided for @tokenLoginTitle. + /// + /// In en, this message translates to: + /// **'Token login'** + String get tokenLoginTitle; + + /// No description provided for @tokenLoginTokenLabel. + /// + /// In en, this message translates to: + /// **'Token'** + String get tokenLoginTokenLabel; + + /// No description provided for @tokenLoginNote. + /// + /// In en, this message translates to: + /// **'Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.'** + String get tokenLoginNote; + + /// No description provided for @tokenLoginButton. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get tokenLoginButton; + + /// No description provided for @tokenLoginError. + /// + /// In en, this message translates to: + /// **'Fill in the token, device name, OS version and Device ID'** + String get tokenLoginError; + + /// No description provided for @tokenLoginFailed. + /// + /// In en, this message translates to: + /// **'Sign in failed'** + String get tokenLoginFailed; + /// No description provided for @loginSignInWithSessionFile. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index edd8f07..d7de50c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -84,6 +84,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loginSignInWithToken => 'Sign in with token'; + @override + String get tokenLoginTitle => 'Token login'; + + @override + String get tokenLoginTokenLabel => 'Token'; + + @override + String get tokenLoginNote => + 'Token login only works with spoofing. Enter the data of the device the token belongs to, otherwise the account may be banned.'; + + @override + String get tokenLoginButton => 'Sign in'; + + @override + String get tokenLoginError => + 'Fill in the token, device name, OS version and Device ID'; + + @override + String get tokenLoginFailed => 'Sign in failed'; + @override String get loginSignInWithSessionFile => 'Sign in with session file'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b6a58ca..12a2e88 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -86,6 +86,26 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loginSignInWithToken => 'По токену'; + @override + String get tokenLoginTitle => 'Вход по токену'; + + @override + String get tokenLoginTokenLabel => 'Токен'; + + @override + String get tokenLoginNote => + 'Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.'; + + @override + String get tokenLoginButton => 'Войти'; + + @override + String get tokenLoginError => + 'Заполните токен, имя устройства, версию ОС и Device ID'; + + @override + String get tokenLoginFailed => 'Не удалось войти'; + @override String get loginSignInWithSessionFile => 'По файлу сессии'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8e15e71..9bba889 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -25,6 +25,12 @@ "serverReconnectFailed": "Не удалось подключиться к серверу", "loginSignInWithQr": "По QR code", "loginSignInWithToken": "По токену", + "tokenLoginTitle": "Вход по токену", + "tokenLoginTokenLabel": "Токен", + "tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.", + "tokenLoginButton": "Войти", + "tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID", + "tokenLoginFailed": "Не удалось войти", "loginSignInWithSessionFile": "По файлу сессии", "loginLanguage": "Язык", "languageNameRu": "Русский",