feat(auth): вход по токену с обязательным ручным спуфом устройства
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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": "Русский",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'По файлу сессии';
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"serverReconnectFailed": "Не удалось подключиться к серверу",
|
||||
"loginSignInWithQr": "По QR code",
|
||||
"loginSignInWithToken": "По токену",
|
||||
"tokenLoginTitle": "Вход по токену",
|
||||
"tokenLoginTokenLabel": "Токен",
|
||||
"tokenLoginNote": "Вход по токену работает только со спуфом. Укажите данные устройства, к которому привязан токен, иначе аккаунт могут заблокировать.",
|
||||
"tokenLoginButton": "Войти",
|
||||
"tokenLoginError": "Заполните токен, имя устройства, версию ОС и Device ID",
|
||||
"tokenLoginFailed": "Не удалось войти",
|
||||
"loginSignInWithSessionFile": "По файлу сессии",
|
||||
"loginLanguage": "Язык",
|
||||
"languageNameRu": "Русский",
|
||||
|
||||
Reference in New Issue
Block a user