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:
klockky
2026-06-08 18:23:23 +03:00
parent b1ce3054a7
commit 02bfbb9ab0
10 changed files with 1624 additions and 8 deletions
+9 -7
View File
@@ -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) {
+341
View File
@@ -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;
}
}
+16
View File
@@ -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;