Files
buzz/mobile/lib/shared/push/push_presentation_cache.dart
Tom Brow 3b3e536bd2 refactor(push): simplify internal notification stack
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
2026-08-24 20:25:40 -07:00

241 lines
7.3 KiB
Dart

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<void> _avatarEncodeTail = Future.value();
/// The latest best-effort App Group presentation-cache failure.
final pushPresentationCacheError = ValueNotifier<String?>(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<void> cacheBuzzPushProfileEvents(
String communityID,
Iterable<NostrEvent> 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<void> cacheBuzzPushChannelEvents(
String? communityID,
Iterable<NostrEvent> metadataEvents,
Iterable<NostrEvent> 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<NostrEvent> metadata, List<NostrEvent> membership})
selectPushChannelEvents(
Iterable<NostrEvent> metadataEvents,
Iterable<NostrEvent> 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<String, NostrEvent> _newestVerifiedEvents(
Iterable<NostrEvent> events, {
required int kind,
required String? Function(NostrEvent event) scope,
Set<String>? allowedScopes,
}) {
final selected = <String, NostrEvent>{};
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<void> 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<void>();
_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<void> _invokeBestEffort(Map<String, Object> arguments) async {
try {
await _pushPresentationChannel.invokeMethod<void>(
'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<Uint8List?> _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;
}