client_framework/lib/src/config/skin_config.dart
2026-04-16 17:00:55 +08:00

431 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, String> 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<String, dynamic>? analyticsJson;
final SkinExtConfigSection? _skinExtConfig;
final Map<String, String> _adjustEvents;
/// 从 JSON 文本解析(如 `rootBundle.loadString` 的结果)。
factory SkinConfig.fromJsonString(String source) {
final dynamic decoded = jsonDecode(source);
if (decoded is! Map<String, dynamic>) {
throw FormatException('skin_config root must be a JSON object');
}
return SkinConfig.fromJson(decoded);
}
factory SkinConfig.fromJson(Map<String, dynamic> 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<String, dynamic>) {
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<String> sanctum = const ['wrapper', 'layer', 'payload'];
List<String> v2Noise = const [
'n1',
'n2',
'n3',
'n4',
'n5',
'n6',
];
if (v2 is Map<String, dynamic>) {
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<String, dynamic>) {
if (fmRaw.isEmpty) {
mapping = kIdentityFieldMapping;
} else {
final m = <String, String>{};
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 = <String, String>{};
if (eventsRaw is Map<String, dynamic>) {
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<String, dynamic>) {
skinExt = SkinExtConfigSection.fromRootJson(extCfg);
}
var videoHomeImagesTabLabel = 'Images';
var videoHomeImagesTabFirst = false;
final vh = json['videoHome'];
if (vh is Map<String, dynamic>) {
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<String, dynamic>?,
skinExtConfig: skinExt,
videoHomeImagesTabLabel: videoHomeImagesTabLabel,
videoHomeImagesTabFirst: videoHomeImagesTabFirst,
);
}
static Map<String, dynamic> _map(dynamic v, String name) {
if (v is! Map<String, dynamic>) {
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<String, dynamic>) {
throw FormatException('proxyKeys must be a JSON object');
}
List<String> 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<String> v2SanctumPath;
@override
final List<String> v2NoiseKeys;
@override
final FieldMapping fieldMapping;
@override
ExtConfigKeySchema get extConfigKeySchema =>
_skinExtConfig?.keySchema ?? ExtConfigKeySchema.defaults();
@override
Map<String, dynamic>? get extConfigDefaults => _skinExtConfig?.defaults;
@override
Map<String, dynamic> get v2FixedValues => {v2LevelField: v2LevelFixedValue};
/// 语义化 Adjust 事件名 → Dashboard token无配置则为空 map。
Map<String, String> get adjustEventTokens =>
Map<String, String>.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<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic>? root,
) {
if (root == null) return null;
final p = root['platformAttribution'];
if (p is Map<String, dynamic>) {
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;
}
}