431 lines
13 KiB
Dart
431 lines
13 KiB
Dart
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;
|
||
}
|
||
}
|