import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../common/network/dio_provider.dart'; /// 金值商城广告位的公共契约;商城是否展示完全由服务端配置决定。 const mallSourceApp = 'jinzhi'; /// The native entry that opened the resident mall. H5 uses this context to /// choose the appropriate landing flow while sharing the same WebView host. class MallEntryRequest { const MallEntryRequest({ required this.sourceEntry, required this.hookType, this.entryScene, this.entryMetal, }); static const home = MallEntryRequest( sourceEntry: 'mall_tab', hookType: 'mall_tab', ); static const quoteRetailGold = MallEntryRequest( sourceEntry: 'market_quote', hookType: 'quote_cta', entryScene: 'retail', entryMetal: 'au', ); final String sourceEntry; final String hookType; final String? entryScene; final String? entryMetal; } class MallTabConfig { const MallTabConfig({ required this.enabled, required this.url, this.title = '商城', this.adId = '', this.trackingId = '', this.openMode = 'in_app', this.entryPromoEnabled = false, this.entryBadgeText = '', this.entryTipTitle = '', this.entryTipBody = '', }); const MallTabConfig.disabled() : this(enabled: false, url: ''); final bool enabled; final String url; final String title; final String adId; final String trackingId; final String openMode; final bool entryPromoEnabled; final String entryBadgeText; final String entryTipTitle; final String entryTipBody; bool get isVisible => enabled && initialUri != null; bool get opensInApp => openMode == 'in_app'; bool get showsEntryBadge => isVisible && entryPromoEnabled && entryBadgeText.trim().isNotEmpty; bool get showsEntryTip => isVisible && entryPromoEnabled && (entryTipTitle.trim().isNotEmpty || entryTipBody.trim().isNotEmpty); Uri? get initialUri => attributedUri(clickId: ''); Uri? attributedUri({ required String clickId, bool isDark = false, MallEntryRequest entry = MallEntryRequest.home, }) { final uri = Uri.tryParse(url.trim()); if (!_isAllowed(uri)) return null; return uri!.replace( queryParameters: { ...uri.queryParameters, 'from': uri.queryParameters['from']?.trim().isNotEmpty == true ? uri.queryParameters['from']! : mallSourceApp, 'source_app': mallSourceApp, 'source_entry': entry.sourceEntry, 'placement': 'mall_tab', 'hook_type': entry.hookType, if (entry.entryScene?.isNotEmpty == true) 'entry_scene': entry.entryScene!, if (entry.entryMetal?.isNotEmpty == true) 'entry_metal': entry.entryMetal!, 'target_app': 'mall', if (adId.isNotEmpty) 'ad_id': adId, if (trackingId.isNotEmpty) 'tracking_id': trackingId, if (clickId.isNotEmpty) 'click_id': clickId, if (clickId.isNotEmpty) 'ts': DateTime.now().millisecondsSinceEpoch.toString(), 'isNight': isDark ? '1' : '0', }, ); } static bool _isAllowed(Uri? uri) { if (uri == null || uri.host.isEmpty) return false; if (uri.scheme == 'https') return true; return !kReleaseMode && uri.scheme == 'http' && const {'localhost', '127.0.0.1', '::1'}.contains(uri.host); } } class MallTabConfigController extends StateNotifier { MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) { refresh(); } final Dio _dio; Future? _loading; Future ensureLoaded() async { if (state.isVisible) return state; await (_loading ??= refresh().whenComplete(() => _loading = null)); return state; } Future refresh() async { try { final response = await _dio.get>( '/api/v1/ads', queryParameters: const {'placement': 'mall_tab'}, options: protobufOptions(), ); state = parseMallTabConfigResponse(response.data ?? const []); if (!state.isVisible) return; await _report('ad_impression'); } catch (_) { state = const MallTabConfig.disabled(); } } Future recordClick() async { if (!state.isVisible) return null; final clickId = const Uuid().v4(); try { await _report('ad_click', clickId: clickId); } catch (_) { // 归因上报失败不能阻断商城入口。 } return clickId; } Future _report(String event, {String clickId = ''}) => _dio.post>( '/api/v1/ads/events', data: _ProtoWriter() .string(1, event) .string(2, 'mall_tab') .string(3, state.adId) .string(4, state.trackingId) .string(5, clickId) .integer(6, DateTime.now().millisecondsSinceEpoch) .toBytes(), options: protobufOptions(), ); } final mallTabConfigProvider = StateNotifierProvider( (ref) => MallTabConfigController(ref.watch(dioProvider)), ); class MallClickAttribution { const MallClickAttribution({ required this.adId, required this.trackingId, required this.clickId, }); final String adId; final String trackingId; final String clickId; } final mallClickAttributionProvider = StateProvider( (ref) => null, ); Map buildEnterWebMallBridgeArguments({ required Uri currentUri, required bool logged, required bool isDark, String? authToken, String? scene, String? sourceApp, MallClickAttribution? attribution, }) { final query = currentUri.queryParameters; final resolvedSourceApp = sourceApp?.trim().isNotEmpty == true ? sourceApp!.trim() : mallSourceApp; final arguments = { ...query, 'from': resolvedSourceApp, 'source_app': resolvedSourceApp, 'source_entry': query['source_entry'] ?? 'mall_tab', 'placement': query['placement'] ?? 'mall_tab', 'hook_type': query['hook_type'] ?? 'mall_tab', 'target_app': query['target_app'] ?? 'mall', 'logged': logged, 'isNight': isDark ? '1' : '0', }; final adId = attribution?.adId.trim() ?? ''; if (adId.isNotEmpty) arguments['ad_id'] = adId; final trackingId = attribution?.trackingId.trim() ?? ''; if (trackingId.isNotEmpty) arguments['tracking_id'] = trackingId; final clickId = attribution?.clickId.trim() ?? ''; if (clickId.isNotEmpty) arguments['click_id'] = clickId; final token = authToken?.trim() ?? ''; if (token.isNotEmpty) { arguments['auth_token'] = token; arguments['authToken'] = token; } final resolvedScene = scene?.trim() ?? ''; if (resolvedScene.isNotEmpty) arguments['scene'] = resolvedScene; return arguments; } String _nonEmpty(String value, String fallback) => value.trim().isEmpty ? fallback : value.trim(); String? _validUrl(String raw) { final uri = Uri.tryParse(raw.trim()); return MallTabConfig._isAllowed(uri) ? uri.toString() : null; } String _openMode(int value) => switch (value) { 1 => 'native_app', 2 => 'in_app', _ => 'external', }; /// Parses the protobuf reply returned by `/api/v1/ads?placement=mall_tab`. MallTabConfig parseMallTabConfigResponse(List bytes) { final response = _ProtoMessage(bytes); final reply = response.message(2); final trackedUrl = reply.string(14); final h5Url = reply.string(9); final url = _validUrl(trackedUrl) ?? _validUrl(h5Url); if (response.integer(1) == 0 || url == null) { return const MallTabConfig.disabled(); } return MallTabConfig( enabled: true, url: url, title: _nonEmpty(reply.string(4), '商城'), adId: reply.string(1), trackingId: reply.string(10), openMode: _openMode(reply.integer(15)), entryPromoEnabled: reply.integer(16) != 0, entryBadgeText: reply.string(17).trim(), entryTipTitle: reply.string(18).trim(), entryTipBody: reply.string(19).trim(), ); } class _ProtoMessage { _ProtoMessage(this._bytes); final List _bytes; int integer(int wanted) => _fields()[wanted] is int ? _fields()[wanted]! as int : 0; String string(int wanted) => _fields()[wanted] is List ? utf8.decode(_fields()[wanted]! as List, allowMalformed: true) : ''; _ProtoMessage message(int wanted) => _ProtoMessage( _fields()[wanted] is List ? _fields()[wanted]! as List : const [], ); Map _fields() { final values = {}; var index = 0; int readVarint() { var value = 0; var shift = 0; while (index < _bytes.length && shift < 64) { final byte = _bytes[index++]; value |= (byte & 127) << shift; if (byte < 128) return value; shift += 7; } throw const FormatException('Invalid protobuf'); } while (index < _bytes.length) { final key = readVarint(); final tag = key >> 3; switch (key & 7) { case 0: values[tag] = readVarint(); case 2: final length = readVarint(); final end = index + length; if (end > _bytes.length) { throw const FormatException('Invalid protobuf'); } values[tag] = _bytes.sublist(index, end); index = end; default: throw const FormatException('Unsupported protobuf'); } } return values; } } class _ProtoWriter { final _bytes = BytesBuilder(copy: false); _ProtoWriter string(int tag, String value) { final data = value.codeUnits; _varint((tag << 3) | 2); _varint(data.length); _bytes.add(data); return this; } _ProtoWriter integer(int tag, int value) { _varint(tag << 3); _varint(value); return this; } Uint8List toBytes() => _bytes.toBytes(); void _varint(int value) { while (value > 127) { _bytes.addByte((value & 127) | 128); value >>= 7; } _bytes.addByte(value); } }