import 'dart:convert'; 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 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 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 ads; final String? trackingId; bool get hasAds => enabled && ads.isNotEmpty; } class AdsApiClient { AdsApiClient(this._dio); final Dio _dio; Future getAdResult(String placement) async { final response = await _dio.get>( '/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 listAds(String placement) async { final response = await _dio.get>( '/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 report( AdCreative ad, String event, { String? clickId, String? trackingId, }) => _dio.post>( '/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( (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 bytes; Map>? _cache; Map> 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 ? utf8.decode(value, allowMalformed: true) : ''; } ProtoMessage message(int tag) { final value = _fields[tag]?.lastOrNull; return ProtoMessage(value is List ? value : const []); } List messages(int tag) => (_fields[tag] ?? const []) .whereType>() .map(ProtoMessage.new) .toList(); Map> _read() { final out = >{}; 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? 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); } }