Files
jinzhi/lib/features/ads/data/ads_api_client.dart
T
2026-08-24 19:07:31 +08:00

295 lines
8.0 KiB
Dart

import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../../common/network/dio_provider.dart';
const splashAdPlacement = 'splash';
const homeBannerAdPlacement = 'home_banner';
enum AdMediaType { image, gif, video, unsupported }
enum AdOpenMode { external, inApp }
class AdCreative {
const AdCreative({
required this.adId,
required this.placement,
required this.targetApp,
required this.title,
required this.imageUrl,
required this.mediaType,
required this.h5Url,
required this.trackingId,
required this.countdownSeconds,
required this.endAtMs,
required this.trackedUrl,
required this.openMode,
});
final String adId,
placement,
targetApp,
title,
imageUrl,
h5Url,
trackingId,
trackedUrl;
final AdMediaType mediaType;
final int countdownSeconds, endAtMs;
final AdOpenMode openMode;
Uri? get mediaUri => _http(imageUrl);
Uri? get targetUri => _http(trackedUrl) ?? _http(h5Url);
bool get isRenderable =>
adId.isNotEmpty &&
mediaUri != null &&
targetUri != null &&
mediaType != AdMediaType.unsupported &&
(endAtMs <= 0 || endAtMs > DateTime.now().millisecondsSinceEpoch);
Uri? clickUri(String clickId) {
final uri = targetUri;
if (uri == null) return null;
return uri.replace(
queryParameters: {
...uri.queryParameters,
'ad_id': adId,
'placement': placement,
'tracking_id': trackingId,
'click_id': clickId,
'target_app': targetApp,
'hook_type': placement,
'ts': DateTime.now().millisecondsSinceEpoch.toString(),
},
);
}
Map<String, dynamic> toJson() => {
'adId': adId,
'placement': placement,
'targetApp': targetApp,
'title': title,
'imageUrl': imageUrl,
'mediaType': mediaType.index,
'h5Url': h5Url,
'trackingId': trackingId,
'countdownSeconds': countdownSeconds,
'endAtMs': endAtMs,
'trackedUrl': trackedUrl,
'openMode': openMode.index,
};
factory AdCreative.fromJson(Map<String, dynamic> value) => AdCreative(
adId: '${value['adId'] ?? ''}',
placement: '${value['placement'] ?? ''}',
targetApp: '${value['targetApp'] ?? ''}',
title: '${value['title'] ?? ''}',
imageUrl: '${value['imageUrl'] ?? ''}',
mediaType:
AdMediaType.values[(value['mediaType'] as num?)?.toInt().clamp(
0,
AdMediaType.values.length - 1,
) ??
AdMediaType.unsupported.index],
h5Url: '${value['h5Url'] ?? ''}',
trackingId: '${value['trackingId'] ?? ''}',
countdownSeconds: (value['countdownSeconds'] as num?)?.toInt() ?? 0,
endAtMs: (value['endAtMs'] as num?)?.toInt() ?? 0,
trackedUrl: '${value['trackedUrl'] ?? ''}',
openMode: (value['openMode'] as num?)?.toInt() == AdOpenMode.inApp.index
? AdOpenMode.inApp
: AdOpenMode.external,
);
factory AdCreative.fromProto(ProtoMessage fields) => AdCreative(
adId: fields.string(1),
placement: fields.string(2),
targetApp: fields.string(3),
title: fields.string(4),
imageUrl: fields.string(6),
mediaType: switch (fields.integer(7)) {
1 => AdMediaType.image,
2 => AdMediaType.gif,
3 => AdMediaType.video,
_ => AdMediaType.unsupported,
},
h5Url: fields.string(9),
trackingId: fields.string(10),
countdownSeconds: fields.integer(11),
endAtMs: fields.integer(13),
trackedUrl: fields.string(14),
openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external,
);
}
class GetAdResult {
const GetAdResult(this.enabled, this.ad, this.dailyCap);
final bool enabled;
final AdCreative? ad;
final int dailyCap;
}
class FeaturedAdsState {
const FeaturedAdsState(this.enabled, this.ads, {this.trackingId});
final bool enabled;
final List<AdCreative> ads;
final String? trackingId;
bool get hasAds => enabled && ads.isNotEmpty;
}
class AdsApiClient {
AdsApiClient(this._dio);
final Dio _dio;
Future<GetAdResult> getAdResult(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final creative = root.message(2);
final ad = AdCreative.fromProto(creative);
return GetAdResult(
root.integer(1) != 0,
ad.isRenderable ? ad : null,
root.integer(3),
);
}
Future<FeaturedAdsState> listAds(String placement) async {
final response = await _dio.get<List<int>>(
'/api/v1/ads/list',
queryParameters: {'placement': placement},
options: protobufOptions(),
);
final root = ProtoMessage(response.data ?? const []);
final ads = root
.messages(2)
.map(AdCreative.fromProto)
.where((ad) => ad.isRenderable)
.toList(growable: false);
return FeaturedAdsState(
root.integer(1) != 0,
ads,
trackingId: root.string(3).isEmpty ? null : root.string(3),
);
}
Future<void> report(
AdCreative ad,
String event, {
String? clickId,
String? trackingId,
}) => _dio.post<List<int>>(
'/api/v1/ads/events',
data: _Writer()
.string(1, event)
.string(2, ad.placement)
.string(3, ad.adId)
.string(4, trackingId ?? ad.trackingId)
.string(5, clickId ?? '')
.integer(6, DateTime.now().millisecondsSinceEpoch)
.toBytes(),
options: protobufOptions(),
);
String newClickId() => const Uuid().v4();
}
final adsApiClientProvider = Provider(
(ref) => AdsApiClient(ref.watch(dioProvider)),
);
final featuredAdsProvider = FutureProvider.autoDispose
.family<FeaturedAdsState, String>(
(ref, placement) => ref.watch(adsApiClientProvider).listAds(placement),
);
Uri? _http(String raw) {
final uri = Uri.tryParse(raw.trim());
return uri != null &&
uri.host.isNotEmpty &&
(uri.scheme == 'https' || uri.scheme == 'http')
? uri
: null;
}
class ProtoMessage {
ProtoMessage(this.bytes);
final List<int> bytes;
Map<int, List<Object>>? _cache;
Map<int, List<Object>> get _fields => _cache ??= _read();
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? String.fromCharCodes(value) : '';
}
ProtoMessage message(int tag) {
final value = _fields[tag]?.lastOrNull;
return ProtoMessage(value is List<int> ? value : const []);
}
List<ProtoMessage> messages(int tag) => (_fields[tag] ?? const [])
.whereType<List<int>>()
.map(ProtoMessage.new)
.toList();
Map<int, List<Object>> _read() {
final out = <int, List<Object>>{};
var i = 0;
int v() {
var n = 0, s = 0;
while (i < bytes.length) {
final b = bytes[i++];
n |= (b & 127) << s;
if (b < 128) return n;
s += 7;
}
throw const FormatException('Invalid protobuf');
}
while (i < bytes.length) {
final key = v(), tag = key >> 3;
Object value;
if ((key & 7) == 0) {
value = v();
} else if ((key & 7) == 2) {
final l = v(), end = i + l;
if (end > bytes.length) throw const FormatException('Invalid protobuf');
value = bytes.sublist(i, end);
i = end;
} else {
throw const FormatException('Unsupported protobuf');
}
(out[tag] ??= []).add(value);
}
return out;
}
}
extension on List<Object> {
Object? get lastOrNull => isEmpty ? null : last;
}
class _Writer {
final b = BytesBuilder(copy: false);
_Writer string(int t, String value) {
final d = value.codeUnits;
v((t << 3) | 2);
v(d.length);
b.add(d);
return this;
}
_Writer integer(int t, int value) {
v(t << 3);
v(value);
return this;
}
Uint8List toBytes() => b.toBytes();
void v(int n) {
while (n > 127) {
b.addByte((n & 127) | 128);
n >>= 7;
}
b.addByte(n);
}
}