295 lines
8.0 KiB
Dart
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);
|
|
}
|
|
}
|