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'; class MallTabConfig { const MallTabConfig({ required this.enabled, required this.url, this.title = '商城', this.adId = '', this.trackingId = '', this.openMode = 'in_app', this.badgeText = '', }); 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 String badgeText; bool get isVisible => enabled && initialUri != null; bool get opensInApp => openMode == 'in_app'; Uri? get initialUri => attributedUri(clickId: ''); Uri? attributedUri({required String clickId, bool isDark = false}) { 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': 'mall_tab', 'placement': 'mall_tab', 'hook_type': 'mall_tab', '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(), ); final reply = _ProtoMessage(response.data ?? const []).message(2); final enabled = _ProtoMessage(response.data ?? const []).integer(1) != 0; final trackedUrl = reply.string(14); final h5Url = reply.string(9); final url = _validUrl(trackedUrl) ?? _validUrl(h5Url); if (!enabled || url == null) { state = const MallTabConfig.disabled(); return; } state = MallTabConfig( enabled: true, url: url, title: _nonEmpty(reply.string(4), '商城'), adId: reply.string(1), trackingId: reply.string(10), openMode: _openMode(reply.integer(15)), badgeText: reply.string(17).trim(), ); 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', }; 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); } }