feat(spoof): пер-аккаунтный спуф с тумблером и прокидыванием в веб-аппы
- спуф теперь свой на каждый аккаунт, вмораживается при входе - тумблер вкл/выкл в настройках (по умолчанию выключен) - новый аккаунт добавляется со своим включённым спуфом (другое устройство) - UA спуфа прокинут в Сферум, Цифровой ID (вебвью + HTTP) - генерация выбирает Android или iOS; iOS отдаёт deviceType IOS - экран спуфа показывает текущую личность, а не свежесгенерированную
This commit is contained in:
@@ -139,3 +139,11 @@ komet.txt
|
||||
original_app.txt
|
||||
fingerprint.py
|
||||
PCAPdroid_*.txt
|
||||
|
||||
# Локальные рабочие файлы и эксперименты (не для репозитория)
|
||||
PR_FullStack.md
|
||||
docs/
|
||||
maxmint/
|
||||
maxtun/
|
||||
turnprobe/
|
||||
test/live_server_probe_test.dart
|
||||
|
||||
@@ -201,6 +201,7 @@ class Api {
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
deviceType = 'IOS';
|
||||
osVersion = iosInfo.systemVersion;
|
||||
deviceName = iosInfo.utsname.machine;
|
||||
} else if (Platform.isAndroid) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../core/protocol/chat_cache_fingerprint.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/spoofing_service.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import 'chats.dart';
|
||||
@@ -883,6 +884,7 @@ class AccountModule {
|
||||
if (sessionToken != null && accountId != null) {
|
||||
await TokenStorage.saveToken(sessionToken, accountId);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
await SpoofingService.commitPendingSpoof(accountId);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -938,6 +940,7 @@ class AccountModule {
|
||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(profile, isActive: true);
|
||||
await TokenStorage.setActiveAccount(accountId);
|
||||
await SpoofingService.commitPendingSpoof(accountId);
|
||||
|
||||
logger.i('Регистрация завершена, accountId=$accountId');
|
||||
return accountId;
|
||||
@@ -992,6 +995,7 @@ class AccountModule {
|
||||
}
|
||||
await TokenStorage.saveToken(authToken, resolvedAccountId);
|
||||
await TokenStorage.setActiveAccount(resolvedAccountId);
|
||||
await SpoofingService.commitPendingSpoof(resolvedAccountId);
|
||||
}
|
||||
|
||||
final result = await _processLoginResponse(dataMap, resolvedAccountId);
|
||||
@@ -1038,6 +1042,11 @@ class AccountModule {
|
||||
}
|
||||
|
||||
Future<void> beginAddAccount() async {
|
||||
final existing = await AppDatabase.loadAllProfiles();
|
||||
await SpoofingService.prepareNewAccountSpoof(
|
||||
existing.map((p) => p.id).toList(growable: false),
|
||||
);
|
||||
|
||||
try {
|
||||
await _api.disconnect();
|
||||
} catch (_) {}
|
||||
@@ -1088,6 +1097,7 @@ class AccountModule {
|
||||
Future<void> removeAccount(int accountId) async {
|
||||
await AppDatabase.deleteAccount(accountId);
|
||||
await TokenStorage.deleteAccount(accountId);
|
||||
await SpoofingService.clearAccountSpoof(accountId);
|
||||
logger.i('Аккаунт $accountId удалён локально');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:math';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/spoofing_service.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../models/digital_id.dart';
|
||||
import 'webapp.dart';
|
||||
@@ -33,6 +34,7 @@ class DigitalIdModule {
|
||||
|
||||
String? _webAppData;
|
||||
String? _deviceId;
|
||||
String? _realUserAgent;
|
||||
|
||||
DigitalIdModule(this._webApp);
|
||||
|
||||
@@ -94,6 +96,33 @@ class DigitalIdModule {
|
||||
return hex;
|
||||
}
|
||||
|
||||
Future<String> _resolveUserAgent() async {
|
||||
final spoofed = await SpoofingService.getWebViewUserAgent();
|
||||
if (spoofed != null && spoofed.isNotEmpty) return spoofed;
|
||||
return _realUserAgent ??= await _buildRealUserAgent();
|
||||
}
|
||||
|
||||
Future<String> _buildRealUserAgent() async {
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final android = await info.androidInfo;
|
||||
return 'Mozilla/5.0 (Linux; Android ${android.version.release}; '
|
||||
'${android.model}) AppleWebKit/537.36 (KHTML, like Gecko) '
|
||||
'Chrome/124.0.0.0 Mobile Safari/537.36';
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
final ios = await info.iosInfo;
|
||||
final version = ios.systemVersion.replaceAll('.', '_');
|
||||
return 'Mozilla/5.0 (iPhone; CPU iPhone OS $version like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 '
|
||||
'Mobile/15E148 Safari/604.1';
|
||||
}
|
||||
} catch (_) {}
|
||||
return 'Mozilla/5.0 (Linux; Android 14; K) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36';
|
||||
}
|
||||
|
||||
Future<dynamic> _send(
|
||||
String method,
|
||||
String path, {
|
||||
@@ -108,11 +137,7 @@ class DigitalIdModule {
|
||||
request.headers.set('Referer', 'https://digital-id.max.ru/');
|
||||
request.headers.set('x-requested-with', 'ru.oneme.app');
|
||||
request.headers.set('Accept', 'application/json');
|
||||
request.headers.set(
|
||||
'User-Agent',
|
||||
'Mozilla/5.0 (Linux; Android 16; Pixel 7 Pro) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Version/4.0 Chrome/148.0.0.0 Mobile Safari/537.36',
|
||||
);
|
||||
request.headers.set('User-Agent', await _resolveUserAgent());
|
||||
if (body != null) {
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.add(utf8.encode(jsonEncode(body)));
|
||||
|
||||
@@ -1,30 +1,239 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/device_presets.dart';
|
||||
import '../../models/spoof_profile.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
class SpoofingService {
|
||||
static const String hardcodedAppVersion = '26.17.1';
|
||||
static const int hardcodedBuildNumber = 6712;
|
||||
static const String pendingScope = 'pending';
|
||||
|
||||
static const String _legacyEnabledKey = 'spoofing_enabled';
|
||||
static const List<String> _legacyKeys = [
|
||||
'spoofing_enabled',
|
||||
'spoof_devicename',
|
||||
'spoof_osversion',
|
||||
'spoof_screen',
|
||||
'spoof_timezone',
|
||||
'spoof_locale',
|
||||
'spoof_devicelocale',
|
||||
'spoof_deviceid',
|
||||
'spoof_devicetype',
|
||||
'spoof_arch',
|
||||
'spoof_appversion',
|
||||
'spoof_buildnumber',
|
||||
'spoof_pushdevicetype',
|
||||
'spoof_instanceid',
|
||||
'spoof_clientsessionid',
|
||||
'spoof_useragent',
|
||||
];
|
||||
|
||||
static final Random _rng = Random.secure();
|
||||
|
||||
static String _profileKey(String scope) => 'spoof_profile_$scope';
|
||||
|
||||
static Future<String> activeScope() async {
|
||||
final id = await TokenStorage.getActiveAccountId();
|
||||
return id?.toString() ?? pendingScope;
|
||||
}
|
||||
|
||||
static Future<SpoofProfile?> loadProfile(String scope) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _read(prefs, scope);
|
||||
}
|
||||
|
||||
static Future<void> saveProfile(String scope, SpoofProfile profile) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_profileKey(scope), jsonEncode(profile.toJson()));
|
||||
}
|
||||
|
||||
static Future<void> clearAccountSpoof(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_profileKey('$accountId'));
|
||||
}
|
||||
|
||||
static Future<void> commitPendingSpoof(int accountId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final pending = await _read(prefs, pendingScope);
|
||||
if (pending == null) return;
|
||||
await prefs.setString(
|
||||
_profileKey('$accountId'),
|
||||
jsonEncode(pending.toJson()),
|
||||
);
|
||||
await prefs.remove(_profileKey(pendingScope));
|
||||
}
|
||||
|
||||
static Future<SpoofProfile> prepareNewAccountSpoof(
|
||||
List<int> existingAccountIds,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final used = <String>{};
|
||||
for (final id in existingAccountIds) {
|
||||
final existing = await _read(prefs, '$id');
|
||||
if (existing != null && existing.deviceName.isNotEmpty) {
|
||||
used.add(existing.deviceName);
|
||||
}
|
||||
}
|
||||
|
||||
bool isMobile(DevicePreset p) =>
|
||||
p.deviceType == 'ANDROID' || p.deviceType == 'IOS';
|
||||
final fresh = devicePresets
|
||||
.where((p) => isMobile(p) && !used.contains(p.deviceName))
|
||||
.toList();
|
||||
final pool = fresh.isNotEmpty ? fresh : devicePresets.where(isMobile).toList();
|
||||
final preset = pool[_rng.nextInt(pool.length)];
|
||||
final shortLocale = preset.locale.split(RegExp(r'[-_]')).first;
|
||||
|
||||
final profile = SpoofProfile(
|
||||
enabled: true,
|
||||
deviceName: preset.deviceName,
|
||||
osVersion: preset.osVersion,
|
||||
screen: preset.screen,
|
||||
timezone: preset.timezone,
|
||||
locale: shortLocale,
|
||||
deviceLocale: shortLocale,
|
||||
deviceId: _hex(8),
|
||||
deviceType: preset.deviceType,
|
||||
arch: preset.deviceType == 'IOS' ? 'arm64' : 'arm64-v8a',
|
||||
appVersion: hardcodedAppVersion,
|
||||
buildNumber: hardcodedBuildNumber,
|
||||
pushDeviceType: 'GCM',
|
||||
instanceId: _uuidV4(),
|
||||
clientSessionId: _rng.nextInt(0x7FFFFFFF) + 1,
|
||||
userAgent: preset.userAgent,
|
||||
);
|
||||
|
||||
await prefs.setString(
|
||||
_profileKey(pendingScope),
|
||||
jsonEncode(profile.toJson()),
|
||||
);
|
||||
return profile;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final isEnabled = prefs.getBool('spoofing_enabled') ?? false;
|
||||
if (!isEnabled) return null;
|
||||
final profile = await _read(prefs, await activeScope());
|
||||
if (profile == null || !profile.enabled) return null;
|
||||
|
||||
return {
|
||||
'device_name': prefs.getString('spoof_devicename'),
|
||||
'os_version': prefs.getString('spoof_osversion'),
|
||||
'screen': prefs.getString('spoof_screen'),
|
||||
'timezone': prefs.getString('spoof_timezone'),
|
||||
'locale': prefs.getString('spoof_locale'),
|
||||
'device_locale': prefs.getString('spoof_devicelocale'),
|
||||
'device_id': prefs.getString('spoof_deviceid'),
|
||||
'device_type': prefs.getString('spoof_devicetype'),
|
||||
'app_version': prefs.getString('spoof_appversion') ?? hardcodedAppVersion,
|
||||
'arch': prefs.getString('spoof_arch') ?? 'arm64-v8a',
|
||||
'build_number': prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber,
|
||||
'instance_id': prefs.getString('spoof_instanceid'),
|
||||
'client_session_id': prefs.getInt('spoof_clientsessionid'),
|
||||
'push_device_type': prefs.getString('spoof_pushdevicetype'),
|
||||
'device_name': profile.deviceName,
|
||||
'os_version': profile.osVersion,
|
||||
'screen': profile.screen,
|
||||
'timezone': profile.timezone,
|
||||
'locale': profile.locale,
|
||||
'device_locale': profile.deviceLocale,
|
||||
'device_id': profile.deviceId,
|
||||
'device_type': profile.deviceType,
|
||||
'app_version':
|
||||
profile.appVersion.isEmpty ? hardcodedAppVersion : profile.appVersion,
|
||||
'arch': profile.arch.isEmpty ? 'arm64-v8a' : profile.arch,
|
||||
'build_number':
|
||||
profile.buildNumber == 0 ? hardcodedBuildNumber : profile.buildNumber,
|
||||
'instance_id': profile.instanceId,
|
||||
'client_session_id': profile.clientSessionId,
|
||||
'push_device_type': profile.pushDeviceType,
|
||||
};
|
||||
}
|
||||
|
||||
static Future<String?> getWebViewUserAgent() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final profile = await _read(prefs, await activeScope());
|
||||
if (profile == null || !profile.enabled) return null;
|
||||
|
||||
if (profile.userAgent.isNotEmpty) return profile.userAgent;
|
||||
for (final preset in devicePresets) {
|
||||
if (preset.deviceName == profile.deviceName) return preset.userAgent;
|
||||
}
|
||||
return _deriveUserAgent(profile);
|
||||
}
|
||||
|
||||
static String _deriveUserAgent(SpoofProfile profile) {
|
||||
final deviceType =
|
||||
profile.deviceType.isEmpty ? 'ANDROID' : profile.deviceType;
|
||||
final osVersion = profile.osVersion;
|
||||
final model = profile.deviceName.isEmpty ? 'K' : profile.deviceName;
|
||||
|
||||
if (deviceType == 'IOS' || deviceType == 'iOS') {
|
||||
final version =
|
||||
osVersion.replaceAll(RegExp(r'[^0-9.]'), '').replaceAll('.', '_');
|
||||
return 'Mozilla/5.0 (iPhone; CPU iPhone OS '
|
||||
'${version.isEmpty ? '17_0' : version} like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 '
|
||||
'Mobile/15E148 Safari/604.1';
|
||||
}
|
||||
|
||||
final android = osVersion.isEmpty ? 'Android 14' : osVersion;
|
||||
return 'Mozilla/5.0 (Linux; $android; $model) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36';
|
||||
}
|
||||
|
||||
static Future<SpoofProfile?> _read(
|
||||
SharedPreferences prefs,
|
||||
String scope,
|
||||
) async {
|
||||
final raw = prefs.getString(_profileKey(scope));
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
return SpoofProfile.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (scope != pendingScope) {
|
||||
return _migrateLegacy(prefs, scope);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<SpoofProfile?> _migrateLegacy(
|
||||
SharedPreferences prefs,
|
||||
String scope,
|
||||
) async {
|
||||
if (!(prefs.getBool(_legacyEnabledKey) ?? false)) return null;
|
||||
|
||||
final profile = SpoofProfile(
|
||||
enabled: true,
|
||||
deviceName: prefs.getString('spoof_devicename') ?? '',
|
||||
osVersion: prefs.getString('spoof_osversion') ?? '',
|
||||
screen: prefs.getString('spoof_screen') ?? '',
|
||||
timezone: prefs.getString('spoof_timezone') ?? '',
|
||||
locale: prefs.getString('spoof_locale') ?? '',
|
||||
deviceLocale: prefs.getString('spoof_devicelocale') ?? '',
|
||||
deviceId: prefs.getString('spoof_deviceid') ?? '',
|
||||
deviceType: prefs.getString('spoof_devicetype') ?? 'ANDROID',
|
||||
arch: prefs.getString('spoof_arch') ?? 'arm64-v8a',
|
||||
appVersion: prefs.getString('spoof_appversion') ?? hardcodedAppVersion,
|
||||
buildNumber: prefs.getInt('spoof_buildnumber') ?? hardcodedBuildNumber,
|
||||
pushDeviceType: prefs.getString('spoof_pushdevicetype') ?? 'GCM',
|
||||
instanceId: prefs.getString('spoof_instanceid') ?? '',
|
||||
clientSessionId: prefs.getInt('spoof_clientsessionid'),
|
||||
userAgent: prefs.getString('spoof_useragent') ?? '',
|
||||
);
|
||||
|
||||
await prefs.setString(_profileKey(scope), jsonEncode(profile.toJson()));
|
||||
for (final key in _legacyKeys) {
|
||||
await prefs.remove(key);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
static String _hex(int bytes) {
|
||||
final sb = StringBuffer();
|
||||
for (var i = 0; i < bytes; i++) {
|
||||
sb.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0'));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static String _uuidV4() {
|
||||
final b = List<int>.generate(16, (_) => _rng.nextInt(256));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
String h(int i) => b[i].toRadixString(16).padLeft(2, '0');
|
||||
return '${h(0)}${h(1)}${h(2)}${h(3)}-${h(4)}${h(5)}-${h(6)}${h(7)}-'
|
||||
'${h(8)}${h(9)}-${h(10)}${h(11)}${h(12)}${h(13)}${h(14)}${h(15)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/storage/spoofing_service.dart';
|
||||
import '../../../main.dart' show webAppModule, digitalIdModule;
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
@@ -175,6 +176,7 @@ class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
||||
InAppWebViewController? _controller;
|
||||
WebAppLaunch? _launch;
|
||||
String? _loadError;
|
||||
String _userAgent = '';
|
||||
double _progress = 0;
|
||||
|
||||
@override
|
||||
@@ -189,6 +191,7 @@ class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
||||
_launch = null;
|
||||
});
|
||||
try {
|
||||
_userAgent = await SpoofingService.getWebViewUserAgent() ?? '';
|
||||
final launch = await webAppModule.fetchDigitalId();
|
||||
if (!mounted) return;
|
||||
setState(() => _launch = launch);
|
||||
@@ -280,6 +283,7 @@ class _DigitalIdWebScreenState extends State<DigitalIdWebScreen> {
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
useHybridComposition: true,
|
||||
useShouldOverrideUrlLoading: true,
|
||||
userAgent: _userAgent,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
_controller = controller;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../core/config/device_presets.dart';
|
||||
import '../../../core/storage/device_identity.dart';
|
||||
import '../../../core/storage/spoofing_service.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../models/spoof_profile.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/info_action_sheet.dart';
|
||||
@@ -47,6 +48,9 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
|
||||
String _selectedDeviceType = 'ANDROID';
|
||||
String _selectedArch = 'arm64-v8a';
|
||||
String _userAgent = '';
|
||||
bool _spoofingEnabled = false;
|
||||
SpoofProfile? _initialProfile;
|
||||
SpoofingMethod _selectedMethod = SpoofingMethod.partial;
|
||||
bool _isLoading = true;
|
||||
|
||||
@@ -89,49 +93,79 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
Future<void> _loadInitialData() async {
|
||||
setState(() => _isLoading = true);
|
||||
await _loadSessionIdentifiers();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final isSpoofingEnabled = prefs.getBool('spoofing_enabled') ?? false;
|
||||
|
||||
if (isSpoofingEnabled) {
|
||||
_deviceNameController.text = prefs.getString('spoof_devicename') ?? '';
|
||||
_osVersionController.text = prefs.getString('spoof_osversion') ?? '';
|
||||
_screenController.text = prefs.getString('spoof_screen') ?? '';
|
||||
_timezoneController.text = prefs.getString('spoof_timezone') ?? '';
|
||||
_localeController.text = prefs.getString('spoof_locale') ?? '';
|
||||
_deviceIdController.text = prefs.getString('spoof_deviceid') ?? '';
|
||||
_appVersionController.text =
|
||||
prefs.getString('spoof_appversion') ?? _hardcodedVersion;
|
||||
_selectedArch = prefs.getString('spoof_arch') ?? 'arm64-v8a';
|
||||
_buildNumberController.text =
|
||||
prefs.getInt('spoof_buildnumber')?.toString() ??
|
||||
'$_hardcodedBuildNumber';
|
||||
_pushDeviceTypeController.text =
|
||||
prefs.getString('spoof_pushdevicetype') ?? 'GCM';
|
||||
final scope = await SpoofingService.activeScope();
|
||||
final profile = await SpoofingService.loadProfile(scope);
|
||||
_initialProfile = profile;
|
||||
|
||||
final savedDeviceLocale = prefs.getString('spoof_devicelocale');
|
||||
if (savedDeviceLocale != null && savedDeviceLocale.isNotEmpty) {
|
||||
_deviceLocaleController.text = savedDeviceLocale;
|
||||
}
|
||||
final savedInstanceId = prefs.getString('spoof_instanceid');
|
||||
if (savedInstanceId != null && savedInstanceId.isNotEmpty) {
|
||||
_instanceIdController.text = savedInstanceId;
|
||||
}
|
||||
final savedClientSessionId = prefs.getInt('spoof_clientsessionid');
|
||||
if (savedClientSessionId != null) {
|
||||
_clientSessionIdController.text = '$savedClientSessionId';
|
||||
}
|
||||
|
||||
String savedType = prefs.getString('spoof_devicetype') ?? 'ANDROID';
|
||||
if (savedType == 'WEB') savedType = 'ANDROID';
|
||||
_selectedDeviceType = savedType;
|
||||
if (profile != null && profile.enabled) {
|
||||
_spoofingEnabled = true;
|
||||
_applyProfileToControllers(profile);
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
} else {
|
||||
_spoofingEnabled = false;
|
||||
await _loadDeviceData();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyProfileToControllers(SpoofProfile profile) {
|
||||
_deviceNameController.text = profile.deviceName;
|
||||
_osVersionController.text = profile.osVersion;
|
||||
_screenController.text = profile.screen;
|
||||
_timezoneController.text = profile.timezone;
|
||||
_localeController.text = profile.locale;
|
||||
_deviceIdController.text = profile.deviceId;
|
||||
_appVersionController.text =
|
||||
profile.appVersion.isEmpty ? _hardcodedVersion : profile.appVersion;
|
||||
_selectedArch = profile.arch.isEmpty ? 'arm64-v8a' : profile.arch;
|
||||
_buildNumberController.text = profile.buildNumber == 0
|
||||
? '$_hardcodedBuildNumber'
|
||||
: '${profile.buildNumber}';
|
||||
_pushDeviceTypeController.text =
|
||||
profile.pushDeviceType.isEmpty ? 'GCM' : profile.pushDeviceType;
|
||||
_userAgent = profile.userAgent;
|
||||
|
||||
if (profile.deviceLocale.isNotEmpty) {
|
||||
_deviceLocaleController.text = profile.deviceLocale;
|
||||
}
|
||||
if (profile.instanceId.isNotEmpty) {
|
||||
_instanceIdController.text = profile.instanceId;
|
||||
}
|
||||
if (profile.clientSessionId != null) {
|
||||
_clientSessionIdController.text = '${profile.clientSessionId}';
|
||||
}
|
||||
|
||||
var type = profile.deviceType.isEmpty ? 'ANDROID' : profile.deviceType;
|
||||
if (type == 'WEB') type = 'ANDROID';
|
||||
_selectedDeviceType = type;
|
||||
}
|
||||
|
||||
SpoofProfile _buildProfileFromControllers() {
|
||||
return SpoofProfile(
|
||||
enabled: _spoofingEnabled,
|
||||
deviceName: _deviceNameController.text,
|
||||
osVersion: _osVersionController.text,
|
||||
screen: _screenController.text,
|
||||
timezone: _timezoneController.text,
|
||||
locale: _localeController.text,
|
||||
deviceLocale: _deviceLocaleController.text,
|
||||
deviceId: _deviceIdController.text,
|
||||
deviceType: _selectedDeviceType,
|
||||
arch: _selectedArch,
|
||||
appVersion: _appVersionController.text,
|
||||
buildNumber:
|
||||
int.tryParse(_buildNumberController.text) ?? _hardcodedBuildNumber,
|
||||
pushDeviceType: _pushDeviceTypeController.text,
|
||||
instanceId: _instanceIdController.text,
|
||||
clientSessionId: int.tryParse(_clientSessionIdController.text),
|
||||
userAgent: _userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadDeviceData() async {
|
||||
setState(() => _isLoading = true);
|
||||
_userAgent = '';
|
||||
_spoofingEnabled = false;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final pixelRatio = View.of(context).devicePixelRatio;
|
||||
@@ -158,13 +192,8 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
_screenController.text =
|
||||
'$densityBucket ${dpi}dpi ${size.width.round()}x${size.height.round()}';
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var realDeviceId = prefs.getString('real_device_id');
|
||||
if (realDeviceId == null || realDeviceId.isEmpty) {
|
||||
realDeviceId = _generateDeviceId();
|
||||
await prefs.setString('real_device_id', realDeviceId);
|
||||
}
|
||||
_deviceIdController.text = realDeviceId;
|
||||
_deviceIdController.text = await DeviceIdentity.deviceId();
|
||||
_buildNumberController.text = '$_hardcodedBuildNumber';
|
||||
|
||||
try {
|
||||
final timezoneInfo = await FlutterTimezone.getLocalTimezone();
|
||||
@@ -181,9 +210,23 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
_selectedArch = androidInfo.supportedAbis.isNotEmpty
|
||||
? androidInfo.supportedAbis.first
|
||||
: 'arm64-v8a';
|
||||
_buildNumberController.text = '$_hardcodedBuildNumber';
|
||||
} else {
|
||||
await _applyGeneratedData();
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
_selectedDeviceType = 'IOS';
|
||||
_deviceNameController.text = iosInfo.utsname.machine;
|
||||
_osVersionController.text = iosInfo.systemVersion;
|
||||
} else if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
_deviceNameController.text = linuxInfo.prettyName;
|
||||
_osVersionController.text = linuxInfo.name;
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
_deviceNameController.text = windowsInfo.productName;
|
||||
_osVersionController.text = windowsInfo.productName;
|
||||
} else if (Platform.isMacOS) {
|
||||
final macInfo = await deviceInfo.macOsInfo;
|
||||
_deviceNameController.text = macInfo.model;
|
||||
_osVersionController.text = 'macOS ${macInfo.osRelease}';
|
||||
}
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
@@ -191,9 +234,7 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
|
||||
Future<void> _applyGeneratedData() async {
|
||||
final filteredPresets = devicePresets
|
||||
.where(
|
||||
(p) => p.deviceType != 'WEB' && p.deviceType == _selectedDeviceType,
|
||||
)
|
||||
.where((p) => p.deviceType == 'ANDROID' || p.deviceType == 'IOS')
|
||||
.toList();
|
||||
|
||||
if (filteredPresets.isEmpty) return;
|
||||
@@ -209,8 +250,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
_screenController.text = preset.screen;
|
||||
_appVersionController.text = _hardcodedVersion;
|
||||
_deviceIdController.text = _generateDeviceId();
|
||||
_userAgent = preset.userAgent;
|
||||
_spoofingEnabled = true;
|
||||
|
||||
_selectedArch = 'arm64-v8a';
|
||||
_selectedDeviceType = preset.deviceType;
|
||||
_selectedArch = preset.deviceType == 'IOS' ? 'arm64' : 'arm64-v8a';
|
||||
_buildNumberController.text = '$_hardcodedBuildNumber';
|
||||
|
||||
if (_selectedMethod == SpoofingMethod.full) {
|
||||
@@ -241,53 +285,21 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
Future<void> _saveSpoofingSettings() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final oldValues = {
|
||||
'device_name': prefs.getString('spoof_devicename') ?? '',
|
||||
'os_version': prefs.getString('spoof_osversion') ?? '',
|
||||
'screen': prefs.getString('spoof_screen') ?? '',
|
||||
'timezone': prefs.getString('spoof_timezone') ?? '',
|
||||
'locale': prefs.getString('spoof_locale') ?? '',
|
||||
'device_id': prefs.getString('spoof_deviceid') ?? '',
|
||||
'device_type': prefs.getString('spoof_devicetype') ?? 'ANDROID',
|
||||
'arch': prefs.getString('spoof_arch') ?? '',
|
||||
'device_locale': prefs.getString('spoof_devicelocale') ?? '',
|
||||
'app_version': prefs.getString('spoof_appversion') ?? '',
|
||||
'build_number': prefs.getInt('spoof_buildnumber')?.toString() ?? '',
|
||||
'push_device_type': prefs.getString('spoof_pushdevicetype') ?? '',
|
||||
'instance_id': prefs.getString('spoof_instanceid') ?? '',
|
||||
'client_session_id':
|
||||
prefs.getInt('spoof_clientsessionid')?.toString() ?? '',
|
||||
};
|
||||
|
||||
final newValues = {
|
||||
'device_name': _deviceNameController.text,
|
||||
'os_version': _osVersionController.text,
|
||||
'screen': _screenController.text,
|
||||
'timezone': _timezoneController.text,
|
||||
'locale': _localeController.text,
|
||||
'device_id': _deviceIdController.text,
|
||||
'device_type': _selectedDeviceType,
|
||||
'arch': _selectedArch,
|
||||
'device_locale': _deviceLocaleController.text,
|
||||
'app_version': _appVersionController.text,
|
||||
'build_number': _buildNumberController.text,
|
||||
'push_device_type': _pushDeviceTypeController.text,
|
||||
'instance_id': _instanceIdController.text,
|
||||
'client_session_id': _clientSessionIdController.text,
|
||||
};
|
||||
|
||||
bool otherDataChanged = false;
|
||||
for (final key in oldValues.keys) {
|
||||
if (oldValues[key] != newValues[key]) {
|
||||
otherDataChanged = true;
|
||||
break;
|
||||
}
|
||||
final newProfile = _buildProfileFromControllers();
|
||||
final wasActive = _initialProfile?.enabled ?? false;
|
||||
final isActive = newProfile.enabled;
|
||||
final bool identityChanged;
|
||||
if (!wasActive && !isActive) {
|
||||
identityChanged = false;
|
||||
} else if (wasActive != isActive) {
|
||||
identityChanged = true;
|
||||
} else {
|
||||
identityChanged =
|
||||
jsonEncode(_initialProfile!.toJson()) != jsonEncode(newProfile.toJson());
|
||||
}
|
||||
|
||||
if (!otherDataChanged) {
|
||||
await _saveAllData(prefs);
|
||||
if (!identityChanged) {
|
||||
await _persistProfile();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
@@ -333,14 +345,12 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
if (!mounted || confirmed == null) return;
|
||||
|
||||
if (confirmed == 'relogin') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _saveAllData(prefs);
|
||||
await _persistProfile();
|
||||
await api.disconnect();
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await TokenStorage.deleteToken(accountId);
|
||||
}
|
||||
await prefs.setBool('spoofing_enabled', true);
|
||||
await api.connect();
|
||||
if (mounted) {
|
||||
final navState = KometApp.navigatorKey.currentState;
|
||||
@@ -356,7 +366,7 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
|
||||
if (confirmed != 'apply') return;
|
||||
|
||||
await _saveAllData(prefs);
|
||||
await _persistProfile();
|
||||
|
||||
try {
|
||||
await api.disconnect();
|
||||
@@ -378,37 +388,11 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAllData(SharedPreferences prefs) async {
|
||||
await prefs.setBool('spoofing_enabled', true);
|
||||
await prefs.setString('spoof_devicename', _deviceNameController.text);
|
||||
await prefs.setString('spoof_osversion', _osVersionController.text);
|
||||
await prefs.setString('spoof_screen', _screenController.text);
|
||||
await prefs.setString('spoof_timezone', _timezoneController.text);
|
||||
await prefs.setString('spoof_locale', _localeController.text);
|
||||
await prefs.setString('spoof_deviceid', _deviceIdController.text);
|
||||
await prefs.setString('spoof_devicetype', _selectedDeviceType);
|
||||
await prefs.setString('spoof_arch', _selectedArch);
|
||||
await prefs.setString('spoof_devicelocale', _deviceLocaleController.text);
|
||||
await prefs.setString('spoof_appversion', _appVersionController.text);
|
||||
await prefs.setString(
|
||||
'spoof_pushdevicetype',
|
||||
_pushDeviceTypeController.text,
|
||||
);
|
||||
await prefs.setString('spoof_instanceid', _instanceIdController.text);
|
||||
|
||||
final buildNumber = int.tryParse(_buildNumberController.text);
|
||||
if (buildNumber != null) {
|
||||
await prefs.setInt('spoof_buildnumber', buildNumber);
|
||||
} else {
|
||||
await prefs.remove('spoof_buildnumber');
|
||||
}
|
||||
|
||||
final clientSessionId = int.tryParse(_clientSessionIdController.text);
|
||||
if (clientSessionId != null) {
|
||||
await prefs.setInt('spoof_clientsessionid', clientSessionId);
|
||||
} else {
|
||||
await prefs.remove('spoof_clientsessionid');
|
||||
}
|
||||
Future<void> _persistProfile() async {
|
||||
final scope = await SpoofingService.activeScope();
|
||||
final profile = _buildProfileFromControllers();
|
||||
await SpoofingService.saveProfile(scope, profile);
|
||||
_initialProfile = profile;
|
||||
}
|
||||
|
||||
void _generateNewDeviceId() {
|
||||
@@ -450,6 +434,8 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildEnableCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
_buildSpoofingMethodCard(),
|
||||
@@ -469,6 +455,26 @@ class _SpoofScreenState extends State<SpoofScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEnableCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
title: Text(
|
||||
l10n.spoofEnableTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
subtitle: Text(
|
||||
_spoofingEnabled
|
||||
? l10n.spoofEnableSubtitleOn
|
||||
: l10n.spoofEnableSubtitleOff,
|
||||
),
|
||||
value: _spoofingEnabled,
|
||||
onChanged: (value) => setState(() => _spoofingEnabled = value),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Card(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../backend/modules/webapp.dart';
|
||||
import '../../../core/storage/spoofing_service.dart';
|
||||
import '../../widgets/connection_status.dart';
|
||||
import '../../widgets/webview_permission_prompt.dart';
|
||||
|
||||
@@ -24,6 +25,7 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
InAppWebViewController? _controller;
|
||||
WebAppLaunch? _launch;
|
||||
String? _loadError;
|
||||
String _userAgent = '';
|
||||
double _progress = 0;
|
||||
|
||||
@override
|
||||
@@ -38,6 +40,7 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
_launch = null;
|
||||
});
|
||||
try {
|
||||
_userAgent = await SpoofingService.getWebViewUserAgent() ?? '';
|
||||
final launch = await widget.loader();
|
||||
if (!mounted) return;
|
||||
setState(() => _launch = launch);
|
||||
@@ -120,6 +123,7 @@ class _WebAppScreenState extends State<WebAppScreen> {
|
||||
transparentBackground: true,
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
useHybridComposition: true,
|
||||
userAgent: _userAgent,
|
||||
),
|
||||
onWebViewCreated: (controller) => _controller = controller,
|
||||
onPermissionRequest: (controller, request) =>
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"proxyInvalidHostOrPort": "Enter a valid proxy host and port (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Session spoofing",
|
||||
"spoofEnableTitle": "Device spoofing",
|
||||
"spoofEnableSubtitleOn": "Enabled for this account",
|
||||
"spoofEnableSubtitleOff": "Disabled — using the real device",
|
||||
"spoofInfoHint": "Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.",
|
||||
"spoofMethodTitle": "Spoofing method",
|
||||
"spoofMethodPartial": "Partial",
|
||||
|
||||
@@ -392,6 +392,24 @@ abstract class AppLocalizations {
|
||||
/// **'Session spoofing'**
|
||||
String get spoofScreenTitle;
|
||||
|
||||
/// No description provided for @spoofEnableTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Device spoofing'**
|
||||
String get spoofEnableTitle;
|
||||
|
||||
/// No description provided for @spoofEnableSubtitleOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enabled for this account'**
|
||||
String get spoofEnableSubtitleOn;
|
||||
|
||||
/// No description provided for @spoofEnableSubtitleOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled — using the real device'**
|
||||
String get spoofEnableSubtitleOff;
|
||||
|
||||
/// No description provided for @spoofInfoHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -161,6 +161,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get spoofScreenTitle => 'Session spoofing';
|
||||
|
||||
@override
|
||||
String get spoofEnableTitle => 'Device spoofing';
|
||||
|
||||
@override
|
||||
String get spoofEnableSubtitleOn => 'Enabled for this account';
|
||||
|
||||
@override
|
||||
String get spoofEnableSubtitleOff => 'Disabled — using the real device';
|
||||
|
||||
@override
|
||||
String get spoofInfoHint =>
|
||||
'Tap \"Generate\":\n• Short tap: random preset.\n• Long press: real device data.';
|
||||
|
||||
@@ -163,6 +163,16 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get spoofScreenTitle => 'Подмена данных сессии';
|
||||
|
||||
@override
|
||||
String get spoofEnableTitle => 'Подмена устройства';
|
||||
|
||||
@override
|
||||
String get spoofEnableSubtitleOn => 'Включена для этого аккаунта';
|
||||
|
||||
@override
|
||||
String get spoofEnableSubtitleOff =>
|
||||
'Выключена — используется реальное устройство';
|
||||
|
||||
@override
|
||||
String get spoofInfoHint =>
|
||||
'Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.';
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"proxyInvalidHostOrPort": "Укажите корректный хост и порт прокси (1–65535)",
|
||||
|
||||
"spoofScreenTitle": "Подмена данных сессии",
|
||||
"spoofEnableTitle": "Подмена устройства",
|
||||
"spoofEnableSubtitleOn": "Включена для этого аккаунта",
|
||||
"spoofEnableSubtitleOff": "Выключена — используется реальное устройство",
|
||||
"spoofInfoHint": "Нажмите \"Сгенерировать\":\n• Короткое нажатие: случайный пресет.\n• Длинное нажатие: реальные данные.",
|
||||
"spoofMethodTitle": "Метод подмены",
|
||||
"spoofMethodPartial": "Частичный",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class SpoofProfile {
|
||||
final bool enabled;
|
||||
final String deviceName;
|
||||
final String osVersion;
|
||||
final String screen;
|
||||
final String timezone;
|
||||
final String locale;
|
||||
final String deviceLocale;
|
||||
final String deviceId;
|
||||
final String deviceType;
|
||||
final String arch;
|
||||
final String appVersion;
|
||||
final int buildNumber;
|
||||
final String pushDeviceType;
|
||||
final String instanceId;
|
||||
final int? clientSessionId;
|
||||
final String userAgent;
|
||||
|
||||
const SpoofProfile({
|
||||
required this.enabled,
|
||||
this.deviceName = '',
|
||||
this.osVersion = '',
|
||||
this.screen = '',
|
||||
this.timezone = '',
|
||||
this.locale = '',
|
||||
this.deviceLocale = '',
|
||||
this.deviceId = '',
|
||||
this.deviceType = 'ANDROID',
|
||||
this.arch = 'arm64-v8a',
|
||||
this.appVersion = '',
|
||||
this.buildNumber = 0,
|
||||
this.pushDeviceType = 'GCM',
|
||||
this.instanceId = '',
|
||||
this.clientSessionId,
|
||||
this.userAgent = '',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'enabled': enabled,
|
||||
'device_name': deviceName,
|
||||
'os_version': osVersion,
|
||||
'screen': screen,
|
||||
'timezone': timezone,
|
||||
'locale': locale,
|
||||
'device_locale': deviceLocale,
|
||||
'device_id': deviceId,
|
||||
'device_type': deviceType,
|
||||
'arch': arch,
|
||||
'app_version': appVersion,
|
||||
'build_number': buildNumber,
|
||||
'push_device_type': pushDeviceType,
|
||||
'instance_id': instanceId,
|
||||
'client_session_id': clientSessionId,
|
||||
'user_agent': userAgent,
|
||||
};
|
||||
|
||||
factory SpoofProfile.fromJson(Map<String, dynamic> json) => SpoofProfile(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
deviceName: json['device_name'] as String? ?? '',
|
||||
osVersion: json['os_version'] as String? ?? '',
|
||||
screen: json['screen'] as String? ?? '',
|
||||
timezone: json['timezone'] as String? ?? '',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
deviceLocale: json['device_locale'] as String? ?? '',
|
||||
deviceId: json['device_id'] as String? ?? '',
|
||||
deviceType: json['device_type'] as String? ?? 'ANDROID',
|
||||
arch: json['arch'] as String? ?? 'arm64-v8a',
|
||||
appVersion: json['app_version'] as String? ?? '',
|
||||
buildNumber: (json['build_number'] as num?)?.toInt() ?? 0,
|
||||
pushDeviceType: json['push_device_type'] as String? ?? 'GCM',
|
||||
instanceId: json['instance_id'] as String? ?? '',
|
||||
clientSessionId: (json['client_session_id'] as num?)?.toInt(),
|
||||
userAgent: json['user_agent'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user