import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../log/sdk_reminder_log.dart'; import '../services/analytics_service.dart'; import 'app_config.dart'; import 'attribution_config.dart'; import 'ext_config_key_schema.dart'; import 'field_mapping.dart'; /// JSON 换皮配置(单文件描述 API、归因、字段映射等)。 /// /// 在宿主 `pubspec.yaml` 中注册 assets 后使用: /// `await ClientBootstrap.initFromAsset('assets/skin_config.json');` /// /// 顶层结构示例:`lib/src/config/skin_config.example.json`。 class SkinConfig implements AppConfig { SkinConfig._({ required this.appName, required this.appId, required this.packageName, required this.backendAppTypeIOS, required this.backendAppTypeAndroid, required this.preBaseUrl, required this.prodBaseUrl, required this.proxyPath, required this.debugBaseUrlOverride, required this.alwaysUsePreBaseUrl, required this.aesKey, required this.proxyKeys, required this.v2LevelField, required this.v2LevelFixedValue, required this.v2SanctumPath, required this.v2NoiseKeys, required this.fieldMapping, required Map adjustEvents, this.analyticsJson, SkinExtConfigSection? skinExtConfig, required this.videoHomeImagesTabLabel, required this.videoHomeImagesTabFirst, }) : _skinExtConfig = skinExtConfig, _adjustEvents = adjustEvents; @override final String videoHomeImagesTabLabel; @override final bool videoHomeImagesTabFirst; final Map? analyticsJson; final SkinExtConfigSection? _skinExtConfig; final Map _adjustEvents; /// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。 factory SkinConfig.fromJsonString(String source) { final dynamic decoded = jsonDecode(source); if (decoded is! Map) { throw FormatException('skin_config root must be a JSON object'); } return SkinConfig.fromJson(decoded); } factory SkinConfig.fromJson(Map json) { final app = _map(json['app'], 'app'); final api = _map(json['api'], 'api'); final name = app['name'] as String?; final id = app['id'] as String?; final pkg = app['packageName'] as String?; if (name == null || name.isEmpty) { throw FormatException('app.name is required'); } if (id == null || id.isEmpty) { throw FormatException('app.id is required'); } if (pkg == null || pkg.isEmpty) { throw FormatException('app.packageName is required'); } final pre = api['preBaseUrl'] as String?; final prod = api['prodBaseUrl'] as String?; final proxyPath = api['proxyPath'] as String?; final aes = api['aesKey'] as String?; if (pre == null || pre.isEmpty) { throw FormatException('api.preBaseUrl is required'); } if (prod == null || prod.isEmpty) { throw FormatException('api.prodBaseUrl is required'); } if (proxyPath == null || proxyPath.isEmpty) { throw FormatException('api.proxyPath is required'); } if (aes == null || aes.isEmpty) { throw FormatException('api.aesKey is required (16 chars for current crypto)'); } final backend = json['backend']; String iosType = 'HIOS'; String androidType = 'HAndroid'; if (backend is Map) { iosType = backend['iosAppType'] as String? ?? iosType; androidType = backend['androidAppType'] as String? ?? androidType; } final debugOverride = api['debugBaseUrlOverride'] as String?; final alwaysPre = api['alwaysUsePreBaseUrl'] as bool? ?? false; final proxyKeys = _proxyKeysFromJson(json['proxyKeys']); final v2 = json['v2']; String v2Level = 'level'; int v2Fixed = 1; List sanctum = const ['wrapper', 'layer', 'payload']; List v2Noise = const [ 'n1', 'n2', 'n3', 'n4', 'n5', 'n6', ]; if (v2 is Map) { v2Level = v2['levelField'] as String? ?? v2Level; v2Fixed = (v2['levelFixedValue'] as num?)?.toInt() ?? v2Fixed; final path = v2['sanctumPath']; if (path is List) { sanctum = path.map((e) => e.toString()).toList(); } final nk = v2['noiseKeys']; if (nk is List) { v2Noise = nk.map((e) => e.toString()).toList(); } } FieldMapping mapping; final fmRaw = json['fieldMapping']; if (fmRaw == null) { mapping = kIdentityFieldMapping; } else if (fmRaw is Map) { if (fmRaw.isEmpty) { mapping = kIdentityFieldMapping; } else { final m = {}; for (final e in fmRaw.entries) { m[e.key.toString()] = e.value.toString(); } mapping = FieldMapping(m); } } else { throw FormatException('fieldMapping must be a JSON object'); } final eventsRaw = json['adjustEvents']; final events = {}; if (eventsRaw is Map) { for (final e in eventsRaw.entries) { final v = e.value; if (v != null && v.toString().isNotEmpty) { events[e.key.toString()] = v.toString(); } } } SkinExtConfigSection? skinExt; final extCfg = json['extConfig']; if (extCfg is Map) { skinExt = SkinExtConfigSection.fromRootJson(extCfg); } var videoHomeImagesTabLabel = 'Images'; var videoHomeImagesTabFirst = false; final vh = json['videoHome']; if (vh is Map) { final rawLabel = vh['imagesTabLabel']; if (rawLabel is String && rawLabel.trim().isNotEmpty) { videoHomeImagesTabLabel = rawLabel.trim(); } videoHomeImagesTabFirst = vh['imagesTabFirst'] as bool? ?? false; } return SkinConfig._( appName: name, appId: id, packageName: pkg, backendAppTypeIOS: iosType, backendAppTypeAndroid: androidType, preBaseUrl: pre, prodBaseUrl: prod, proxyPath: proxyPath, debugBaseUrlOverride: debugOverride, alwaysUsePreBaseUrl: alwaysPre, aesKey: aes, proxyKeys: proxyKeys, v2LevelField: v2Level, v2LevelFixedValue: v2Fixed, v2SanctumPath: sanctum, v2NoiseKeys: v2Noise, fieldMapping: mapping, adjustEvents: events, analyticsJson: json['analytics'] as Map?, skinExtConfig: skinExt, videoHomeImagesTabLabel: videoHomeImagesTabLabel, videoHomeImagesTabFirst: videoHomeImagesTabFirst, ); } static Map _map(dynamic v, String name) { if (v is! Map) { throw FormatException('$name must be a JSON object'); } return v; } static ProxyKeysConfig _proxyKeysFromJson(dynamic v) { if (v == null) return const ProxyKeysConfig(); if (v is! Map) { throw FormatException('proxyKeys must be a JSON object'); } List noise = const ['noise1', 'noise2', 'noise3', 'noise4']; final nk = v['noiseKeys']; if (nk is List && nk.isNotEmpty) { noise = nk.map((e) => e.toString()).toList(); } return ProxyKeysConfig( appIdField: v['appIdField'] as String? ?? 'appId', pathField: v['pathField'] as String? ?? 'path', methodField: v['methodField'] as String? ?? 'method', headerField: v['headerField'] as String? ?? 'headers', paramsField: v['paramsField'] as String? ?? 'params', bodyField: v['bodyField'] as String? ?? 'body', noiseKeys: noise, ); } @override final String appName; @override final String appId; @override final String packageName; @override final String backendAppTypeIOS; @override final String backendAppTypeAndroid; @override final String preBaseUrl; @override final String prodBaseUrl; @override final String proxyPath; @override final String? debugBaseUrlOverride; /// 为 true 时 [baseUrl] 始终为 [preBaseUrl](便于长期连预发调试)。 final bool alwaysUsePreBaseUrl; @override String get baseUrl { if (alwaysUsePreBaseUrl) return preBaseUrl; if (!kDebugMode) return prodBaseUrl; return debugBaseUrlOverride ?? preBaseUrl; } @override String get proxyUrl => '$baseUrl$proxyPath'; @override final String aesKey; @override final ProxyKeysConfig proxyKeys; @override final String v2LevelField; @override final int v2LevelFixedValue; @override final List v2SanctumPath; @override final List v2NoiseKeys; @override final FieldMapping fieldMapping; @override ExtConfigKeySchema get extConfigKeySchema => _skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults(); @override Map? get extConfigDefaults => _skinExtConfig?.defaults; @override Map get v2FixedValues => {v2LevelField: v2LevelFixedValue}; /// 语义化 Adjust 事件名 → Dashboard token;无配置则为空 map。 Map get adjustEventTokens => Map.unmodifiable(_adjustEvents); /// 按 [adjustEvents] 里的逻辑名上报 Adjust(若 token 不存在则忽略)。 void trackAdjustEvent(String logicalName) { final token = _adjustEvents[logicalName]; if (token != null && token.isNotEmpty) { AnalyticsService.trackEvent(token); } } /// 构建 [AnalyticsService.init] 所需配置(从 JSON `analytics` 节读取)。 AnalyticsConfig buildAnalyticsConfig({bool debugLogsFallback = false}) { final root = analyticsJson; bool debugLogs = debugLogsFallback; AdjustConfig? adjustCfg; FacebookConfig? fbCfg; if (root != null) { debugLogs = root['debugLogs'] as bool? ?? debugLogs; final adj = root['adjust']; if (adj is Map) { final token = adj['appToken'] as String?; if (token != null && _isUsableSecret(token)) { adjustCfg = AdjustConfig( appToken: token.trim(), environment: _parseAdjustEnv(adj['environment'] as String?), logLevel: _parseAdjustLogLevel(adj['logLevel'] as String?), fbAppId: adj['fbAppId'] as String?, ); } else if (token != null && token.trim().isNotEmpty) { SdkReminderLog.adjust('appToken 为占位或无效值,已跳过 Adjust。'); } } final fb = root['facebook']; if (fb is Map) { final id = fb['appId'] as String? ?? ''; final clientTok = fb['clientToken'] as String? ?? ''; final idOk = _isUsableSecret(id); final ct = clientTok.trim(); final ctOk = ct.isEmpty || _isUsableSecret(ct); if (idOk && ctOk) { fbCfg = FacebookConfig( appId: id.trim(), clientToken: ct.isEmpty ? null : ct, debugLogs: _coerceConfigBool(fb['debugLogs']), ); } else if (id.trim().isNotEmpty || clientTok.trim().isNotEmpty) { SdkReminderLog.facebook( 'App ID / Client Token 为占位或无效,已跳过 Facebook。', ); } } } return AnalyticsConfig( packageName: packageName, adjustConfig: adjustCfg, facebookConfig: fbCfg, platformAttributionConfig: _parsePlatformAttribution(root), debugLogs: debugLogs, ); } static AdjustEnv _parseAdjustEnv(String? raw) { switch (raw?.toLowerCase()) { case 'sandbox': return AdjustEnv.sandbox; case 'production': default: return AdjustEnv.production; } } static AdjustLogLevel _parseAdjustLogLevel(String? raw) { switch (raw?.toLowerCase()) { case 'verbose': return AdjustLogLevel.verbose; case 'off': default: return AdjustLogLevel.off; } } /// 排除模板占位符,避免带着假 token 初始化 SDK。 static bool _isUsableSecret(String? raw) { if (raw == null) return false; final s = raw.trim(); if (s.isEmpty) return false; final lower = s.toLowerCase(); if (lower.startsWith('todo')) return false; if (lower.contains('your_')) return false; if (lower.contains('example.com')) return false; if (lower == 'changeme') return false; return true; } static PlatformAttributionConfig? _parsePlatformAttribution( Map? root, ) { if (root == null) return null; final p = root['platformAttribution']; if (p is Map) { return PlatformAttributionConfig( enabled: p['enabled'] as bool? ?? true, ); } return const PlatformAttributionConfig(); } /// JSON / 远程配置里偶发 `1`/`"true"`,避免 `as bool?` 失败或恒为 false。 static bool _coerceConfigBool(dynamic value, {bool fallback = false}) { if (value == true || value == 1) return true; if (value == false || value == 0) return false; if (value is String) { final s = value.trim().toLowerCase(); if (s == 'true' || s == '1' || s == 'yes') return true; if (s == 'false' || s == '0' || s == 'no') return false; } return fallback; } }