feat(digital-id): Цифровой ID — WebView-мост к оригиналу + нативный режим
Реализован функционал Цифрового ID (паспорт, СНИЛС, ИНН, ОМС, водительское, СТС и др.) двумя путями с переключателем в меню разработчика. WebView-мост (по умолчанию): - Открывает оригинальную страницу digital-id.max.ru и реализует нативный хост-мост window.WebViewHandler (postEvent/receiveEvent), который настоящее приложение MAX предоставляет нативно: lifecycle-события, биометрия, SecureStorage/DeviceStorage, OpenLink (переход на Госуслуги) и заворот OAuth-callback (externalCallback) обратно в мини-апп. - botId Цифрового ID резолвится из settings-entry-banners конфига логина (как Сферум). Нативный режим: - DigitalIdModule — REST-клиент к ext-api.max.ru (все эндпоинты по образцу библиотеки gaijin: biometry, docs, esia, qr, verify, acms, profile), авторизация через #WebAppData= из webAppInitData. - Модели документов и экран онбординга/просмотра. Прочее: - Тумблер AppDigitalIdNative (WebView/нативно) и кнопка «Сбросить Цифровой ID» (очистка кук и данных WebView) — в настройках разработчика.
This commit is contained in:
@@ -1297,23 +1297,25 @@ class AccountModule {
|
||||
Future<void> _persistEntryBannerApps(int accountId, Map serverConfig) async {
|
||||
final banners = serverConfig['settings-entry-banners'];
|
||||
if (banners is! List) return;
|
||||
final resolved = <String, int>{};
|
||||
for (final banner in banners) {
|
||||
final items = (banner is Map) ? banner['items'] : null;
|
||||
if (items is! List) continue;
|
||||
for (final item in items) {
|
||||
if (item is! Map) continue;
|
||||
final appId = item['appid'];
|
||||
if (appId is! int) continue;
|
||||
final icon = item['icon']?.toString().toLowerCase() ?? '';
|
||||
if (appId is int && icon.contains('sferum')) {
|
||||
await AppDatabase.setSyncValue(
|
||||
accountId,
|
||||
EntryBannerApps.sferumKey,
|
||||
appId.toString(),
|
||||
);
|
||||
return;
|
||||
for (final entry in EntryBannerApps.iconMatchers.entries) {
|
||||
if (!resolved.containsKey(entry.key) && icon.contains(entry.value)) {
|
||||
resolved[entry.key] = appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final entry in resolved.entries) {
|
||||
await AppDatabase.setSyncValue(accountId, entry.key, entry.value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractChatMarker(List<Map> chats) {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
import '../../core/storage/app_database.dart';
|
||||
import '../../core/storage/token_storage.dart';
|
||||
import '../../models/digital_id.dart';
|
||||
import 'webapp.dart';
|
||||
|
||||
class DigitalIdException implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
const DigitalIdException(this.code, this.message);
|
||||
|
||||
bool get isUnauthorized => code == 'UNAUTHORIZED';
|
||||
bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK';
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class DigitalIdModule {
|
||||
static const String _baseUrl = 'https://ext-api.max.ru';
|
||||
static const String _deviceIdKey = 'digital_id_device_id';
|
||||
static const String _tokenKey = 'digital_id_biometry_token';
|
||||
|
||||
final WebAppModule _webApp;
|
||||
final HttpClient _http = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 20);
|
||||
|
||||
String? _webAppData;
|
||||
String? _deviceId;
|
||||
|
||||
DigitalIdModule(this._webApp);
|
||||
|
||||
Future<String> _ensureWebAppData({bool forceRefresh = false}) async {
|
||||
if (!forceRefresh && _webAppData != null) return _webAppData!;
|
||||
final launch = await _webApp.fetchDigitalId();
|
||||
final data = _extractWebAppData(launch.url);
|
||||
if (data == null) {
|
||||
throw const DigitalIdException(
|
||||
'NO_INIT_DATA',
|
||||
'Не удалось получить данные авторизации Цифрового ID',
|
||||
);
|
||||
}
|
||||
_webAppData = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
String? _extractWebAppData(String url) {
|
||||
final hashIndex = url.indexOf('#');
|
||||
if (hashIndex < 0) return null;
|
||||
final fragment = url.substring(hashIndex + 1);
|
||||
final match = RegExp(r'WebAppData=([^&]*(?:&(?!WebApp)[^&]*)*)')
|
||||
.firstMatch(fragment);
|
||||
final raw = match?.group(1);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return Uri.decodeComponent(raw);
|
||||
}
|
||||
|
||||
Future<String> deviceId() async {
|
||||
if (_deviceId != null) return _deviceId!;
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
final stored = await AppDatabase.getSyncValue(accountId, _deviceIdKey);
|
||||
if (stored != null && stored.isNotEmpty) {
|
||||
_deviceId = stored;
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
final generated = await _generateDeviceId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.setSyncValue(accountId, _deviceIdKey, generated);
|
||||
}
|
||||
_deviceId = generated;
|
||||
return generated;
|
||||
}
|
||||
|
||||
Future<String> _generateDeviceId() async {
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
final hex =
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final android = await info.androidInfo;
|
||||
return '${android.id}_$hex';
|
||||
}
|
||||
} catch (_) {}
|
||||
return hex;
|
||||
}
|
||||
|
||||
Future<dynamic> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
bool retry = true,
|
||||
}) async {
|
||||
final webAppData = await _ensureWebAppData();
|
||||
final uri = Uri.parse('$_baseUrl$path');
|
||||
final request = await _http.openUrl(method, uri);
|
||||
request.headers.set('Authorization', '#WebAppData=$webAppData');
|
||||
request.headers.set('Origin', 'https://digital-id.max.ru');
|
||||
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',
|
||||
);
|
||||
if (body != null) {
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.add(utf8.encode(jsonEncode(body)));
|
||||
}
|
||||
final response = await request.close();
|
||||
final text = await response.transform(utf8.decoder).join();
|
||||
|
||||
if (response.statusCode == 401 && retry) {
|
||||
await _ensureWebAppData(forceRefresh: true);
|
||||
return _send(method, path, body: body, retry: false);
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw _errorFor(response.statusCode, text);
|
||||
}
|
||||
if (text.isEmpty) return null;
|
||||
return jsonDecode(text);
|
||||
}
|
||||
|
||||
DigitalIdException _errorFor(int statusCode, String body) {
|
||||
String code = 'HTTP_$statusCode';
|
||||
String message = 'Ошибка Цифрового ID ($statusCode)';
|
||||
try {
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map) {
|
||||
final rawCode = decoded['code'] ?? decoded['error'] ?? decoded['status'];
|
||||
if (rawCode is String && rawCode.isNotEmpty) code = rawCode;
|
||||
final rawMessage = decoded['message'] ?? decoded['error_description'];
|
||||
if (rawMessage is String && rawMessage.isNotEmpty) message = rawMessage;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (statusCode == 401) code = 'UNAUTHORIZED';
|
||||
return DigitalIdException(code, message);
|
||||
}
|
||||
|
||||
Map _unwrapData(dynamic decoded) {
|
||||
if (decoded is Map && decoded['data'] is Map) {
|
||||
return decoded['data'] as Map;
|
||||
}
|
||||
return decoded is Map ? decoded : const {};
|
||||
}
|
||||
|
||||
Future<DigitalIdBiometryStatus> biometryStatus() async {
|
||||
final decoded = await _send('GET', '/v2/digital-id/biometry-status');
|
||||
return DigitalIdBiometryStatus.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
Future<String> createBiometryToken({
|
||||
required String deviceId,
|
||||
String? photoHash,
|
||||
}) async {
|
||||
final decoded = await _send(
|
||||
'POST',
|
||||
'/v3/digital-id/create-biometry-token',
|
||||
body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
},
|
||||
);
|
||||
return _unwrapData(decoded)['token'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<String> refreshUserDocs(String token) async {
|
||||
final decoded =
|
||||
await _send('POST', '/v3/digital-id/refresh-user-docs', body: {
|
||||
'token': token,
|
||||
});
|
||||
return _unwrapData(decoded)['state'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<DigitalIdUserDocs?> getUserDocs(String state) async {
|
||||
final decoded = await _send('POST', '/v2/digital-id/get-user-docs', body: {
|
||||
'state': state,
|
||||
});
|
||||
if (decoded is Map && decoded['status'] == 'done') {
|
||||
final data = decoded['data'];
|
||||
if (data is Map) return DigitalIdUserDocs.fromMap(data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<DigitalIdEsiaLink> createEsiaLink() async {
|
||||
final decoded = await _send('GET', '/v2/digital-id/create-esia-link');
|
||||
return DigitalIdEsiaLink.fromMap(decoded is Map ? decoded : const {});
|
||||
}
|
||||
|
||||
Future<DigitalIdVerification> verifyPhoto({
|
||||
required String deviceId,
|
||||
String? photoHash,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/digital-id-verify-photo', body: {
|
||||
'device_id': deviceId,
|
||||
'photo_hash': ?photoHash,
|
||||
});
|
||||
final status = decoded is Map ? decoded['status'] as String? : null;
|
||||
return DigitalIdVerification.fromValue(status);
|
||||
}
|
||||
|
||||
Future<bool> shadowMode(String deviceId) async {
|
||||
try {
|
||||
final decoded = await _send('POST', '/v3/digital-id/shadow-mode', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
return _unwrapData(decoded)['shadow_mode'] == true;
|
||||
} on DigitalIdException catch (e) {
|
||||
if (e.code == 'HTTP_404') return false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateGuHashes() async {
|
||||
await _send('POST', '/v2/digital-id/update-gu-hashes', body: const {});
|
||||
}
|
||||
|
||||
Future<DigitalIdUniversalQr> userQr(String token) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/user-qr', body: {
|
||||
'token': token,
|
||||
});
|
||||
return DigitalIdUniversalQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
Future<DigitalIdQr> generateQr({
|
||||
required String photo,
|
||||
required String token,
|
||||
required DigitalIdQrType qrType,
|
||||
String? kidAct,
|
||||
}) async {
|
||||
final decoded = await _send('POST', '/v3/digital-id/generate-qr', body: {
|
||||
'photo': photo,
|
||||
'token': token,
|
||||
'qr_type': qrType.code,
|
||||
'kid_act': ?kidAct,
|
||||
});
|
||||
return DigitalIdQr.fromMap(_unwrapData(decoded));
|
||||
}
|
||||
|
||||
Future<List<DigitalIdAcmsCard>> getCardsList({
|
||||
String? passStatus,
|
||||
String? inn,
|
||||
}) async {
|
||||
final query = <String, String>{
|
||||
'pass_status': ?passStatus,
|
||||
'inn': ?inn,
|
||||
};
|
||||
final suffix =
|
||||
query.isEmpty ? '' : '?${Uri(queryParameters: query).query}';
|
||||
final decoded =
|
||||
await _send('GET', '/v2/digital-id/get-cards-list$suffix');
|
||||
final cards = _unwrapData(decoded)['acms_cards'];
|
||||
if (cards is! List) return const [];
|
||||
return cards
|
||||
.whereType<Map>()
|
||||
.map(DigitalIdAcmsCard.fromMap)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> activateAcms({required String id, required String inn}) async {
|
||||
await _send('POST', '/v2/digital-id/activate-acms', body: {
|
||||
'id': id,
|
||||
'inn': inn,
|
||||
'pass_status': 'active',
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> createLiteProfile(String deviceId) async {
|
||||
await _send('POST', '/v2/digital-id/create-lite-profile', body: {
|
||||
'device_id': deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteProfile() async {
|
||||
await _send('DELETE', '/v3/digital-id/delete-profile');
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.setSyncValue(accountId, _tokenKey, '');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _storedToken() async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
final value = await AppDatabase.getSyncValue(accountId, _tokenKey);
|
||||
return (value != null && value.isNotEmpty) ? value : null;
|
||||
}
|
||||
|
||||
Future<void> _saveToken(String token) async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId != null) {
|
||||
await AppDatabase.setSyncValue(accountId, _tokenKey, token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> ensureBiometryToken({String? photoHash}) async {
|
||||
final existing = await _storedToken();
|
||||
if (existing != null) return existing;
|
||||
final id = await deviceId();
|
||||
final token = await createBiometryToken(deviceId: id, photoHash: photoHash);
|
||||
if (token.isNotEmpty) await _saveToken(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<DigitalIdUserDocs?> loadDocuments({
|
||||
bool createIfMissing = false,
|
||||
int attempts = 5,
|
||||
}) async {
|
||||
var token = await _storedToken();
|
||||
if (token == null) {
|
||||
if (!createIfMissing) return null;
|
||||
final id = await deviceId();
|
||||
token = await createBiometryToken(deviceId: id);
|
||||
if (token.isEmpty) return null;
|
||||
await _saveToken(token);
|
||||
}
|
||||
final state = await refreshUserDocs(token);
|
||||
if (state.isEmpty) return null;
|
||||
for (var attempt = 0; attempt < attempts; attempt++) {
|
||||
final docs = await getUserDocs(state);
|
||||
if (docs != null) return docs;
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_webAppData = null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import '../../core/storage/token_storage.dart';
|
||||
|
||||
abstract class EntryBannerApps {
|
||||
static const String sferumKey = 'entry_banner_app_sferum';
|
||||
static const String digitalIdKey = 'entry_banner_app_digital_id';
|
||||
|
||||
static const Map<String, String> iconMatchers = {
|
||||
sferumKey: 'sferum',
|
||||
digitalIdKey: 'digital',
|
||||
};
|
||||
}
|
||||
|
||||
class WebAppLaunch {
|
||||
@@ -46,6 +52,16 @@ class WebAppModule {
|
||||
return fetchLaunch(botId);
|
||||
}
|
||||
|
||||
Future<WebAppLaunch> fetchDigitalId() async {
|
||||
final botId = await _resolveEntryApp(EntryBannerApps.digitalIdKey);
|
||||
if (botId == null) {
|
||||
throw const WebAppUnavailable(
|
||||
'Цифровой ID сейчас недоступен. Переподключитесь и попробуйте снова.',
|
||||
);
|
||||
}
|
||||
return fetchLaunch(botId);
|
||||
}
|
||||
|
||||
Future<int?> _resolveEntryApp(String key) async {
|
||||
final accountId = await TokenStorage.getActiveAccountId();
|
||||
if (accountId == null) return null;
|
||||
|
||||
Reference in New Issue
Block a user