import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; const _pushPresentationChannel = MethodChannel('buzz/push'); const _maximumAvatarSourceBytes = 512 * 1024; const _maximumAvatarPNGBytes = 64 * 1024; Future _avatarEncodeTail = Future.value(); /// The latest best-effort App Group presentation-cache failure. final pushPresentationCacheError = ValueNotifier(null); /// Revalidates a relay event before it crosses into the native cache writer. bool isVerifiedPushPresentationEvent(NostrEvent event) { try { nostr.Event( event.id, event.pubkey, event.createdAt, event.kind, event.tags, event.content, event.sig, ); return true; } catch (_) { return false; } } /// Exports raw verified kind-0 events. Native code verifies them again before storage. Future cacheBuzzPushProfileEvents( String communityID, Iterable events, ) async { if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } final verified = _newestVerifiedEvents( events, kind: 0, scope: (event) => event.pubkey.toLowerCase(), ).values.toList(); if (verified.isEmpty) return; await _invokeBestEffort({ 'section': 'profiles', 'communityId': communityID, 'events': [for (final event in verified) event.toJson()], }); } /// Exports verified channel metadata and membership for native authority checks. Future cacheBuzzPushChannelEvents( String? communityID, Iterable metadataEvents, Iterable membershipEvents, ) async { if (defaultTargetPlatform != TargetPlatform.iOS || communityID == null || communityID.isEmpty) { return; } final batch = selectPushChannelEvents(metadataEvents, membershipEvents); final verifiedMetadata = batch.metadata; final verifiedMembership = batch.membership; if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; await _invokeBestEffort({ 'section': 'channels', 'communityId': communityID, 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], 'membershipEvents': [ for (final event in verifiedMembership) event.toJson(), ], }); } /// Selects the newest paired verified channel metadata and membership events. @visibleForTesting ({List metadata, List membership}) selectPushChannelEvents( Iterable metadataEvents, Iterable membershipEvents, ) { final verifiedMembershipByChannel = _newestVerifiedEvents( membershipEvents, kind: 39002, scope: (event) => event.getTagValue('d'), ); final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); final verifiedMetadataByChannel = _newestVerifiedEvents( metadataEvents, kind: 39000, scope: (event) => event.getTagValue('d'), allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, ); if (selectedChannelIDs.isEmpty) { selectedChannelIDs.addAll(verifiedMetadataByChannel.keys); } final verifiedMetadata = [ for (final entry in verifiedMetadataByChannel.entries) if (selectedChannelIDs.contains(entry.key)) entry.value, ]; final verifiedMembership = [ for (final entry in verifiedMembershipByChannel.entries) if (selectedChannelIDs.contains(entry.key)) entry.value, ]; return (metadata: verifiedMetadata, membership: verifiedMembership); } Map _newestVerifiedEvents( Iterable events, { required int kind, required String? Function(NostrEvent event) scope, Set? allowedScopes, }) { final selected = {}; for (final event in events) { if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; final key = scope(event); if (key == null || key.isEmpty) continue; if (allowedScopes != null && !allowedScopes.contains(key)) continue; final existing = selected[key]; if (existing != null) { if (_isNewerEvent(event, existing)) selected[key] = event; continue; } selected[key] = event; } return selected; } bool _isNewerEvent(NostrEvent candidate, NostrEvent existing) => candidate.createdAt > existing.createdAt || (candidate.createdAt == existing.createdAt && candidate.id.compareTo(existing.id) < 0); /// Reuses bytes already fetched for a visible foreground avatar. /// /// This never starts network I/O. Oversized, malformed, or unsupported images /// are ignored, and notification delivery remains independent of the cache. Future cacheBuzzPushAvatarFromLoadedBytes( String communityID, String sourceURL, Uint8List sourceBytes, ) async { if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty || sourceBytes.isEmpty || sourceBytes.length > _maximumAvatarSourceBytes || !isCacheablePushAvatarSource(sourceURL)) { return; } final previous = _avatarEncodeTail; final release = Completer(); _avatarEncodeTail = release.future; await previous; try { final png = await _boundedAvatarPNG(sourceBytes); if (png == null) return; await _invokeBestEffort({ 'section': 'avatar', 'communityId': communityID, 'sourceUrl': sourceURL, 'png': png, }); } finally { release.complete(); } } Future _invokeBestEffort(Map arguments) async { try { await _pushPresentationChannel.invokeMethod( 'syncPushSnapshot', arguments, ); pushPresentationCacheError.value = null; } on MissingPluginException { // Non-Runner embeddings do not provide the native snapshot bridge. } catch (error, stackTrace) { pushPresentationCacheError.value = error.toString(); debugPrint('Push presentation cache update failed: $error'); debugPrintStack(stackTrace: stackTrace); } } @visibleForTesting bool isCacheablePushAvatarSource(String value) { final trimmed = value.trim(); if (trimmed.startsWith('data:image/')) { try { final data = UriData.parse(trimmed); return data.mimeType.startsWith('image/') && data.mimeType != 'image/svg+xml' && data.contentAsBytes().isNotEmpty; } on FormatException { return false; } } final uri = Uri.tryParse(value.trim()); return uri != null && (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty && uri.userInfo.isEmpty; } Future _boundedAvatarPNG(Uint8List sourceBytes) async { for (final size in const [128, 96, 64, 48]) { ui.Codec? codec; ui.Image? image; try { codec = await ui.instantiateImageCodec( sourceBytes, targetWidth: size, targetHeight: size, allowUpscaling: false, ); final frame = await codec.getNextFrame(); image = frame.image; final data = await image.toByteData(format: ui.ImageByteFormat.png); if (data == null) continue; final png = data.buffer.asUint8List( data.offsetInBytes, data.lengthInBytes, ); if (png.isNotEmpty && png.length <= _maximumAvatarPNGBytes) return png; } catch (_) { return null; } finally { image?.dispose(); codec?.dispose(); } } return null; }