feat(auth): вход по токену с обязательным ручным спуфом устройства

This commit is contained in:
klockky
2026-06-21 09:23:34 +00:00
parent d96499d88d
commit f20a818825
8 changed files with 453 additions and 0 deletions
+19
View File
@@ -1060,6 +1060,25 @@ class AccountModule {
logger.i('Добавление аккаунта: сессия сброшена, активный аккаунт очищен');
}
Future<LoginResult> 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<ProfileData> switchAccount(int accountId) async {
final profile = await AppDatabase.loadProfile(accountId);
if (profile == null) {
@@ -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<LoginScreen> {
),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => TokenLoginScreen(
returnToAccountId: widget.returnToAccountId,
),
),
);
},
),
ListTile(
@@ -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<TokenLoginScreen> createState() => _TokenLoginScreenState();
}
class _TokenLoginScreenState extends State<TokenLoginScreen> {
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<void> _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<String> 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);
}
+6
View File
@@ -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": "Русский",
+36
View File
@@ -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:
+20
View File
@@ -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';
+20
View File
@@ -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 => 'По файлу сессии';
+6
View File
@@ -25,6 +25,12 @@
"serverReconnectFailed": "Не удалось подключиться к серверу",
"loginSignInWithQr": "По QR code",
"loginSignInWithToken": "По токену",
"tokenLoginTitle": "Вход по токену",
"tokenLoginTokenLabel": "Токен",
"tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.",
"tokenLoginButton": "Войти",
"tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID",
"tokenLoginFailed": "Не удалось войти",
"loginSignInWithSessionFile": "По файлу сессии",
"loginLanguage": "Язык",
"languageNameRu": "Русский",