- 同步商城 H5 登录态与原生 token - 完善商城/原生 tab 切换、动画及返回交互 - 修复七鱼客服键盘避让和状态栏恢复 - 调整开屏跳过按钮与登录入口交互
294 lines
8.3 KiB
Dart
294 lines
8.3 KiB
Dart
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<MallTabConfig> {
|
|
MallTabConfigController(this._dio) : super(const MallTabConfig.disabled()) {
|
|
refresh();
|
|
}
|
|
|
|
final Dio _dio;
|
|
Future<void>? _loading;
|
|
|
|
Future<MallTabConfig> ensureLoaded() async {
|
|
if (state.isVisible) return state;
|
|
await (_loading ??= refresh().whenComplete(() => _loading = null));
|
|
return state;
|
|
}
|
|
|
|
Future<void> refresh() async {
|
|
try {
|
|
final response = await _dio.get<List<int>>(
|
|
'/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<String?> recordClick() async {
|
|
if (!state.isVisible) return null;
|
|
final clickId = const Uuid().v4();
|
|
try {
|
|
await _report('ad_click', clickId: clickId);
|
|
} catch (_) {
|
|
// 归因上报失败不能阻断商城入口。
|
|
}
|
|
return clickId;
|
|
}
|
|
|
|
Future<void> _report(String event, {String clickId = ''}) =>
|
|
_dio.post<List<int>>(
|
|
'/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<MallTabConfigController, MallTabConfig>(
|
|
(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<MallClickAttribution?>(
|
|
(ref) => null,
|
|
);
|
|
|
|
Map<String, dynamic> 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 = <String, dynamic>{
|
|
...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<int> _bytes;
|
|
|
|
int integer(int wanted) =>
|
|
_fields()[wanted] is int ? _fields()[wanted]! as int : 0;
|
|
String string(int wanted) => _fields()[wanted] is List<int>
|
|
? utf8.decode(_fields()[wanted]! as List<int>, allowMalformed: true)
|
|
: '';
|
|
_ProtoMessage message(int wanted) => _ProtoMessage(
|
|
_fields()[wanted] is List<int> ? _fields()[wanted]! as List<int> : const [],
|
|
);
|
|
|
|
Map<int, Object> _fields() {
|
|
final values = <int, Object>{};
|
|
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);
|
|
}
|
|
}
|