feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const _pngSignature = <int>[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const _allowedPngAncillaryChunks = {
|
||||
'cHRM',
|
||||
'gAMA',
|
||||
'sBIT',
|
||||
'sRGB',
|
||||
'bKGD',
|
||||
'hIST',
|
||||
'tRNS',
|
||||
'sPLT',
|
||||
'acTL',
|
||||
'fcTL',
|
||||
'fdAT',
|
||||
};
|
||||
const _allowedWebpChunks = {'VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF'};
|
||||
const _webpMetadataFlags = 0x20 | 0x08 | 0x04;
|
||||
|
||||
/// Remove metadata from an animated image without decoding its frames.
|
||||
///
|
||||
/// Decoding through UIKit or Android Bitmap would flatten animations. These
|
||||
/// structural scrubbers retain only the chunks/extensions accepted by the
|
||||
/// relay and preserve animation timing, disposal, looping, and frame data.
|
||||
Uint8List sanitizeAnimatedImageForUpload(Uint8List bytes, String mimeType) {
|
||||
return switch (mimeType) {
|
||||
'image/gif' => _scrubGif(bytes),
|
||||
'image/png' => _scrubPng(bytes),
|
||||
'image/webp' => _scrubWebp(bytes),
|
||||
_ => throw FormatException('Unsupported animated image type: $mimeType'),
|
||||
};
|
||||
}
|
||||
|
||||
Uint8List _scrubPng(Uint8List bytes) {
|
||||
if (!_startsWith(bytes, _pngSignature)) {
|
||||
throw const FormatException('Invalid PNG signature');
|
||||
}
|
||||
|
||||
final output = BytesBuilder(copy: false)..add(_pngSignature);
|
||||
var offset = _pngSignature.length;
|
||||
while (offset < bytes.length) {
|
||||
if (bytes.length - offset < 12) {
|
||||
throw const FormatException('Truncated PNG chunk');
|
||||
}
|
||||
final payloadLength = _readUint32BigEndian(bytes, offset);
|
||||
final chunkLength = payloadLength + 12;
|
||||
if (payloadLength > bytes.length - offset - 12) {
|
||||
throw const FormatException('Invalid PNG chunk length');
|
||||
}
|
||||
final typeStart = offset + 4;
|
||||
final type = ascii.decode(bytes.sublist(typeStart, typeStart + 4));
|
||||
if (type == 'iCCP') {
|
||||
throw const FormatException(
|
||||
'Animated PNG ICC profile cannot be removed safely',
|
||||
);
|
||||
}
|
||||
if (type == 'eXIf') {
|
||||
final orientation = _readExifOrientation(
|
||||
bytes,
|
||||
offset + 8,
|
||||
payloadLength,
|
||||
);
|
||||
if (orientation != null && orientation >= 2 && orientation <= 8) {
|
||||
throw const FormatException(
|
||||
'Animated PNG EXIF orientation cannot be removed safely',
|
||||
);
|
||||
}
|
||||
}
|
||||
final isAncillary = bytes[typeStart] & 0x20 != 0;
|
||||
if (!isAncillary || _allowedPngAncillaryChunks.contains(type)) {
|
||||
output.add(Uint8List.sublistView(bytes, offset, offset + chunkLength));
|
||||
}
|
||||
|
||||
offset += chunkLength;
|
||||
if (type == 'IEND') {
|
||||
return output.takeBytes();
|
||||
}
|
||||
}
|
||||
|
||||
throw const FormatException('PNG is missing IEND');
|
||||
}
|
||||
|
||||
Uint8List _scrubWebp(Uint8List bytes) {
|
||||
if (bytes.length < 12 ||
|
||||
!_matchesAscii(bytes, 0, 'RIFF') ||
|
||||
!_matchesAscii(bytes, 8, 'WEBP')) {
|
||||
throw const FormatException('Invalid WebP signature');
|
||||
}
|
||||
|
||||
final declaredLength = _readUint32LittleEndian(bytes, 4);
|
||||
final inputEnd = declaredLength + 8;
|
||||
if (inputEnd < 12 || inputEnd > bytes.length) {
|
||||
throw const FormatException('Invalid WebP container length');
|
||||
}
|
||||
|
||||
final chunks = BytesBuilder(copy: false);
|
||||
var offset = 12;
|
||||
while (offset < inputEnd) {
|
||||
if (inputEnd - offset < 8) {
|
||||
throw const FormatException('Truncated WebP chunk');
|
||||
}
|
||||
final type = ascii.decode(bytes.sublist(offset, offset + 4));
|
||||
final payloadLength = _readUint32LittleEndian(bytes, offset + 4);
|
||||
final payloadStart = offset + 8;
|
||||
final paddedLength = payloadLength + (payloadLength.isOdd ? 1 : 0);
|
||||
final chunkEnd = payloadStart + paddedLength;
|
||||
if (chunkEnd > inputEnd) {
|
||||
throw const FormatException('Invalid WebP chunk length');
|
||||
}
|
||||
|
||||
if (type == 'EXIF') {
|
||||
final orientation = _readExifOrientation(
|
||||
bytes,
|
||||
payloadStart,
|
||||
payloadLength,
|
||||
);
|
||||
if (orientation != null && orientation >= 2 && orientation <= 8) {
|
||||
throw const FormatException(
|
||||
'Animated WebP EXIF orientation cannot be removed safely',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (type == 'ICCP') {
|
||||
throw const FormatException(
|
||||
'Animated WebP ICC profile cannot be removed safely',
|
||||
);
|
||||
}
|
||||
|
||||
if (_allowedWebpChunks.contains(type)) {
|
||||
if (type == 'VP8X') {
|
||||
if (payloadLength == 0) {
|
||||
throw const FormatException('Invalid VP8X chunk');
|
||||
}
|
||||
final payload = BytesBuilder(copy: false)
|
||||
..addByte(bytes[payloadStart] & ~_webpMetadataFlags)
|
||||
..add(
|
||||
Uint8List.sublistView(
|
||||
bytes,
|
||||
payloadStart + 1,
|
||||
payloadStart + payloadLength,
|
||||
),
|
||||
);
|
||||
_addWebpChunk(chunks, type, payload.takeBytes());
|
||||
} else if (type == 'ANMF') {
|
||||
_addWebpChunk(
|
||||
chunks,
|
||||
type,
|
||||
_scrubAnmfPayload(
|
||||
Uint8List.sublistView(
|
||||
bytes,
|
||||
payloadStart,
|
||||
payloadStart + payloadLength,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_addWebpChunk(
|
||||
chunks,
|
||||
type,
|
||||
Uint8List.sublistView(
|
||||
bytes,
|
||||
payloadStart,
|
||||
payloadStart + payloadLength,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
|
||||
final chunkBytes = chunks.takeBytes();
|
||||
final output = BytesBuilder(copy: false)
|
||||
..add(ascii.encode('RIFF'))
|
||||
..add(_uint32LittleEndian(chunkBytes.length + 4))
|
||||
..add(ascii.encode('WEBP'))
|
||||
..add(chunkBytes);
|
||||
return output.takeBytes();
|
||||
}
|
||||
|
||||
void _addWebpChunk(BytesBuilder output, String type, Uint8List payload) {
|
||||
output
|
||||
..add(ascii.encode(type))
|
||||
..add(_uint32LittleEndian(payload.length))
|
||||
..add(payload);
|
||||
if (payload.length.isOdd) output.addByte(0);
|
||||
}
|
||||
|
||||
Uint8List _scrubAnmfPayload(Uint8List payload) {
|
||||
const frameHeaderLength = 16;
|
||||
if (payload.length < frameHeaderLength) {
|
||||
throw const FormatException('Invalid WebP animation frame');
|
||||
}
|
||||
|
||||
final output = BytesBuilder(copy: false)
|
||||
..add(Uint8List.sublistView(payload, 0, frameHeaderLength));
|
||||
var offset = frameHeaderLength;
|
||||
var sawAlpha = false;
|
||||
var sawImage = false;
|
||||
|
||||
while (offset < payload.length) {
|
||||
if (payload.length - offset < 8) {
|
||||
throw const FormatException('Truncated WebP animation frame chunk');
|
||||
}
|
||||
final chunkLength = _readUint32LittleEndian(payload, offset + 4);
|
||||
final chunkStart = offset + 8;
|
||||
final paddedLength = chunkLength + (chunkLength.isOdd ? 1 : 0);
|
||||
final chunkEnd = chunkStart + paddedLength;
|
||||
if (chunkEnd > payload.length) {
|
||||
throw const FormatException('Invalid WebP animation frame chunk length');
|
||||
}
|
||||
final chunkPayload = Uint8List.sublistView(
|
||||
payload,
|
||||
chunkStart,
|
||||
chunkStart + chunkLength,
|
||||
);
|
||||
|
||||
if (_matchesAscii(payload, offset, 'ALPH')) {
|
||||
if (sawAlpha || sawImage) {
|
||||
throw const FormatException('Invalid WebP animation frame layout');
|
||||
}
|
||||
_addWebpChunk(output, 'ALPH', chunkPayload);
|
||||
sawAlpha = true;
|
||||
} else if (_matchesAscii(payload, offset, 'VP8 ')) {
|
||||
if (sawImage) {
|
||||
throw const FormatException('Invalid WebP animation frame layout');
|
||||
}
|
||||
_addWebpChunk(output, 'VP8 ', chunkPayload);
|
||||
sawImage = true;
|
||||
} else if (_matchesAscii(payload, offset, 'VP8L')) {
|
||||
if (sawAlpha || sawImage) {
|
||||
throw const FormatException('Invalid WebP animation frame layout');
|
||||
}
|
||||
_addWebpChunk(output, 'VP8L', chunkPayload);
|
||||
sawImage = true;
|
||||
}
|
||||
|
||||
offset = chunkEnd;
|
||||
}
|
||||
|
||||
if (!sawImage) {
|
||||
throw const FormatException('WebP animation frame is missing image data');
|
||||
}
|
||||
return output.takeBytes();
|
||||
}
|
||||
|
||||
int? _readExifOrientation(
|
||||
Uint8List bytes,
|
||||
int payloadStart,
|
||||
int payloadLength,
|
||||
) {
|
||||
final payloadEnd = payloadStart + payloadLength;
|
||||
var tiffStart = payloadStart;
|
||||
if (payloadLength >= 6 &&
|
||||
_matchesAscii(bytes, payloadStart, 'Exif') &&
|
||||
bytes[payloadStart + 4] == 0 &&
|
||||
bytes[payloadStart + 5] == 0) {
|
||||
tiffStart += 6;
|
||||
}
|
||||
if (payloadEnd - tiffStart < 8) return null;
|
||||
|
||||
final endian = switch ((bytes[tiffStart], bytes[tiffStart + 1])) {
|
||||
(0x49, 0x49) => Endian.little,
|
||||
(0x4d, 0x4d) => Endian.big,
|
||||
_ => null,
|
||||
};
|
||||
if (endian == null) return null;
|
||||
|
||||
int? readUint16(int offset) {
|
||||
if (offset < tiffStart || offset + 2 > payloadEnd) return null;
|
||||
return ByteData.sublistView(bytes, offset, offset + 2).getUint16(0, endian);
|
||||
}
|
||||
|
||||
int? readUint32(int offset) {
|
||||
if (offset < tiffStart || offset + 4 > payloadEnd) return null;
|
||||
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0, endian);
|
||||
}
|
||||
|
||||
if (readUint16(tiffStart + 2) != 42) return null;
|
||||
final ifdOffset = readUint32(tiffStart + 4);
|
||||
if (ifdOffset == null) return null;
|
||||
final ifdStart = tiffStart + ifdOffset;
|
||||
final entryCount = readUint16(ifdStart);
|
||||
if (entryCount == null) return null;
|
||||
|
||||
final entriesStart = ifdStart + 2;
|
||||
for (var index = 0; index < entryCount; index += 1) {
|
||||
final entryStart = entriesStart + index * 12;
|
||||
if (readUint16(entryStart) == 0x0112 &&
|
||||
readUint16(entryStart + 2) == 3 &&
|
||||
readUint32(entryStart + 4) == 1) {
|
||||
return readUint16(entryStart + 8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Uint8List _scrubGif(Uint8List bytes) {
|
||||
if (bytes.length < 13 ||
|
||||
(!_matchesAscii(bytes, 0, 'GIF87a') &&
|
||||
!_matchesAscii(bytes, 0, 'GIF89a'))) {
|
||||
throw const FormatException('Invalid GIF signature');
|
||||
}
|
||||
|
||||
var offset = 13;
|
||||
final packed = bytes[10];
|
||||
if (packed & 0x80 != 0) {
|
||||
final tableLength = 3 << ((packed & 0x07) + 1);
|
||||
offset += tableLength;
|
||||
if (offset > bytes.length) {
|
||||
throw const FormatException('Truncated GIF color table');
|
||||
}
|
||||
}
|
||||
|
||||
final segments = <Uint8List?>[Uint8List.sublistView(bytes, 0, offset)];
|
||||
final pendingGraphicControls = <int>[];
|
||||
|
||||
while (offset < bytes.length) {
|
||||
switch (bytes[offset]) {
|
||||
case 0x2c:
|
||||
final start = offset;
|
||||
if (bytes.length - offset < 10) {
|
||||
throw const FormatException('Truncated GIF image descriptor');
|
||||
}
|
||||
final imagePacked = bytes[offset + 9];
|
||||
offset += 10;
|
||||
if (imagePacked & 0x80 != 0) {
|
||||
offset += 3 << ((imagePacked & 0x07) + 1);
|
||||
if (offset > bytes.length) {
|
||||
throw const FormatException('Truncated GIF local color table');
|
||||
}
|
||||
}
|
||||
if (offset >= bytes.length) {
|
||||
throw const FormatException('Missing GIF LZW code size');
|
||||
}
|
||||
offset = _gifSubBlocksEnd(bytes, offset + 1);
|
||||
segments.add(Uint8List.sublistView(bytes, start, offset));
|
||||
pendingGraphicControls.clear();
|
||||
case 0x21:
|
||||
final start = offset;
|
||||
if (bytes.length - offset < 2) {
|
||||
throw const FormatException('Truncated GIF extension');
|
||||
}
|
||||
final label = bytes[offset + 1];
|
||||
offset += 2;
|
||||
switch (label) {
|
||||
case 0xf9:
|
||||
if (bytes.length - offset < 6 ||
|
||||
bytes[offset] != 4 ||
|
||||
bytes[offset + 5] != 0) {
|
||||
throw const FormatException('Invalid GIF graphic control');
|
||||
}
|
||||
offset += 6;
|
||||
segments.add(Uint8List.sublistView(bytes, start, offset));
|
||||
pendingGraphicControls.add(segments.length - 1);
|
||||
case 0xff:
|
||||
if (bytes.length - offset < 12 || bytes[offset] != 11) {
|
||||
throw const FormatException('Invalid GIF application extension');
|
||||
}
|
||||
final isLoopExtension =
|
||||
_matchesAscii(bytes, offset + 1, 'NETSCAPE2.0') ||
|
||||
_matchesAscii(bytes, offset + 1, 'ANIMEXTS1.0');
|
||||
final dataStart = offset + 12;
|
||||
offset = _gifSubBlocksEnd(bytes, dataStart);
|
||||
if (isLoopExtension) {
|
||||
if (bytes.length - dataStart < 5 ||
|
||||
bytes[dataStart] != 3 ||
|
||||
bytes[dataStart + 1] != 1) {
|
||||
throw const FormatException('Invalid GIF loop extension');
|
||||
}
|
||||
segments
|
||||
..add(Uint8List.sublistView(bytes, start, dataStart + 4))
|
||||
..add(Uint8List(1));
|
||||
}
|
||||
case 0x01:
|
||||
offset = _gifSubBlocksEnd(bytes, offset);
|
||||
for (final segmentIndex in pendingGraphicControls) {
|
||||
segments[segmentIndex] = null;
|
||||
}
|
||||
pendingGraphicControls.clear();
|
||||
default:
|
||||
offset = _gifSubBlocksEnd(bytes, offset);
|
||||
}
|
||||
case 0x3b:
|
||||
segments.add(Uint8List.sublistView(bytes, offset, offset + 1));
|
||||
final output = BytesBuilder(copy: false);
|
||||
for (final segment in segments) {
|
||||
if (segment != null) output.add(segment);
|
||||
}
|
||||
return output.takeBytes();
|
||||
default:
|
||||
throw const FormatException('Invalid GIF block');
|
||||
}
|
||||
}
|
||||
|
||||
throw const FormatException('GIF is missing trailer');
|
||||
}
|
||||
|
||||
int _gifSubBlocksEnd(Uint8List bytes, int offset) {
|
||||
while (offset < bytes.length) {
|
||||
final blockLength = bytes[offset];
|
||||
offset += 1;
|
||||
if (blockLength == 0) return offset;
|
||||
offset += blockLength;
|
||||
if (offset > bytes.length) {
|
||||
throw const FormatException('Truncated GIF data block');
|
||||
}
|
||||
}
|
||||
throw const FormatException('GIF data blocks are missing a terminator');
|
||||
}
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> prefix) {
|
||||
if (bytes.length < prefix.length) return false;
|
||||
for (var index = 0; index < prefix.length; index += 1) {
|
||||
if (bytes[index] != prefix[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _matchesAscii(Uint8List bytes, int offset, String value) {
|
||||
final expected = ascii.encode(value);
|
||||
if (bytes.length - offset < expected.length) return false;
|
||||
for (var index = 0; index < expected.length; index += 1) {
|
||||
if (bytes[offset + index] != expected[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int _readUint32BigEndian(Uint8List bytes, int offset) {
|
||||
return ByteData.sublistView(bytes, offset, offset + 4).getUint32(0);
|
||||
}
|
||||
|
||||
int _readUint32LittleEndian(Uint8List bytes, int offset) {
|
||||
return ByteData.sublistView(
|
||||
bytes,
|
||||
offset,
|
||||
offset + 4,
|
||||
).getUint32(0, Endian.little);
|
||||
}
|
||||
|
||||
Uint8List _uint32LittleEndian(int value) {
|
||||
return Uint8List(4)..buffer.asByteData().setUint32(0, value, Endian.little);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'relay_session.dart';
|
||||
|
||||
/// Tracks the app lifecycle state and drives websocket connect/disconnect
|
||||
/// behavior for mobile battery efficiency.
|
||||
class AppLifecycleNotifier extends Notifier<AppLifecycleState> {
|
||||
AppLifecycleListener? _listener;
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
|
||||
@override
|
||||
AppLifecycleState build() {
|
||||
_listener = AppLifecycleListener(onStateChange: _onStateChange);
|
||||
|
||||
// Watch connectivity changes to trigger reconnect on network restore.
|
||||
_connectivitySub = Connectivity().onConnectivityChanged.listen((results) {
|
||||
final hasNetwork = results.any((r) => r != ConnectivityResult.none);
|
||||
if (hasNetwork && state == AppLifecycleState.resumed) {
|
||||
ref.read(relaySessionProvider.notifier).onAppResumed();
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
_listener?.dispose();
|
||||
_connectivitySub?.cancel();
|
||||
});
|
||||
|
||||
return AppLifecycleState.resumed;
|
||||
}
|
||||
|
||||
void _onStateChange(AppLifecycleState newState) {
|
||||
state = newState;
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
switch (newState) {
|
||||
case AppLifecycleState.resumed:
|
||||
session.onAppResumed();
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
session.onAppPaused();
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
break; // Brief transition states — no action.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appLifecycleProvider =
|
||||
NotifierProvider<AppLifecycleNotifier, AppLifecycleState>(
|
||||
AppLifecycleNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Reads an identity-scoped preference, migrating values left under a
|
||||
/// pre-canonicalization relay origin.
|
||||
///
|
||||
/// Identity-scoped keys embed the relay origin, and that origin used to be
|
||||
/// whatever scheme the onboarding flow happened to persist — `wss://` for an
|
||||
/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl]
|
||||
/// canonicalizes the scheme, an install that joined by invite would compute a
|
||||
/// different key than the one its data was written under, leaving that data on
|
||||
/// disk but unreachable.
|
||||
///
|
||||
/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is
|
||||
/// returned straight away and promoted onto the canonical key in the
|
||||
/// background. The legacy entry is removed only once the copy reports success,
|
||||
/// so a migration interrupted mid-flight leaves the original readable and the
|
||||
/// next read simply retries.
|
||||
///
|
||||
/// Pass matching accessors for the stored type — `getString`/`setString`, or
|
||||
/// `getStringList`/`setStringList`.
|
||||
T? readMigratedPref<T>(
|
||||
SharedPreferences prefs, {
|
||||
required String canonicalKey,
|
||||
required String legacyKey,
|
||||
required T? Function(String key) read,
|
||||
required Future<bool> Function(String key, T value) write,
|
||||
}) {
|
||||
final canonical = read(canonicalKey);
|
||||
if (canonical != null) return canonical;
|
||||
|
||||
// Pairing-created communities already store an HTTP origin, so the two keys
|
||||
// coincide and there is nothing to migrate.
|
||||
if (canonicalKey == legacyKey) return null;
|
||||
|
||||
final legacy = read(legacyKey);
|
||||
if (legacy == null) return null;
|
||||
|
||||
unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy)));
|
||||
return legacy;
|
||||
}
|
||||
|
||||
Future<void> _promote(
|
||||
SharedPreferences prefs,
|
||||
String legacyKey,
|
||||
Future<bool> copy,
|
||||
) async {
|
||||
if (await copy) await prefs.remove(legacyKey);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import 'relay_provider.dart';
|
||||
|
||||
const _mediaGetAuthKind = 24242;
|
||||
const _mediaGetAuthLifetimeSeconds = 600;
|
||||
|
||||
/// Re-sign this long before the cached auth event expires, so an in-flight
|
||||
/// request signed just before the boundary still lands well within validity.
|
||||
const _mediaGetAuthRefreshMarginSeconds = 60;
|
||||
|
||||
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
|
||||
///
|
||||
/// Returns an empty map for non-relay URLs or when no signing key is available,
|
||||
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
|
||||
/// leaking Buzz credentials to third-party hosts.
|
||||
///
|
||||
/// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds]
|
||||
/// before expiry: repeated calls return the byte-identical map instead of
|
||||
/// producing a fresh Schnorr signature per widget build. The service itself is
|
||||
/// rebuilt (dropping the memo) whenever the relay config — base URL or signing
|
||||
/// identity — changes, via [mediaGetAuthServiceProvider].
|
||||
class MediaGetAuthService {
|
||||
final String _baseUrl;
|
||||
final String? _nsec;
|
||||
final DateTime Function() _now;
|
||||
|
||||
Map<String, String>? _cachedHeaders;
|
||||
DateTime? _refreshAt;
|
||||
|
||||
MediaGetAuthService({
|
||||
required String baseUrl,
|
||||
required String? nsec,
|
||||
DateTime Function()? now,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_nsec = nsec,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
bool isRelayMediaUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
final relayUri = Uri.tryParse(_baseUrl);
|
||||
if (uri == null || relayUri == null) return false;
|
||||
return _isRelayMediaUrl(uri, relayUri);
|
||||
}
|
||||
|
||||
Map<String, String> headersFor(String url) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return const {};
|
||||
if (!isRelayMediaUrl(url)) return const {};
|
||||
|
||||
final cached = _cachedHeaders;
|
||||
final refreshAt = _refreshAt;
|
||||
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
final signedAt = _now();
|
||||
final authEvent = _buildGetAuthEvent(nsec);
|
||||
final encoded = base64Url
|
||||
.encode(utf8.encode(authEvent.toJson()))
|
||||
.replaceAll('=', '');
|
||||
final headers = Map<String, String>.unmodifiable({
|
||||
'Authorization': 'Nostr $encoded',
|
||||
});
|
||||
_cachedHeaders = headers;
|
||||
_refreshAt = signedAt.add(
|
||||
const Duration(
|
||||
seconds:
|
||||
_mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds,
|
||||
),
|
||||
);
|
||||
return headers;
|
||||
} catch (_) {
|
||||
// Read auth is best-effort: while the relay rollout flag is off, an
|
||||
// unsigned fetch still works. Once the flag is on, this request will 403
|
||||
// instead of crashing the widget tree because local key material is bad.
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRelayMediaUrl(Uri uri, Uri relayUri) {
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
|
||||
if (uri.host.isEmpty || relayUri.host.isEmpty) return false;
|
||||
// Extract the URL's origin and path. Query strings are ignored for media
|
||||
// host/path detection, matching the fetch target shape used by descriptors.
|
||||
final base = '${uri.scheme}://${uri.authority}';
|
||||
final mediaAuthority = extractServerAuthority(base);
|
||||
final relayAuthority = extractServerAuthority(_baseUrl);
|
||||
if (mediaAuthority == null || relayAuthority == null) return false;
|
||||
if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return uri.path.startsWith('/media/');
|
||||
}
|
||||
|
||||
nostr.Event _buildGetAuthEvent(String nsec) {
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
|
||||
final expiration =
|
||||
(_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds;
|
||||
final tags = <List<String>>[
|
||||
['t', 'get'],
|
||||
['expiration', '$expiration'],
|
||||
if (extractServerAuthority(_baseUrl) case final authority?)
|
||||
['server', authority],
|
||||
];
|
||||
|
||||
return nostr.Event.from(
|
||||
kind: _mediaGetAuthKind,
|
||||
content: 'Get buzz-media',
|
||||
tags: tags,
|
||||
secretKey: privkeyHex,
|
||||
verify: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec);
|
||||
});
|
||||
|
||||
Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
|
||||
return ref.read(mediaGetAuthServiceProvider).headersFor(url);
|
||||
}
|
||||
|
||||
Map<String, String> mediaGetHeadersForContext(
|
||||
BuildContext context,
|
||||
String url,
|
||||
) {
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
return container.read(mediaGetAuthServiceProvider).headersFor(url);
|
||||
}
|
||||
|
||||
String? extractServerAuthority(String baseUrl) {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
if (uri.host.isEmpty) return null;
|
||||
final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host;
|
||||
final port = uri.hasPort ? uri.port : null;
|
||||
final authority = port == null ? host : '$host:$port';
|
||||
return _normalizeAuthority(authority);
|
||||
}
|
||||
|
||||
String _normalizeAuthority(String authority) {
|
||||
var normalized = authority.trim().toLowerCase();
|
||||
if (normalized.endsWith('.')) {
|
||||
normalized = normalized.substring(0, normalized.length - 1);
|
||||
}
|
||||
if (normalized.endsWith(':443')) {
|
||||
return normalized.substring(0, normalized.length - ':443'.length);
|
||||
}
|
||||
if (normalized.endsWith(':80')) {
|
||||
return normalized.substring(0, normalized.length - ':80'.length);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'media_auth.dart';
|
||||
|
||||
/// Shared, keep-alive HTTP client for media fetches. One client per container
|
||||
/// so TLS connections are reused across images instead of re-handshaking per
|
||||
/// fetch. Override in tests to stub the network.
|
||||
final mediaHttpClientProvider = Provider<http.Client>((ref) {
|
||||
final client = http.Client();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
});
|
||||
|
||||
/// An [ImageProvider] for relay-hosted (and arbitrary remote) media that fixes
|
||||
/// the fetch-stampede failure modes of `Image.network` + per-build auth
|
||||
/// headers:
|
||||
///
|
||||
/// 1. **Stable cache identity.** Flutter's [NetworkImage] includes the full
|
||||
/// headers map in its `==`/`hashCode`, so a freshly signed Authorization
|
||||
/// header on every widget build made the *same URL* a new cache key and
|
||||
/// bypassed the in-memory [ImageCache] entirely. This provider keys on
|
||||
/// (url, scale, auth scope) and injects auth headers at *fetch* time, so
|
||||
/// header refreshes never invalidate cached images.
|
||||
/// 2. **Failure cooldown.** A URL that fails to load is not retried until a
|
||||
/// cooldown elapses (honoring `Retry-After` on 429), so error retries on
|
||||
/// rebuild cannot amplify into a request storm against the rate limiter.
|
||||
///
|
||||
/// The auth scope (relay base URL + signing identity) is part of key equality:
|
||||
/// switching relay or account can never reuse another identity's cached bytes.
|
||||
class MediaImageProvider extends ImageProvider<MediaImageProvider> {
|
||||
final String url;
|
||||
final double scale;
|
||||
final MediaGetAuthService auth;
|
||||
|
||||
/// Excluded from equality: transport, not identity.
|
||||
final http.Client client;
|
||||
|
||||
const MediaImageProvider({
|
||||
required this.url,
|
||||
required this.auth,
|
||||
required this.client,
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
static const _defaultCooldown = Duration(seconds: 30);
|
||||
static const _maxRetryAfter = Duration(minutes: 5);
|
||||
static final Map<String, DateTime> _cooldownUntil = {};
|
||||
|
||||
/// Injectable clock for cooldown tests.
|
||||
@visibleForTesting
|
||||
static DateTime Function() debugNow = DateTime.now;
|
||||
|
||||
@visibleForTesting
|
||||
static void debugResetCooldowns() => _cooldownUntil.clear();
|
||||
|
||||
@override
|
||||
Future<MediaImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<MediaImageProvider>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
MediaImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
debugLabel: url,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadAsync(
|
||||
MediaImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
try {
|
||||
final until = _cooldownUntil[url];
|
||||
if (until != null) {
|
||||
if (debugNow().isBefore(until)) {
|
||||
throw MediaImageCooldownException(url: url, until: until);
|
||||
}
|
||||
_cooldownUntil.remove(url);
|
||||
}
|
||||
|
||||
final uri = Uri.parse(url);
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await client.get(uri, headers: auth.headersFor(url));
|
||||
} catch (_) {
|
||||
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
||||
rethrow;
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
_cooldownUntil[url] = debugNow().add(_cooldownFor(response));
|
||||
throw NetworkImageLoadException(
|
||||
statusCode: response.statusCode,
|
||||
uri: uri,
|
||||
);
|
||||
}
|
||||
final bytes = response.bodyBytes;
|
||||
if (bytes.isEmpty) {
|
||||
_cooldownUntil[url] = debugNow().add(_defaultCooldown);
|
||||
throw NetworkImageLoadException(statusCode: 200, uri: uri);
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
return decode(buffer);
|
||||
} catch (_) {
|
||||
// Match NetworkImage: make sure an errored key is not retained in the
|
||||
// cache. The cooldown map (not the cache) throttles retries.
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Duration _cooldownFor(http.Response response) {
|
||||
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
|
||||
if (retryAfter != null && retryAfter > 0) {
|
||||
final requested = Duration(seconds: retryAfter);
|
||||
return requested > _maxRetryAfter ? _maxRetryAfter : requested;
|
||||
}
|
||||
return _defaultCooldown;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is MediaImageProvider &&
|
||||
other.url == url &&
|
||||
other.scale == scale &&
|
||||
other.auth == auth;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(url, scale, auth);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MediaImageProvider("$url", scale: ${scale.toStringAsFixed(1)})';
|
||||
}
|
||||
|
||||
/// Thrown when a fetch is suppressed because the URL recently failed.
|
||||
class MediaImageCooldownException implements Exception {
|
||||
final String url;
|
||||
final DateTime until;
|
||||
|
||||
const MediaImageCooldownException({required this.url, required this.until});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MediaImageCooldownException: $url is cooling down until $until';
|
||||
}
|
||||
|
||||
/// Drop-in replacement for the media `Image.network` call sites.
|
||||
///
|
||||
/// Renders [url] through [MediaImageProvider] (stable cache key, fetch-time
|
||||
/// auth, failure cooldown) and bounds the decode size so a handful of large
|
||||
/// originals cannot thrash the global [ImageCache]:
|
||||
///
|
||||
/// - [decodeWidth] set: decode at that logical width (x device pixel ratio).
|
||||
/// - otherwise, when [boundDecodeToLayout] is true (default): decode at the
|
||||
/// layout width reported by [LayoutBuilder], when finite.
|
||||
/// - [boundDecodeToLayout] false and no [decodeWidth]: full-resolution decode
|
||||
/// (the zoomable full-screen viewer).
|
||||
class MediaImage extends ConsumerWidget {
|
||||
final String url;
|
||||
final BoxFit? fit;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final String? semanticLabel;
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
final FilterQuality filterQuality;
|
||||
final double? decodeWidth;
|
||||
final bool boundDecodeToLayout;
|
||||
|
||||
const MediaImage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.fit,
|
||||
this.width,
|
||||
this.height,
|
||||
this.semanticLabel,
|
||||
this.errorBuilder,
|
||||
this.filterQuality = FilterQuality.medium,
|
||||
this.decodeWidth,
|
||||
this.boundDecodeToLayout = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = MediaImageProvider(
|
||||
url: url,
|
||||
auth: ref.watch(mediaGetAuthServiceProvider),
|
||||
client: ref.watch(mediaHttpClientProvider),
|
||||
);
|
||||
|
||||
if (decodeWidth != null) {
|
||||
return _image(provider, _physicalWidth(context, decodeWidth!));
|
||||
}
|
||||
if (!boundDecodeToLayout) {
|
||||
return _image(provider, null);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
final cacheWidth = maxWidth.isFinite && maxWidth > 0
|
||||
? _physicalWidth(context, maxWidth)
|
||||
: null;
|
||||
return _image(provider, cacheWidth);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _physicalWidth(BuildContext context, double logicalWidth) {
|
||||
return (logicalWidth * MediaQuery.devicePixelRatioOf(context)).ceil();
|
||||
}
|
||||
|
||||
Widget _image(MediaImageProvider provider, int? cacheWidth) {
|
||||
return Image(
|
||||
image: ResizeImage.resizeIfNeeded(cacheWidth, null, provider),
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
semanticLabel: semanticLabel,
|
||||
errorBuilder: errorBuilder,
|
||||
filterQuality: filterQuality,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:file_selector/file_selector.dart' as file_selector;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
|
||||
import 'animated_image_sanitizer.dart';
|
||||
import 'media_auth.dart';
|
||||
import 'mp4_fast_start.dart';
|
||||
import 'relay_provider.dart';
|
||||
|
||||
const _mediaUploadPath = '/upload';
|
||||
const _legacyMediaUploadPath = '/media/upload';
|
||||
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
|
||||
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
|
||||
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
|
||||
const _generateVideoPosterMethod = 'generateVideoPoster';
|
||||
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
|
||||
const _requiresLegacyMediaStoragePermissionMethod =
|
||||
'requiresLegacyMediaStoragePermission';
|
||||
const _readClipboardImageMethod = 'readClipboardImage';
|
||||
const _clipboardHasImageMethod = 'clipboardHasImage';
|
||||
const _uploadAuthKind = 24242;
|
||||
const _uploadAuthLifetimeSeconds = 300;
|
||||
const _heicBrands = {
|
||||
'heic',
|
||||
'heix',
|
||||
'hevc',
|
||||
'hevx',
|
||||
'heim',
|
||||
'heis',
|
||||
'mif1',
|
||||
'msf1',
|
||||
};
|
||||
final _mediaUploadPlatformChannel = MethodChannel(
|
||||
_mediaUploadPlatformChannelName,
|
||||
);
|
||||
|
||||
/// Whether saving media needs Android's pre-scoped-storage runtime permission.
|
||||
Future<bool> requiresLegacyMediaStoragePermission() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
return false;
|
||||
}
|
||||
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
|
||||
_requiresLegacyMediaStoragePermissionMethod,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
const _allowedImageMimeTypes = {
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
};
|
||||
const _allowedVideoMimeTypes = {'video/mp4'};
|
||||
const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB
|
||||
const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB
|
||||
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
|
||||
|
||||
typedef PickGalleryImage = Future<XFile?> Function();
|
||||
|
||||
/// Selects multiple gallery images for upload in picker order.
|
||||
typedef PickGalleryImages = Future<List<XFile>> Function();
|
||||
typedef PickGalleryVideo = Future<XFile?> Function();
|
||||
typedef PickAttachmentFile = Future<XFile?> Function();
|
||||
typedef SanitizeImageBytes =
|
||||
Future<Uint8List> Function(Uint8List bytes, String mimeType);
|
||||
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
|
||||
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
|
||||
|
||||
/// Generates poster-frame bytes for the video at [filePath], when available.
|
||||
typedef GenerateVideoPoster = Future<Uint8List?> Function(String filePath);
|
||||
typedef ReadClipboardImage = Future<Uint8List?> Function();
|
||||
|
||||
class MediaPolicyUploadException implements Exception {
|
||||
const MediaPolicyUploadException();
|
||||
|
||||
@override
|
||||
String toString() => _mediaPolicyUploadMessage;
|
||||
}
|
||||
|
||||
/// Cancels a single user-initiated media upload without closing the shared
|
||||
/// HTTP client used by later uploads.
|
||||
class UploadCancellationToken {
|
||||
final Completer<void> _cancelled = Completer<void>();
|
||||
|
||||
/// Whether cancellation has been requested.
|
||||
bool get isCancelled => _cancelled.isCompleted;
|
||||
|
||||
/// Completes when cancellation is first requested.
|
||||
Future<void> get whenCancelled => _cancelled.future;
|
||||
|
||||
/// Requests cancellation. Calling this more than once has no effect.
|
||||
void cancel() {
|
||||
if (!_cancelled.isCompleted) _cancelled.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates that a user cancelled a media upload before it completed.
|
||||
class UploadCancelledException implements Exception {
|
||||
const UploadCancelledException();
|
||||
}
|
||||
|
||||
@immutable
|
||||
class _PreparedUploadImage {
|
||||
final Uint8List bytes;
|
||||
final String mimeType;
|
||||
|
||||
const _PreparedUploadImage({required this.bytes, required this.mimeType});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class BlobDescriptor {
|
||||
final String url;
|
||||
final String sha256;
|
||||
final int size;
|
||||
final String type;
|
||||
final int uploaded;
|
||||
final String? dim;
|
||||
final String? blurhash;
|
||||
final String? thumb;
|
||||
final double? duration;
|
||||
final String? image;
|
||||
final String? filename;
|
||||
|
||||
const BlobDescriptor({
|
||||
required this.url,
|
||||
required this.sha256,
|
||||
required this.size,
|
||||
required this.type,
|
||||
required this.uploaded,
|
||||
this.dim,
|
||||
this.blurhash,
|
||||
this.thumb,
|
||||
this.duration,
|
||||
this.image,
|
||||
this.filename,
|
||||
});
|
||||
|
||||
factory BlobDescriptor.fromJson(Map<String, dynamic> json) => BlobDescriptor(
|
||||
url: json['url'] as String,
|
||||
sha256: json['sha256'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
type: json['type'] as String,
|
||||
uploaded: (json['uploaded'] as num).toInt(),
|
||||
dim: json['dim'] as String?,
|
||||
blurhash: json['blurhash'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
duration: (json['duration'] as num?)?.toDouble(),
|
||||
image: json['image'] as String?,
|
||||
filename: json['filename'] as String?,
|
||||
);
|
||||
|
||||
BlobDescriptor withFilename(String value) => BlobDescriptor(
|
||||
url: url,
|
||||
sha256: sha256,
|
||||
size: size,
|
||||
type: type,
|
||||
uploaded: uploaded,
|
||||
dim: dim,
|
||||
blurhash: blurhash,
|
||||
thumb: thumb,
|
||||
duration: duration,
|
||||
image: image,
|
||||
filename: value,
|
||||
);
|
||||
|
||||
/// Returns a descriptor with [value] as its NIP-71 video poster URL.
|
||||
BlobDescriptor withImage(String value) => BlobDescriptor(
|
||||
url: url,
|
||||
sha256: sha256,
|
||||
size: size,
|
||||
type: type,
|
||||
uploaded: uploaded,
|
||||
dim: dim,
|
||||
blurhash: blurhash,
|
||||
thumb: thumb,
|
||||
duration: duration,
|
||||
image: value,
|
||||
filename: filename,
|
||||
);
|
||||
|
||||
List<String> toImetaTag() => [
|
||||
'imeta',
|
||||
'url $url',
|
||||
'm $type',
|
||||
'x $sha256',
|
||||
'size $size',
|
||||
if (dim != null) 'dim $dim',
|
||||
if (blurhash != null) 'blurhash $blurhash',
|
||||
if (thumb != null) 'thumb $thumb',
|
||||
if (duration != null) 'duration $duration',
|
||||
if (image != null) 'image $image',
|
||||
if (filename != null) 'filename $filename',
|
||||
];
|
||||
|
||||
String toMarkdownImage() {
|
||||
if (type.startsWith('video/')) return '';
|
||||
if (type.startsWith('image/')) return '';
|
||||
final label = (filename ?? 'file').replaceAllMapped(
|
||||
RegExp(r'[\\\[\]]'),
|
||||
(match) => '\\${match[0]}',
|
||||
);
|
||||
return '[$label]($url)';
|
||||
}
|
||||
}
|
||||
|
||||
class MediaUploadService {
|
||||
final String _baseUrl;
|
||||
final String? _nsec;
|
||||
final PickGalleryImage _pickGalleryImage;
|
||||
final PickGalleryImages _pickGalleryImages;
|
||||
final PickGalleryVideo _pickGalleryVideo;
|
||||
final PickAttachmentFile? _pickAttachmentFile;
|
||||
final SanitizeImageBytes _sanitizeImageBytes;
|
||||
final TranscodeImageToJpeg _transcodeImageToJpeg;
|
||||
final TranscodeVideoToMp4 _transcodeVideoToMp4;
|
||||
final GenerateVideoPoster _generateVideoPoster;
|
||||
final ReadClipboardImage _readClipboardImage;
|
||||
final DateTime Function() _now;
|
||||
final http.Client _http;
|
||||
final bool _ownsHttpClient;
|
||||
|
||||
MediaUploadService({
|
||||
required String baseUrl,
|
||||
required String? nsec,
|
||||
required PickGalleryImage pickGalleryImage,
|
||||
PickGalleryImages? pickGalleryImages,
|
||||
required PickGalleryVideo pickGalleryVideo,
|
||||
PickAttachmentFile? pickAttachmentFile,
|
||||
SanitizeImageBytes? sanitizeImageBytes,
|
||||
TranscodeImageToJpeg? transcodeImageToJpeg,
|
||||
TranscodeVideoToMp4? transcodeVideoToMp4,
|
||||
GenerateVideoPoster? generateVideoPoster,
|
||||
ReadClipboardImage? readClipboardImage,
|
||||
DateTime Function()? now,
|
||||
http.Client? httpClient,
|
||||
}) : _baseUrl = baseUrl,
|
||||
_nsec = nsec,
|
||||
_pickGalleryImage = pickGalleryImage,
|
||||
_pickGalleryImages =
|
||||
pickGalleryImages ??
|
||||
(() async {
|
||||
final image = await pickGalleryImage();
|
||||
return image == null ? const <XFile>[] : [image];
|
||||
}),
|
||||
_pickGalleryVideo = pickGalleryVideo,
|
||||
_pickAttachmentFile = pickAttachmentFile,
|
||||
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
|
||||
_transcodeImageToJpeg =
|
||||
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
|
||||
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
|
||||
_generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster,
|
||||
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
|
||||
_now = now ?? DateTime.now,
|
||||
_http = httpClient ?? http.Client(),
|
||||
_ownsHttpClient = httpClient == null;
|
||||
|
||||
void dispose() {
|
||||
if (_ownsHttpClient) {
|
||||
_http.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<BlobDescriptor?> pickAndUploadImage() async {
|
||||
final pickedImage = await _pickGalleryImage();
|
||||
if (pickedImage == null) return null;
|
||||
return uploadImage(pickedImage);
|
||||
}
|
||||
|
||||
/// Opens the system picker with multi-selection enabled.
|
||||
Future<List<XFile>> pickGalleryImages() => _pickGalleryImages();
|
||||
|
||||
Future<BlobDescriptor> uploadImage(
|
||||
XFile image, {
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
final preparedImage = await _prepareUploadImage(image);
|
||||
_throwIfCancelled(cancellationToken);
|
||||
return _uploadPreparedBytes(
|
||||
preparedImage.bytes,
|
||||
mimeType: preparedImage.mimeType,
|
||||
onProgress: onProgress,
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> clipboardHasImage() async {
|
||||
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
|
||||
_clipboardHasImageMethod,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<BlobDescriptor> readAndUploadClipboardImage() async {
|
||||
final image = await readClipboardImage();
|
||||
if (image == null) throw Exception('Unable to read pasted image');
|
||||
return uploadImage(image);
|
||||
}
|
||||
|
||||
/// Reads a clipboard image for composer preview before the user sends it.
|
||||
Future<XFile?> readClipboardImage({String filename = 'Pasted image'}) async {
|
||||
final bytes = await _readClipboardImage();
|
||||
if (bytes == null || bytes.isEmpty) return null;
|
||||
return XFile.fromData(bytes, name: filename);
|
||||
}
|
||||
|
||||
/// Opens the system gallery video picker.
|
||||
Future<XFile?> pickGalleryVideo() => _pickGalleryVideo();
|
||||
|
||||
/// Sanitizes and uploads [pickedVideo] as an MP4 attachment.
|
||||
Future<BlobDescriptor> uploadVideo(
|
||||
XFile pickedVideo, {
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final length = await pickedVideo.length();
|
||||
if (length > _maxVideoSizeBytes) {
|
||||
throw Exception(
|
||||
'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
|
||||
);
|
||||
}
|
||||
|
||||
// Always rebuild the container. Passing an existing MP4 through would retain
|
||||
// QuickTime GPS, global metadata, chapters, or non-A/V tracks.
|
||||
String? transcodedPath;
|
||||
try {
|
||||
transcodedPath = await _transcodeVideoToMp4(pickedVideo.path);
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final transcodedFile = File(transcodedPath);
|
||||
final transcodedLength = await transcodedFile.length();
|
||||
if (transcodedLength > _maxVideoSizeBytes) {
|
||||
throw Exception(
|
||||
'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
|
||||
);
|
||||
}
|
||||
final bytes = await transcodedFile.readAsBytes();
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final video = await uploadBytes(
|
||||
bytes,
|
||||
mimeType: 'video/mp4',
|
||||
onProgress: onProgress == null
|
||||
? null
|
||||
: (progress) => onProgress(progress * 0.9),
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
|
||||
// Extract from the canonical output first so the poster matches the
|
||||
// uploaded orientation. Some AVFoundation exports need a moment before
|
||||
// their first frame is seekable; fall back to the picked source instead
|
||||
// of silently sending a permanently gray video card.
|
||||
Uint8List? posterBytes;
|
||||
Object? posterExtractionError;
|
||||
for (final sourcePath in {transcodedPath, pickedVideo.path}) {
|
||||
try {
|
||||
final candidate = await _generateVideoPoster(sourcePath);
|
||||
if (candidate != null && candidate.isNotEmpty) {
|
||||
posterBytes = candidate;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
posterExtractionError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (posterBytes == null) {
|
||||
if (posterExtractionError != null) {
|
||||
debugPrint('Unable to generate video poster: $posterExtractionError');
|
||||
}
|
||||
onProgress?.call(1);
|
||||
return video;
|
||||
}
|
||||
|
||||
// Posters are best-effort: a video that passed the media policy should
|
||||
// still send if the separate preview upload fails.
|
||||
try {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final poster = await uploadImage(
|
||||
XFile.fromData(
|
||||
posterBytes,
|
||||
mimeType: 'image/jpeg',
|
||||
name: 'video-poster.jpg',
|
||||
),
|
||||
onProgress: onProgress == null
|
||||
? null
|
||||
: (progress) => onProgress(0.9 + (progress * 0.1)),
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
return video.withImage(poster.url);
|
||||
} catch (error) {
|
||||
debugPrint('Unable to upload video poster: $error');
|
||||
onProgress?.call(1);
|
||||
return video;
|
||||
}
|
||||
} finally {
|
||||
if (transcodedPath != null) {
|
||||
try {
|
||||
await File(transcodedPath).delete();
|
||||
} catch (_) {
|
||||
// Best-effort temp file cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<BlobDescriptor?> pickAndUploadVideo() async {
|
||||
final pickedVideo = await pickGalleryVideo();
|
||||
if (pickedVideo == null) return null;
|
||||
return uploadVideo(pickedVideo);
|
||||
}
|
||||
|
||||
/// Opens the system document picker for a generic file attachment.
|
||||
Future<XFile?> pickAttachmentFile() async {
|
||||
final pickAttachmentFile = _pickAttachmentFile;
|
||||
if (pickAttachmentFile == null) {
|
||||
throw Exception("File attachments aren't available on this device.");
|
||||
}
|
||||
return pickAttachmentFile();
|
||||
}
|
||||
|
||||
/// Uploads [pickedFile] as a size-limited generic attachment.
|
||||
Future<BlobDescriptor> uploadFile(
|
||||
XFile pickedFile, {
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final length = await pickedFile.length();
|
||||
if (length == 0) {
|
||||
throw Exception('File is empty.');
|
||||
}
|
||||
if (length > _maxFileSizeBytes) {
|
||||
throw Exception(
|
||||
'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
|
||||
);
|
||||
}
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final descriptor = await _uploadPreparedBytes(
|
||||
bytes,
|
||||
mimeType: 'application/octet-stream',
|
||||
allowGenericFile: true,
|
||||
onProgress: onProgress,
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name));
|
||||
}
|
||||
|
||||
Future<BlobDescriptor?> pickAndUploadFile() async {
|
||||
final pickedFile = await pickAttachmentFile();
|
||||
if (pickedFile == null) return null;
|
||||
return uploadFile(pickedFile);
|
||||
}
|
||||
|
||||
Future<BlobDescriptor> uploadBytes(
|
||||
Uint8List bytes, {
|
||||
required String mimeType,
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
if (mimeType == 'image/gif' ||
|
||||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
|
||||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
|
||||
try {
|
||||
bytes = sanitizeAnimatedImageForUpload(bytes, mimeType);
|
||||
} on FormatException {
|
||||
throw Exception('failed to sanitize image for upload');
|
||||
}
|
||||
}
|
||||
return _uploadPreparedBytes(
|
||||
bytes,
|
||||
mimeType: mimeType,
|
||||
onProgress: onProgress,
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<BlobDescriptor> _uploadPreparedBytes(
|
||||
Uint8List bytes, {
|
||||
required String mimeType,
|
||||
bool allowGenericFile = false,
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
if (!allowGenericFile &&
|
||||
!_allowedImageMimeTypes.contains(mimeType) &&
|
||||
!_allowedVideoMimeTypes.contains(mimeType)) {
|
||||
throw Exception('unsupported file type: $mimeType');
|
||||
}
|
||||
|
||||
final sha256 = _sha256Hex(bytes);
|
||||
var response = await _sendUploadRequest(
|
||||
bytes: bytes,
|
||||
mimeType: mimeType,
|
||||
sha256: sha256,
|
||||
path: _mediaUploadPath,
|
||||
onProgress: onProgress,
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
if (response.statusCode == HttpStatus.notFound ||
|
||||
response.statusCode == HttpStatus.methodNotAllowed) {
|
||||
response = await _sendUploadRequest(
|
||||
bytes: bytes,
|
||||
mimeType: mimeType,
|
||||
sha256: sha256,
|
||||
path: _legacyMediaUploadPath,
|
||||
onProgress: onProgress,
|
||||
cancellationToken: cancellationToken,
|
||||
);
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
if (_allowedImageMimeTypes.contains(mimeType) &&
|
||||
(response.statusCode == HttpStatus.unsupportedMediaType ||
|
||||
response.statusCode == HttpStatus.unprocessableEntity)) {
|
||||
throw const MediaPolicyUploadException();
|
||||
}
|
||||
throw Exception(
|
||||
'upload failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
return BlobDescriptor.fromJson(
|
||||
jsonDecode(response.body) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> _sendUploadRequest({
|
||||
required Uint8List bytes,
|
||||
required String mimeType,
|
||||
required String sha256,
|
||||
required String path,
|
||||
ValueChanged<double>? onProgress,
|
||||
UploadCancellationToken? cancellationToken,
|
||||
}) async {
|
||||
_throwIfCancelled(cancellationToken);
|
||||
final request = http.AbortableStreamedRequest(
|
||||
'PUT',
|
||||
Uri.parse(_baseUrl).resolve(path),
|
||||
abortTrigger: cancellationToken?.whenCancelled,
|
||||
);
|
||||
request.contentLength = bytes.length;
|
||||
request.headers.addAll(
|
||||
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
|
||||
);
|
||||
final writeRequest = request.sink
|
||||
.addStream(_uploadByteStream(bytes, onProgress))
|
||||
.whenComplete(request.sink.close);
|
||||
final response = await _http.send(request);
|
||||
await writeRequest;
|
||||
_throwIfCancelled(cancellationToken);
|
||||
return http.Response.fromStream(response);
|
||||
}
|
||||
|
||||
void _throwIfCancelled(UploadCancellationToken? cancellationToken) {
|
||||
if (cancellationToken?.isCancelled ?? false) {
|
||||
throw const UploadCancelledException();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> _buildUploadHeaders({
|
||||
required String mimeType,
|
||||
required String sha256,
|
||||
}) {
|
||||
final headers = <String, String>{
|
||||
'Authorization': _buildUploadAuthHeader(sha256),
|
||||
'Content-Type': mimeType,
|
||||
'X-SHA-256': sha256,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
String _buildUploadAuthHeader(String sha256) {
|
||||
final authEvent = _buildUploadAuthEvent(sha256);
|
||||
final authJson = authEvent.toJson();
|
||||
final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', '');
|
||||
return 'Nostr $encoded';
|
||||
}
|
||||
|
||||
nostr.Event _buildUploadAuthEvent(String sha256) {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw Exception('Cannot upload media: no signing key available');
|
||||
}
|
||||
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
|
||||
final expiration =
|
||||
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
|
||||
final tags = <List<String>>[
|
||||
['t', 'upload'],
|
||||
['x', sha256],
|
||||
['expiration', '$expiration'],
|
||||
if (extractServerAuthority(_baseUrl) case final authority?)
|
||||
['server', authority],
|
||||
];
|
||||
|
||||
return nostr.Event.from(
|
||||
kind: _uploadAuthKind,
|
||||
content: 'Upload buzz-media',
|
||||
tags: tags,
|
||||
secretKey: privkeyHex,
|
||||
verify: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async {
|
||||
final bytes = await pickedImage.readAsBytes();
|
||||
final detectedMimeType = _tryDetectImageMimeType(bytes);
|
||||
if (detectedMimeType case final mimeType?) {
|
||||
return _prepareDetectedUploadImage(bytes, mimeType);
|
||||
}
|
||||
|
||||
if (_shouldTranscodePickedImage(pickedImage, bytes)) {
|
||||
return _prepareTranscodedUploadImage(bytes);
|
||||
}
|
||||
|
||||
throw Exception('unsupported file type');
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareDetectedUploadImage(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType);
|
||||
return _buildPreparedUploadImage(preparedBytes);
|
||||
}
|
||||
|
||||
Future<_PreparedUploadImage> _prepareTranscodedUploadImage(
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
final transcodedBytes = await _transcodeImageToJpeg(bytes);
|
||||
return _buildPreparedUploadImage(transcodedBytes);
|
||||
}
|
||||
|
||||
_PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) {
|
||||
return _PreparedUploadImage(
|
||||
bytes: bytes,
|
||||
mimeType: _detectImageMimeType(bytes),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _sanitizeImageBytesIfNeeded(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
if (mimeType == 'image/gif' ||
|
||||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
|
||||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
|
||||
try {
|
||||
return sanitizeAnimatedImageForUpload(bytes, mimeType);
|
||||
} on FormatException {
|
||||
throw Exception('failed to sanitize image for upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (!_shouldSanitizePickedImage(mimeType)) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType);
|
||||
if (sanitizedBytes.isEmpty) {
|
||||
throw Exception('failed to sanitize image for upload');
|
||||
}
|
||||
return sanitizedBytes;
|
||||
}
|
||||
}
|
||||
|
||||
String _safeAttachmentFilename(String filename) {
|
||||
final segments = filename.split(RegExp(r'[/\\]'));
|
||||
final basename = segments.isEmpty ? '' : segments.last;
|
||||
final sanitized = StringBuffer();
|
||||
var byteLength = 0;
|
||||
|
||||
for (final rune in basename.runes) {
|
||||
if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final character = String.fromCharCode(rune);
|
||||
final characterByteLength = utf8.encode(character).length;
|
||||
if (byteLength + characterByteLength > 255) break;
|
||||
|
||||
sanitized.write(character);
|
||||
byteLength += characterByteLength;
|
||||
}
|
||||
|
||||
final safeBasename = sanitized.toString().trim();
|
||||
return safeBasename.isEmpty ? 'file' : safeBasename;
|
||||
}
|
||||
|
||||
Stream<List<int>> _uploadByteStream(
|
||||
Uint8List bytes,
|
||||
ValueChanged<double>? onProgress,
|
||||
) async* {
|
||||
const chunkSize = 64 * 1024;
|
||||
onProgress?.call(0);
|
||||
if (bytes.isEmpty) {
|
||||
onProgress?.call(1);
|
||||
return;
|
||||
}
|
||||
for (var start = 0; start < bytes.length; start += chunkSize) {
|
||||
final end = math.min(start + chunkSize, bytes.length);
|
||||
yield Uint8List.sublistView(bytes, start, end);
|
||||
onProgress?.call(end / bytes.length);
|
||||
}
|
||||
}
|
||||
|
||||
String _sha256Hex(Uint8List bytes) {
|
||||
final digest = SHA256Digest().process(bytes);
|
||||
return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
String? _tryDetectImageMimeType(Uint8List bytes) {
|
||||
try {
|
||||
return _detectImageMimeType(bytes);
|
||||
} on Exception {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _detectImageMimeType(Uint8List bytes) {
|
||||
if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (_startsWith(bytes, const [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
])) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (_startsWith(bytes, ascii.encode('GIF87a')) ||
|
||||
_startsWith(bytes, ascii.encode('GIF89a'))) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (_startsWith(bytes, ascii.encode('RIFF')) &&
|
||||
bytes.length >= 12 &&
|
||||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
|
||||
return 'image/webp';
|
||||
}
|
||||
throw Exception('unsupported file type');
|
||||
}
|
||||
|
||||
bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) {
|
||||
return _supportsNativeUploadImageProcessing() &&
|
||||
(_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes));
|
||||
}
|
||||
|
||||
bool _isAnimatedPng(Uint8List bytes) {
|
||||
if (!_startsWith(bytes, const [
|
||||
0x89,
|
||||
0x50,
|
||||
0x4e,
|
||||
0x47,
|
||||
0x0d,
|
||||
0x0a,
|
||||
0x1a,
|
||||
0x0a,
|
||||
])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = 8;
|
||||
while (offset + 12 <= bytes.length) {
|
||||
final chunkSize = _readUint32BigEndian(bytes, offset);
|
||||
if (offset + 12 + chunkSize > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_matchesAscii(bytes, offset + 4, 'acTL')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
offset += 12 + chunkSize;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isAnimatedWebp(Uint8List bytes) {
|
||||
if (!_startsWith(bytes, ascii.encode('RIFF')) ||
|
||||
bytes.length < 12 ||
|
||||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
final chunkSize = _readUint32LittleEndian(bytes, offset + 4);
|
||||
final payloadOffset = offset + 8;
|
||||
if (payloadOffset + chunkSize > bytes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_matchesAscii(bytes, offset, 'ANIM') ||
|
||||
_matchesAscii(bytes, offset, 'ANMF')) {
|
||||
return true;
|
||||
}
|
||||
if (_matchesAscii(bytes, offset, 'VP8X') &&
|
||||
chunkSize >= 1 &&
|
||||
(bytes[payloadOffset] & 0x02) != 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _shouldSanitizePickedImage(String mimeType) {
|
||||
return _supportsNativeUploadImageProcessing() &&
|
||||
(mimeType == 'image/jpeg' ||
|
||||
mimeType == 'image/png' ||
|
||||
mimeType == 'image/webp');
|
||||
}
|
||||
|
||||
bool _supportsNativeUploadImageProcessing() {
|
||||
return switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android || TargetPlatform.iOS => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _hasHeicFileExtension(XFile pickedImage) {
|
||||
for (final candidate in [pickedImage.name, pickedImage.path]) {
|
||||
final normalizedCandidate = candidate.toLowerCase();
|
||||
if (normalizedCandidate.endsWith('.heic') ||
|
||||
normalizedCandidate.endsWith('.heif')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _looksLikeHeicOrHeif(Uint8List bytes) {
|
||||
if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final upperBound = bytes.length < 32 ? bytes.length : 32;
|
||||
for (var offset = 8; offset + 4 <= upperBound; offset += 4) {
|
||||
final brand = ascii.decode(
|
||||
bytes.sublist(offset, offset + 4),
|
||||
allowInvalid: true,
|
||||
);
|
||||
if (_heicBrands.contains(brand.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _startsWith(Uint8List bytes, List<int> prefix) {
|
||||
if (bytes.length < prefix.length) return false;
|
||||
for (var i = 0; i < prefix.length; i++) {
|
||||
if (bytes[i] != prefix[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _matchesAscii(Uint8List bytes, int offset, String value) {
|
||||
final codeUnits = ascii.encode(value);
|
||||
if (bytes.length < offset + codeUnits.length) return false;
|
||||
for (var i = 0; i < codeUnits.length; i++) {
|
||||
if (bytes[offset + i] != codeUnits[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int _readUint32BigEndian(Uint8List bytes, int offset) {
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
int _readUint32LittleEndian(Uint8List bytes, int offset) {
|
||||
return bytes[offset] |
|
||||
(bytes[offset + 1] << 8) |
|
||||
(bytes[offset + 2] << 16) |
|
||||
(bytes[offset + 3] << 24);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _readPlatformClipboardImage() async {
|
||||
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
|
||||
_readClipboardImageMethod,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _generatePickedVideoPoster(String filePath) {
|
||||
return _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
|
||||
_generateVideoPosterMethod,
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _transcodePickedVideoToMp4(String filePath) async {
|
||||
final result = await _mediaUploadPlatformChannel.invokeMethod<String>(
|
||||
_transcodeVideoToMp4Method,
|
||||
filePath,
|
||||
);
|
||||
if (result == null || result.isEmpty) {
|
||||
throw Exception('Failed to convert video to MP4.');
|
||||
}
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final source = File(result);
|
||||
final destination = File(
|
||||
'$result.faststart-${DateTime.now().microsecondsSinceEpoch}.mp4',
|
||||
);
|
||||
try {
|
||||
await rewriteMp4ForFastStart(source, destination);
|
||||
await source.delete();
|
||||
return destination.path;
|
||||
} catch (_) {
|
||||
try {
|
||||
await destination.delete();
|
||||
} on FileSystemException {
|
||||
// Best-effort cleanup; preserve the original platform error.
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Uint8List> _transcodePickedImageToJpeg(Uint8List bytes) async {
|
||||
return _invokeRequiredPlatformBytesMethod(
|
||||
_transcodeImageToJpegMethod,
|
||||
arguments: bytes,
|
||||
errorMessage: 'failed to convert image for upload',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _sanitizePickedImageBytes(
|
||||
Uint8List bytes,
|
||||
String mimeType,
|
||||
) async {
|
||||
return _invokeRequiredPlatformBytesMethod(
|
||||
_sanitizeImageForUploadMethod,
|
||||
arguments: {'bytes': bytes, 'mimeType': mimeType},
|
||||
errorMessage: 'failed to sanitize image for upload',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List> _invokeRequiredPlatformBytesMethod(
|
||||
String method, {
|
||||
Object? arguments,
|
||||
required String errorMessage,
|
||||
}) async {
|
||||
final result = await _mediaUploadPlatformChannel.invokeMethod<Uint8List>(
|
||||
method,
|
||||
arguments,
|
||||
);
|
||||
if (result == null || result.isEmpty) {
|
||||
throw Exception(errorMessage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final picker = ImagePicker();
|
||||
final service = MediaUploadService(
|
||||
baseUrl: config.baseUrl,
|
||||
nsec: config.nsec,
|
||||
pickGalleryImage: () => picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
requestFullMetadata: false,
|
||||
),
|
||||
pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false),
|
||||
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
|
||||
pickAttachmentFile: file_selector.openFile,
|
||||
);
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const _maxTopLevelBoxes = 4096;
|
||||
const _maxNestedBoxes = 100000;
|
||||
const _maxBoxDepth = 32;
|
||||
const _maxMoovBytes = 64 * 1024 * 1024;
|
||||
const _copyBufferBytes = 1024 * 1024;
|
||||
const _uint32Max = 0xffffffff;
|
||||
const _uint64Max = 0x7fffffffffffffff;
|
||||
const _containerTypes = {
|
||||
'moov',
|
||||
'trak',
|
||||
'mdia',
|
||||
'minf',
|
||||
'stbl',
|
||||
'edts',
|
||||
'dinf',
|
||||
};
|
||||
|
||||
final class _Mp4Box {
|
||||
final int offset;
|
||||
final int size;
|
||||
final int headerSize;
|
||||
final String type;
|
||||
|
||||
const _Mp4Box({
|
||||
required this.offset,
|
||||
required this.size,
|
||||
required this.headerSize,
|
||||
required this.type,
|
||||
});
|
||||
}
|
||||
|
||||
/// Rewrites [source] to [destination] with `moov` before the first `mdat`.
|
||||
///
|
||||
/// Only chunk offsets into the region moved by the relocation are adjusted.
|
||||
/// The files must be distinct, and malformed or excessively nested inputs fail
|
||||
/// closed rather than producing a partially valid upload.
|
||||
Future<void> rewriteMp4ForFastStart(File source, File destination) async {
|
||||
if (source.absolute.path == destination.absolute.path) {
|
||||
throw const FormatException(
|
||||
'MP4 fast-start rewrite requires a distinct destination',
|
||||
);
|
||||
}
|
||||
|
||||
final input = await source.open();
|
||||
try {
|
||||
final boxes = await _readTopLevelBoxes(input);
|
||||
if (boxes.isEmpty || boxes.first.type != 'ftyp') {
|
||||
throw const FormatException('MP4 must start with an ftyp box');
|
||||
}
|
||||
final moovBoxes = boxes.where((box) => box.type == 'moov').toList();
|
||||
if (moovBoxes.length != 1) {
|
||||
throw const FormatException('MP4 must contain exactly one moov box');
|
||||
}
|
||||
final moov = moovBoxes.single;
|
||||
final mdatBoxes = boxes.where((box) => box.type == 'mdat').toList();
|
||||
final firstMdat = mdatBoxes.isEmpty ? null : mdatBoxes.first;
|
||||
if (firstMdat == null) {
|
||||
throw const FormatException('MP4 is missing an mdat box');
|
||||
}
|
||||
if (moov.size > _maxMoovBytes) {
|
||||
throw const FormatException('MP4 moov box is too large');
|
||||
}
|
||||
|
||||
await input.setPosition(moov.offset);
|
||||
final moovBytes = await input.read(moov.size);
|
||||
if (moovBytes.length != moov.size) {
|
||||
throw const FormatException('truncated MP4 moov box');
|
||||
}
|
||||
if (moov.offset > firstMdat.offset) {
|
||||
_patchChunkOffsets(
|
||||
moovBytes,
|
||||
moov.headerSize,
|
||||
moovBytes.length,
|
||||
firstMdat.offset,
|
||||
moov.offset,
|
||||
moov.size,
|
||||
0,
|
||||
[0],
|
||||
);
|
||||
}
|
||||
|
||||
final output = await destination.open(mode: FileMode.write);
|
||||
try {
|
||||
var insertedMoov = false;
|
||||
for (final box in boxes) {
|
||||
if (box.type == 'moov') continue;
|
||||
if (!insertedMoov && box.type == 'mdat') {
|
||||
await output.writeFrom(moovBytes);
|
||||
insertedMoov = true;
|
||||
}
|
||||
await _copyRange(input, output, box.offset, box.size);
|
||||
}
|
||||
if (!insertedMoov) {
|
||||
throw const FormatException('MP4 is missing an mdat box');
|
||||
}
|
||||
await output.flush();
|
||||
} finally {
|
||||
await output.close();
|
||||
}
|
||||
} catch (_) {
|
||||
try {
|
||||
await destination.delete();
|
||||
} on FileSystemException {
|
||||
// Best-effort cleanup of a partial rewrite.
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
await input.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<_Mp4Box>> _readTopLevelBoxes(RandomAccessFile input) async {
|
||||
final length = await input.length();
|
||||
final boxes = <_Mp4Box>[];
|
||||
var offset = 0;
|
||||
while (offset < length) {
|
||||
if (boxes.length >= _maxTopLevelBoxes) {
|
||||
throw const FormatException('MP4 has too many top-level boxes');
|
||||
}
|
||||
final box = await _readFileBoxHeader(input, offset, length);
|
||||
boxes.add(box);
|
||||
offset += box.size;
|
||||
}
|
||||
if (offset != length) {
|
||||
throw const FormatException('MP4 boxes do not cover the file');
|
||||
}
|
||||
return boxes;
|
||||
}
|
||||
|
||||
Future<_Mp4Box> _readFileBoxHeader(
|
||||
RandomAccessFile input,
|
||||
int offset,
|
||||
int end,
|
||||
) async {
|
||||
if (end - offset < 8) {
|
||||
throw const FormatException('truncated MP4 box header');
|
||||
}
|
||||
await input.setPosition(offset);
|
||||
final compactHeader = await input.read(8);
|
||||
final compactSize = _readUint32(compactHeader, 0);
|
||||
final type = latin1.decode(compactHeader.sublist(4, 8));
|
||||
final int headerSize;
|
||||
final int size;
|
||||
if (compactSize == 1) {
|
||||
if (end - offset < 16) {
|
||||
throw const FormatException('truncated extended MP4 box header');
|
||||
}
|
||||
final extended = await input.read(8);
|
||||
headerSize = 16;
|
||||
size = _readUint64(extended, 0);
|
||||
} else if (compactSize == 0) {
|
||||
headerSize = 8;
|
||||
size = end - offset;
|
||||
} else {
|
||||
headerSize = 8;
|
||||
size = compactSize;
|
||||
}
|
||||
if (size < headerSize || size > end - offset) {
|
||||
throw const FormatException('invalid MP4 box size');
|
||||
}
|
||||
return _Mp4Box(
|
||||
offset: offset,
|
||||
size: size,
|
||||
headerSize: headerSize,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _copyRange(
|
||||
RandomAccessFile input,
|
||||
RandomAccessFile output,
|
||||
int offset,
|
||||
int size,
|
||||
) async {
|
||||
await input.setPosition(offset);
|
||||
var remaining = size;
|
||||
while (remaining > 0) {
|
||||
final bytes = await input.read(
|
||||
remaining < _copyBufferBytes ? remaining : _copyBufferBytes,
|
||||
);
|
||||
if (bytes.isEmpty) {
|
||||
throw const FormatException('truncated MP4 while copying');
|
||||
}
|
||||
await output.writeFrom(bytes);
|
||||
remaining -= bytes.length;
|
||||
}
|
||||
}
|
||||
|
||||
void _patchChunkOffsets(
|
||||
Uint8List bytes,
|
||||
int start,
|
||||
int end,
|
||||
int movedRegionStart,
|
||||
int movedRegionEnd,
|
||||
int delta,
|
||||
int depth,
|
||||
List<int> boxesSeen,
|
||||
) {
|
||||
if (depth > _maxBoxDepth) {
|
||||
throw const FormatException('MP4 box nesting is too deep');
|
||||
}
|
||||
var offset = start;
|
||||
while (offset < end) {
|
||||
boxesSeen[0]++;
|
||||
if (boxesSeen[0] > _maxNestedBoxes) {
|
||||
throw const FormatException('MP4 has too many nested boxes');
|
||||
}
|
||||
final box = _readMemoryBoxHeader(bytes, offset, end);
|
||||
final boxEnd = offset + box.size;
|
||||
switch (box.type) {
|
||||
case 'stco':
|
||||
_patchStco(
|
||||
bytes,
|
||||
offset + box.headerSize,
|
||||
boxEnd,
|
||||
movedRegionStart,
|
||||
movedRegionEnd,
|
||||
delta,
|
||||
);
|
||||
break;
|
||||
case 'co64':
|
||||
_patchCo64(
|
||||
bytes,
|
||||
offset + box.headerSize,
|
||||
boxEnd,
|
||||
movedRegionStart,
|
||||
movedRegionEnd,
|
||||
delta,
|
||||
);
|
||||
break;
|
||||
case final type when _containerTypes.contains(type):
|
||||
_patchChunkOffsets(
|
||||
bytes,
|
||||
offset + box.headerSize,
|
||||
boxEnd,
|
||||
movedRegionStart,
|
||||
movedRegionEnd,
|
||||
delta,
|
||||
depth + 1,
|
||||
boxesSeen,
|
||||
);
|
||||
break;
|
||||
}
|
||||
offset = boxEnd;
|
||||
}
|
||||
if (offset != end) {
|
||||
throw const FormatException('MP4 child boxes do not cover their parent');
|
||||
}
|
||||
}
|
||||
|
||||
_Mp4Box _readMemoryBoxHeader(Uint8List bytes, int offset, int end) {
|
||||
if (end - offset < 8) {
|
||||
throw const FormatException('truncated MP4 child box');
|
||||
}
|
||||
final compactSize = _readUint32(bytes, offset);
|
||||
final type = latin1.decode(bytes.sublist(offset + 4, offset + 8));
|
||||
final int headerSize;
|
||||
final int size;
|
||||
if (compactSize == 1) {
|
||||
if (end - offset < 16) {
|
||||
throw const FormatException('truncated extended MP4 child box');
|
||||
}
|
||||
headerSize = 16;
|
||||
size = _readUint64(bytes, offset + 8);
|
||||
} else if (compactSize == 0) {
|
||||
headerSize = 8;
|
||||
size = end - offset;
|
||||
} else {
|
||||
headerSize = 8;
|
||||
size = compactSize;
|
||||
}
|
||||
if (size < headerSize || size > end - offset) {
|
||||
throw const FormatException('invalid MP4 child box size');
|
||||
}
|
||||
return _Mp4Box(
|
||||
offset: offset,
|
||||
size: size,
|
||||
headerSize: headerSize,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
void _patchStco(
|
||||
Uint8List bytes,
|
||||
int start,
|
||||
int end,
|
||||
int movedRegionStart,
|
||||
int movedRegionEnd,
|
||||
int delta,
|
||||
) {
|
||||
if (end - start < 8) throw const FormatException('truncated stco box');
|
||||
final count = _readUint32(bytes, start + 4);
|
||||
if (count > (end - start - 8) ~/ 4) {
|
||||
throw const FormatException('invalid stco entry count');
|
||||
}
|
||||
final data = ByteData.sublistView(bytes);
|
||||
for (var index = 0; index < count; index++) {
|
||||
final entry = start + 8 + index * 4;
|
||||
final value = data.getUint32(entry, Endian.big);
|
||||
if (value >= movedRegionStart && value < movedRegionEnd) {
|
||||
final adjusted = value + delta;
|
||||
if (adjusted > _uint32Max) {
|
||||
throw const FormatException('stco offset overflow');
|
||||
}
|
||||
data.setUint32(entry, adjusted, Endian.big);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _patchCo64(
|
||||
Uint8List bytes,
|
||||
int start,
|
||||
int end,
|
||||
int movedRegionStart,
|
||||
int movedRegionEnd,
|
||||
int delta,
|
||||
) {
|
||||
if (end - start < 8) throw const FormatException('truncated co64 box');
|
||||
final count = _readUint32(bytes, start + 4);
|
||||
if (count > (end - start - 8) ~/ 8) {
|
||||
throw const FormatException('invalid co64 entry count');
|
||||
}
|
||||
final data = ByteData.sublistView(bytes);
|
||||
for (var index = 0; index < count; index++) {
|
||||
final entry = start + 8 + index * 8;
|
||||
final value = data.getUint64(entry, Endian.big);
|
||||
if (value >= movedRegionStart && value < movedRegionEnd) {
|
||||
final adjusted = value + delta;
|
||||
if (adjusted > _uint64Max) {
|
||||
throw const FormatException('co64 offset overflow');
|
||||
}
|
||||
data.setUint64(entry, adjusted, Endian.big);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _readUint32(Uint8List bytes, int offset) =>
|
||||
ByteData.sublistView(bytes).getUint32(offset, Endian.big);
|
||||
|
||||
int _readUint64(Uint8List bytes, int offset) =>
|
||||
ByteData.sublistView(bytes).getUint64(offset, Endian.big);
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'nostr_models.dart';
|
||||
|
||||
/// Canonical [NostrFilter] constructors for common Buzz queries.
|
||||
///
|
||||
/// Centralising filter shapes keeps relay queries consistent across providers
|
||||
/// and makes kind/tag conventions easy to audit.
|
||||
abstract final class NostrFilters {
|
||||
/// Channels where I'm a member (kind:39002 with `#p` = my pubkey).
|
||||
static NostrFilter myChannels(String myPk) => NostrFilter(
|
||||
kinds: [39002],
|
||||
tags: {
|
||||
'#p': [myPk],
|
||||
},
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
/// Channel metadata for the given channel IDs.
|
||||
static NostrFilter channelMetadata(List<String> ids) =>
|
||||
NostrFilter(kinds: [39000], tags: {'#d': ids}, limit: ids.length);
|
||||
|
||||
/// Members list for a single channel.
|
||||
static NostrFilter channelMembers(String channelId) => NostrFilter(
|
||||
kinds: [39002],
|
||||
tags: {
|
||||
'#d': [channelId],
|
||||
},
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
/// A single user's profile (kind:0).
|
||||
static NostrFilter profile(String pubkey) =>
|
||||
NostrFilter(kinds: [0], authors: [pubkey], limit: 1);
|
||||
|
||||
/// Batch user profiles (kind:0) for multiple pubkeys.
|
||||
static NostrFilter profilesBatch(List<String> pubkeys) =>
|
||||
NostrFilter(kinds: [0], authors: pubkeys, limit: pubkeys.length);
|
||||
|
||||
/// Channel messages (all event kinds that appear in channels).
|
||||
static NostrFilter messages(
|
||||
String channelId, {
|
||||
int limit = 200,
|
||||
int? until,
|
||||
}) => NostrFilter(
|
||||
kinds: EventKind.channelEventKinds,
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Reactions (kind:7) on a specific event.
|
||||
static NostrFilter reactions(String eventId) => NostrFilter(
|
||||
kinds: [7],
|
||||
tags: {
|
||||
'#e': [eventId],
|
||||
},
|
||||
);
|
||||
|
||||
/// Canvas event for a channel.
|
||||
static NostrFilter canvas(String channelId) => NostrFilter(
|
||||
kinds: [40100],
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
/// Workflows (kind:30620) in a channel.
|
||||
static NostrFilter workflows(String channelId) => NostrFilter(
|
||||
kinds: [30620],
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
);
|
||||
|
||||
/// DM channels where I'm a participant.
|
||||
static NostrFilter dmList(String myPk) => NostrFilter(
|
||||
kinds: [39000],
|
||||
tags: {
|
||||
'#t': ['dm'],
|
||||
'#p': [myPk],
|
||||
},
|
||||
);
|
||||
|
||||
/// Latest per-viewer hidden-DM snapshot (kind:30622, `#p` = my pubkey).
|
||||
static NostrFilter hiddenDms(String myPk) => NostrFilter(
|
||||
kinds: [EventKind.dmVisibility],
|
||||
tags: {
|
||||
'#p': [myPk],
|
||||
},
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
/// Forum posts (kind:45001) in a channel.
|
||||
static NostrFilter forumPosts(
|
||||
String channelId, {
|
||||
int limit = 50,
|
||||
int? until,
|
||||
}) => NostrFilter(
|
||||
kinds: [45001],
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Replies in a forum thread (root event id + channel scope).
|
||||
static NostrFilter forumThread(String rootId, String channelId) =>
|
||||
NostrFilter(
|
||||
kinds: [9, 45003],
|
||||
tags: {
|
||||
'#e': [rootId],
|
||||
'#h': [channelId],
|
||||
},
|
||||
);
|
||||
|
||||
/// NIP-50 message search, optionally scoped to a channel.
|
||||
static NostrFilter searchMessages(
|
||||
String query, {
|
||||
String? channelId,
|
||||
int limit = 20,
|
||||
}) => NostrFilter(
|
||||
kinds: [9, 40002, 45001, 45003],
|
||||
tags: channelId != null
|
||||
? {
|
||||
'#h': [channelId],
|
||||
}
|
||||
: const {},
|
||||
search: query,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
/// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge).
|
||||
///
|
||||
/// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is
|
||||
/// a typeahead surface, so a partially typed name must match ("rac" →
|
||||
/// "raccoon"). Mirrors desktop's `build_user_search_filter`
|
||||
/// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through
|
||||
/// `queryRelay`, not a WebSocket REQ.
|
||||
static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter(
|
||||
kinds: [0],
|
||||
search: query,
|
||||
limit: limit,
|
||||
extensions: const {'search_mode': 'prefix'},
|
||||
);
|
||||
|
||||
/// Deletions (kind:5) targeting event IDs.
|
||||
static NostrFilter deletionsByTargetIds(
|
||||
List<String> ids, {
|
||||
List<String>? authors,
|
||||
}) => NostrFilter(
|
||||
kinds: [EventKind.deletion],
|
||||
authors: authors,
|
||||
tags: {'#e': ids},
|
||||
limit: ids.length,
|
||||
);
|
||||
|
||||
/// User notes (kind:1) for the global Pulse timeline.
|
||||
static NostrFilter globalNotes({int limit = 50, int? until}) =>
|
||||
NostrFilter(kinds: [EventKind.note], limit: limit, until: until);
|
||||
|
||||
/// Notes by a set of authors for Pulse timelines.
|
||||
static NostrFilter notesTimeline(
|
||||
List<String> pubkeys, {
|
||||
int limit = 200,
|
||||
int? until,
|
||||
}) => NostrFilter(
|
||||
kinds: [EventKind.note],
|
||||
authors: pubkeys,
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Reactions authored by a user.
|
||||
static NostrFilter userReactions(String pubkey, {int limit = 200}) =>
|
||||
NostrFilter(kinds: [EventKind.reaction], authors: [pubkey], limit: limit);
|
||||
|
||||
/// Reactions targeting notes.
|
||||
static NostrFilter noteReactions(List<String> noteIds) => NostrFilter(
|
||||
kinds: [EventKind.reaction],
|
||||
tags: {'#e': noteIds},
|
||||
limit: 500,
|
||||
);
|
||||
|
||||
/// Fetch notes by ids.
|
||||
static NostrFilter notesByIds(List<String> ids) =>
|
||||
NostrFilter(kinds: [EventKind.note], ids: ids, limit: ids.length);
|
||||
|
||||
/// User notes (kind:1) for a single author.
|
||||
static NostrFilter userNotes(String pubkey, {int limit = 20, int? until}) =>
|
||||
NostrFilter(
|
||||
kinds: [EventKind.note],
|
||||
authors: [pubkey],
|
||||
limit: limit,
|
||||
until: until,
|
||||
);
|
||||
|
||||
/// Contact list (kind:3) for a user.
|
||||
static NostrFilter contactList(String pubkey) =>
|
||||
NostrFilter(kinds: [EventKind.contactList], authors: [pubkey], limit: 1);
|
||||
|
||||
/// Relay membership list (kind:13534).
|
||||
static NostrFilter relayMembers() =>
|
||||
const NostrFilter(kinds: [13534], limit: 1);
|
||||
|
||||
/// Agent profiles (kind:10100).
|
||||
static NostrFilter agentProfiles() =>
|
||||
const NostrFilter(kinds: [10100], limit: 100);
|
||||
|
||||
/// User status (NIP-38, kind:30315).
|
||||
static NostrFilter userStatus(String pubkey) =>
|
||||
NostrFilter(kinds: [30315], authors: [pubkey], limit: 1);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Nostr event kind constants.
|
||||
///
|
||||
/// Keep in sync with `desktop/src/shared/constants/kinds.ts`.
|
||||
abstract final class EventKind {
|
||||
static const note = 1;
|
||||
static const contactList = 3;
|
||||
static const deletion = 5;
|
||||
static const reaction = 7;
|
||||
static const streamMessage = 9;
|
||||
static const nip29DeleteEvent = 9005;
|
||||
static const presenceUpdate = 20001;
|
||||
static const typingIndicator = 20002;
|
||||
static const auth = 22242;
|
||||
static const agentObserverFrame = 24200;
|
||||
static const huddleReaction = 24810;
|
||||
static const readState = 30078;
|
||||
static const eventReminder = 30300;
|
||||
static const userStatus = 30315;
|
||||
static const dmVisibility = 30622;
|
||||
static const streamMessageV2 = 40002;
|
||||
static const channelThreadSummary = 39005;
|
||||
static const channelWindowBounds = 39006;
|
||||
static const streamMessageEdit = 40003;
|
||||
static const streamMessageDiff = 40008;
|
||||
static const systemMessage = 40099;
|
||||
static const jobRequest = 43001;
|
||||
static const jobAccepted = 43002;
|
||||
static const jobProgress = 43003;
|
||||
static const jobResult = 43004;
|
||||
static const jobCancel = 43005;
|
||||
static const jobError = 43006;
|
||||
static const forumPost = 45001;
|
||||
static const forumComment = 45003;
|
||||
static const huddleStarted = 48100;
|
||||
static const huddleParticipantJoined = 48101;
|
||||
static const huddleParticipantLeft = 48102;
|
||||
static const huddleEnded = 48103;
|
||||
|
||||
/// Event kinds that represent user-visible channel messages.
|
||||
static const channelMessageEventKinds = [
|
||||
streamMessage, // 9
|
||||
streamMessageV2, // 40002
|
||||
forumPost, // 45001
|
||||
forumComment, // 45003
|
||||
];
|
||||
|
||||
/// Event kinds that represent channel activity (messages, edits, reactions,
|
||||
/// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`.
|
||||
static const channelEventKinds = [
|
||||
deletion, // 5
|
||||
reaction, // 7
|
||||
nip29DeleteEvent, // 9005 — Buzz-native deletion
|
||||
...channelMessageEventKinds,
|
||||
40001, // legacy pre-migration stream messages
|
||||
streamMessageEdit, // 40003
|
||||
streamMessageDiff, // 40008
|
||||
systemMessage, // 40099
|
||||
huddleStarted, // 48100 — visible huddle session row
|
||||
huddleParticipantJoined, // 48101 — huddle lifecycle metadata
|
||||
huddleParticipantLeft, // 48102 — huddle lifecycle metadata
|
||||
huddleEnded, // 48103 — visible huddle ended row
|
||||
];
|
||||
|
||||
/// Auxiliary timeline kinds that overlay or hide existing rows.
|
||||
static const channelAuxEventKinds = [
|
||||
deletion,
|
||||
reaction,
|
||||
nip29DeleteEvent,
|
||||
streamMessageEdit,
|
||||
];
|
||||
|
||||
/// Visible content kinds requested by the NIP-CW channel-window path.
|
||||
static const channelTimelineContentKinds = [
|
||||
streamMessage,
|
||||
streamMessageV2,
|
||||
streamMessageDiff,
|
||||
systemMessage,
|
||||
jobRequest,
|
||||
jobAccepted,
|
||||
jobProgress,
|
||||
jobResult,
|
||||
jobCancel,
|
||||
jobError,
|
||||
huddleStarted,
|
||||
];
|
||||
}
|
||||
|
||||
/// A Nostr event as defined by NIP-01.
|
||||
@immutable
|
||||
class NostrEvent {
|
||||
final String id;
|
||||
final String pubkey;
|
||||
final int createdAt;
|
||||
final int kind;
|
||||
final List<List<String>> tags;
|
||||
final String content;
|
||||
final String sig;
|
||||
|
||||
const NostrEvent({
|
||||
required this.id,
|
||||
required this.pubkey,
|
||||
required this.createdAt,
|
||||
required this.kind,
|
||||
required this.tags,
|
||||
required this.content,
|
||||
required this.sig,
|
||||
});
|
||||
|
||||
factory NostrEvent.fromJson(Map<String, dynamic> json) {
|
||||
return NostrEvent(
|
||||
id: json['id'] as String,
|
||||
pubkey: json['pubkey'] as String,
|
||||
createdAt: json['created_at'] as int,
|
||||
kind: json['kind'] as int,
|
||||
tags: (json['tags'] as List<dynamic>)
|
||||
.map((t) => (t as List<dynamic>).map((e) => e as String).toList())
|
||||
.toList(),
|
||||
content: json['content'] as String,
|
||||
sig: json['sig'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'pubkey': pubkey,
|
||||
'created_at': createdAt,
|
||||
'kind': kind,
|
||||
'tags': tags,
|
||||
'content': content,
|
||||
'sig': sig,
|
||||
};
|
||||
|
||||
/// Get the first value for a given tag key.
|
||||
String? getTagValue(String key) {
|
||||
for (final tag in tags) {
|
||||
if (tag.isNotEmpty && tag[0] == key && tag.length > 1) {
|
||||
return tag[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The channel/group ID from the `h` tag (NIP-29).
|
||||
String? get channelId => getTagValue('h');
|
||||
|
||||
/// Extract thread parent and root IDs from `e` tags.
|
||||
///
|
||||
/// Matches the desktop's `getThreadReference` logic:
|
||||
/// - Tags with marker `"reply"` identify the direct parent.
|
||||
/// - Tags with marker `"root"` identify the thread root.
|
||||
/// - If no markers are present, falls back to null (top-level message).
|
||||
({String? parentId, String? rootId}) get threadReference {
|
||||
final eTags = [
|
||||
for (final tag in tags)
|
||||
if (tag.length >= 2 && tag[0] == 'e') tag,
|
||||
];
|
||||
|
||||
if (eTags.isEmpty) return (parentId: null, rootId: null);
|
||||
|
||||
// Find tagged root and reply markers (desktop convention).
|
||||
List<String>? rootTag;
|
||||
List<String>? replyTag;
|
||||
for (final tag in eTags) {
|
||||
if (tag.length >= 4) {
|
||||
if (tag[3] == 'root') rootTag = tag;
|
||||
if (tag[3] == 'reply') replyTag = tag;
|
||||
}
|
||||
}
|
||||
|
||||
if (replyTag == null) return (parentId: null, rootId: null);
|
||||
|
||||
final parentId = replyTag[1];
|
||||
final rootId = rootTag?[1] ?? parentId;
|
||||
return (parentId: parentId, rootId: rootId);
|
||||
}
|
||||
|
||||
/// The parent event ID from the `e` tag.
|
||||
String? get parentEventId => threadReference.parentId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is NostrEvent && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// A NIP-01 subscription filter.
|
||||
@immutable
|
||||
class NostrFilter {
|
||||
final List<int> kinds;
|
||||
final List<String>? authors;
|
||||
|
||||
/// Specific event IDs (NIP-01 single-event lookup).
|
||||
final List<String>? ids;
|
||||
final int limit;
|
||||
final int? since;
|
||||
final int? until;
|
||||
|
||||
/// NIP-50 full-text search query.
|
||||
final String? search;
|
||||
|
||||
/// Tag filters, e.g. `{'#h': ['channel-id']}`.
|
||||
final Map<String, List<String>> tags;
|
||||
|
||||
/// Buzz relay bridge filter extensions (for example NIP-CW `top_level`).
|
||||
final Map<String, Object?> extensions;
|
||||
|
||||
const NostrFilter({
|
||||
required this.kinds,
|
||||
this.authors,
|
||||
this.ids,
|
||||
this.limit = 100,
|
||||
this.since,
|
||||
this.until,
|
||||
this.search,
|
||||
this.tags = const {},
|
||||
this.extensions = const {},
|
||||
});
|
||||
|
||||
/// Return a copy with an updated `since` value.
|
||||
NostrFilter copyWithSince(int since) => NostrFilter(
|
||||
kinds: kinds,
|
||||
authors: authors,
|
||||
ids: ids,
|
||||
limit: limit,
|
||||
since: since,
|
||||
until: until,
|
||||
search: search,
|
||||
tags: tags,
|
||||
extensions: extensions,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{'kinds': kinds, 'limit': limit};
|
||||
if (authors != null) json['authors'] = authors;
|
||||
if (ids != null) json['ids'] = ids;
|
||||
if (since != null) json['since'] = since;
|
||||
if (until != null) json['until'] = until;
|
||||
if (search != null) json['search'] = search;
|
||||
for (final entry in tags.entries) {
|
||||
json[entry.key] = entry.value;
|
||||
}
|
||||
for (final entry in extensions.entries) {
|
||||
json[entry.key] = entry.value;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed kind:0 user profile metadata.
|
||||
@immutable
|
||||
class ProfileData {
|
||||
final String pubkey;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String? about;
|
||||
final String? nip05;
|
||||
|
||||
const ProfileData({
|
||||
required this.pubkey,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.about,
|
||||
this.nip05,
|
||||
});
|
||||
|
||||
factory ProfileData.fromEvent(NostrEvent event) {
|
||||
Map<String, dynamic> meta = {};
|
||||
try {
|
||||
final decoded = jsonDecode(event.content);
|
||||
if (decoded is Map<String, dynamic>) meta = decoded;
|
||||
} catch (_) {}
|
||||
return ProfileData(
|
||||
pubkey: event.pubkey,
|
||||
displayName:
|
||||
(meta['display_name'] as String?) ?? (meta['name'] as String?),
|
||||
avatarUrl: meta['picture'] as String?,
|
||||
about: meta['about'] as String?,
|
||||
nip05: meta['nip05'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed kind:39000 channel metadata.
|
||||
@immutable
|
||||
class ChannelData {
|
||||
final String id;
|
||||
final String name;
|
||||
final String channelType;
|
||||
final String visibility;
|
||||
final String description;
|
||||
final String? topic;
|
||||
final List<String> participantPubkeys;
|
||||
final int? ttlSeconds;
|
||||
final DateTime? ttlDeadline;
|
||||
final bool isArchived;
|
||||
|
||||
const ChannelData({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.channelType,
|
||||
required this.visibility,
|
||||
required this.description,
|
||||
this.topic,
|
||||
this.participantPubkeys = const [],
|
||||
this.ttlSeconds,
|
||||
this.ttlDeadline,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
factory ChannelData.fromEvent(NostrEvent event) {
|
||||
final id = event.getTagValue('d') ?? '';
|
||||
final name = event.getTagValue('name') ?? '';
|
||||
// Prefer explicit ["t", type]; fall back to ["hidden"] => dm, else "stream".
|
||||
// The fallback exists for relays that haven't been upgraded to emit the
|
||||
// explicit type tag yet.
|
||||
final explicitType = event.getTagValue('t');
|
||||
final hasHidden = event.tags.any((t) => t.isNotEmpty && t[0] == 'hidden');
|
||||
final channelType = explicitType ?? (hasHidden ? 'dm' : 'stream');
|
||||
// Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private".
|
||||
final hasPublic = event.tags.any((t) => t.isNotEmpty && t[0] == 'public');
|
||||
final hasPrivate = event.tags.any((t) => t.isNotEmpty && t[0] == 'private');
|
||||
final visibility = hasPublic
|
||||
? 'open'
|
||||
: hasPrivate
|
||||
? 'private'
|
||||
: 'open';
|
||||
final description = event.getTagValue('about') ?? '';
|
||||
final topic = event.getTagValue('topic');
|
||||
final participants = [
|
||||
for (final t in event.tags)
|
||||
if (t.length >= 2 && t[0] == 'p') t[1],
|
||||
];
|
||||
final ttlRaw = event.getTagValue('ttl');
|
||||
final ttlSeconds = ttlRaw != null ? int.tryParse(ttlRaw) : null;
|
||||
final ttlDeadlineRaw = event.getTagValue('ttl_deadline');
|
||||
final ttlDeadline = ttlDeadlineRaw != null
|
||||
? DateTime.tryParse(ttlDeadlineRaw)
|
||||
: null;
|
||||
// Relay republishes kind:39000 with `["archived", "true"]` when a channel
|
||||
// is archived (including the auto-archive emitted by the TTL reaper). The
|
||||
// tag value "false" is also accepted server-side, so only treat "true" as
|
||||
// archived — anything else (missing tag, "false", unexpected value) means
|
||||
// active.
|
||||
final isArchived = event.getTagValue('archived') == 'true';
|
||||
return ChannelData(
|
||||
id: id,
|
||||
name: name,
|
||||
channelType: channelType,
|
||||
visibility: visibility,
|
||||
description: description,
|
||||
topic: topic,
|
||||
participantPubkeys: participants,
|
||||
ttlSeconds: ttlSeconds,
|
||||
ttlDeadline: ttlDeadline,
|
||||
isArchived: isArchived,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single member entry parsed from a kind:39002 members event.
|
||||
@immutable
|
||||
class MemberEntry {
|
||||
final String pubkey;
|
||||
final String role;
|
||||
|
||||
const MemberEntry({required this.pubkey, required this.role});
|
||||
}
|
||||
|
||||
/// Parse a kind:39002 members event into the list of `(pubkey, role)` entries.
|
||||
///
|
||||
/// NIP-29 members tags follow the shape `["p", <pubkey>, <relay>, <role>]`.
|
||||
List<MemberEntry> membersFromEvent(NostrEvent event) {
|
||||
return [
|
||||
for (final t in event.tags)
|
||||
if (t.length >= 2 && t[0] == 'p')
|
||||
MemberEntry(pubkey: t[1], role: t.length >= 4 ? t[3] : 'member'),
|
||||
];
|
||||
}
|
||||
|
||||
/// Parse a Buzz command response from the relay's OK message content.
|
||||
///
|
||||
/// Command kinds (e.g. 41010, 30620, 46020) return `"response:{...}"` in the
|
||||
/// OK message. Returns `null` if the message is not a command response or the
|
||||
/// JSON is invalid.
|
||||
Map<String, dynamic>? parseCommandResponse(String message) {
|
||||
// Try the spec format first: "response:{...}".
|
||||
const prefix = 'response:';
|
||||
if (message.startsWith(prefix)) {
|
||||
try {
|
||||
final decoded = jsonDecode(message.substring(prefix.length));
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
// Fallback: raw JSON object (older relays, backward compat).
|
||||
try {
|
||||
final decoded = jsonDecode(message);
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export 'app_lifecycle_provider.dart';
|
||||
export 'identity_scoped_prefs.dart';
|
||||
export 'media_auth.dart';
|
||||
export 'media_image.dart';
|
||||
export 'media_upload.dart';
|
||||
export 'nostr_filters.dart';
|
||||
export 'nostr_models.dart';
|
||||
export 'relay_closed_policy.dart';
|
||||
export 'relay_client.dart';
|
||||
export 'relay_provider.dart';
|
||||
export 'relay_rate_limit_gate.dart';
|
||||
export 'relay_session.dart';
|
||||
export 'relay_socket.dart';
|
||||
export 'signed_event_relay.dart';
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Lightweight HTTP context for talking to the Buzz relay.
|
||||
///
|
||||
/// In the pure-nostr architecture, all data flow happens over the relay
|
||||
/// WebSocket. This client now exists only to provide a base URL (and a
|
||||
/// shared HTTP client) for the media upload endpoint, which is the one
|
||||
/// remaining HTTP path because Blossom uses kind:24242 NIP-98 auth on a
|
||||
/// regular HTTP PUT.
|
||||
class RelayClient {
|
||||
final String baseUrl;
|
||||
final http.Client _http;
|
||||
|
||||
RelayClient({required this.baseUrl, http.Client? httpClient})
|
||||
: _http = httpClient ?? http.Client();
|
||||
|
||||
/// Shared underlying HTTP client (used by [MediaUploader]).
|
||||
http.Client get httpClient => _http;
|
||||
|
||||
/// Fully-qualified URL for the relay's Blossom-style media upload endpoint.
|
||||
String get mediaUploadUrl {
|
||||
final base = Uri.parse(baseUrl);
|
||||
return base.resolve('/upload').toString();
|
||||
}
|
||||
|
||||
void dispose() => _http.close();
|
||||
}
|
||||
|
||||
/// Thrown when an HTTP call to the relay returns a non-2xx status code.
|
||||
///
|
||||
/// Retained for backwards compatibility with provider code that still
|
||||
/// references it during the migration to pure-nostr WebSocket flows.
|
||||
class RelayException implements Exception {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
|
||||
RelayException(this.statusCode, this.body);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final trimmedBody = body.trim();
|
||||
if (trimmedBody.isEmpty) {
|
||||
return 'RelayException($statusCode)';
|
||||
}
|
||||
return 'RelayException($statusCode): $trimmedBody';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// Recovery policy for a relay `CLOSED` subscription message.
|
||||
enum RelayClosedClass {
|
||||
/// A transient failure that may recover when the same REQ is retried.
|
||||
retryable,
|
||||
|
||||
/// Relay back-pressure that must also arm the shared request gate.
|
||||
rateLimited,
|
||||
|
||||
/// An authorization, access, or filter failure that cannot recover unchanged.
|
||||
terminal,
|
||||
}
|
||||
|
||||
/// Classifies whether a relay `CLOSED` message should be retried.
|
||||
RelayClosedClass classifyRelayClosed(String message) {
|
||||
final normalized = message.trim().toLowerCase();
|
||||
if (normalized.startsWith('rate-limited:')) {
|
||||
return RelayClosedClass.rateLimited;
|
||||
}
|
||||
if (normalized.startsWith('restricted:') ||
|
||||
normalized.startsWith('auth-required:') ||
|
||||
normalized.startsWith('blocked:') ||
|
||||
normalized.startsWith('invalid:') ||
|
||||
normalized.startsWith('pow:') ||
|
||||
normalized.startsWith('duplicate:') ||
|
||||
normalized.startsWith('unsupported:') ||
|
||||
normalized.startsWith('error: mixed search') ||
|
||||
normalized.startsWith('error: too many subscriptions')) {
|
||||
return RelayClosedClass.terminal;
|
||||
}
|
||||
return RelayClosedClass.retryable;
|
||||
}
|
||||
|
||||
final _rateLimitRetryPattern = RegExp(r'retry in (\d+)s', caseSensitive: false);
|
||||
|
||||
/// Parses the relay's canonical `retry in Ns` hint, when present.
|
||||
int? parseRateLimitRetrySeconds(String message) {
|
||||
final match = _rateLimitRetryPattern.firstMatch(message);
|
||||
return match == null ? null : int.tryParse(match.group(1)!);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../community/community_provider.dart';
|
||||
import 'relay_client.dart';
|
||||
|
||||
/// Relay connection configuration.
|
||||
///
|
||||
/// In the pure-nostr world the only secrets the app cares about are:
|
||||
/// - `baseUrl` — where the relay lives (used for WS + media upload)
|
||||
/// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs)
|
||||
class RelayConfig {
|
||||
const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl;
|
||||
|
||||
/// Relay origin exactly as the active community stored it.
|
||||
final String _baseUrl;
|
||||
|
||||
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH.
|
||||
final String? nsec;
|
||||
|
||||
/// The origin as persisted, before scheme canonicalization.
|
||||
///
|
||||
/// Exists solely so identity-scoped storage keys written before [baseUrl]
|
||||
/// was canonicalized stay reachable — see [readMigratedPref]. Never use it
|
||||
/// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to.
|
||||
String get storedOrigin => _baseUrl;
|
||||
|
||||
/// Relay origin as an HTTP(S) URL.
|
||||
///
|
||||
/// Communities are persisted with whichever scheme their onboarding flow
|
||||
/// used: device pairing stores `https://` (it rejects anything else), while
|
||||
/// an invite join stores the `wss://` relay URL carried by the invite link.
|
||||
/// Every consumer treats this as an HTTP origin — [wsUrl], the `/query`
|
||||
/// endpoint, media upload and Blossom auth — so a `wss://` base silently
|
||||
/// degrades all of them. Folding the websocket schemes back here keeps both
|
||||
/// onboarding paths equivalent, including for already-persisted communities.
|
||||
///
|
||||
/// Derived rather than normalized in the constructor so that the constructor
|
||||
/// stays `const`: the compile-time fallback below relies on canonicalization
|
||||
/// to keep its identity stable across rebuilds, and Riverpod's default
|
||||
/// `updateShouldNotify` is `previous != next`, which falls back to identity
|
||||
/// here. A fresh instance per rebuild would resubscribe every listener.
|
||||
String get baseUrl {
|
||||
final uri = Uri.tryParse(_baseUrl);
|
||||
if (uri == null) return _baseUrl;
|
||||
final scheme = switch (uri.scheme) {
|
||||
'wss' => 'https',
|
||||
'ws' => 'http',
|
||||
_ => null,
|
||||
};
|
||||
return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString();
|
||||
}
|
||||
|
||||
/// Derive the websocket URL from the HTTP base URL.
|
||||
String get wsUrl {
|
||||
final uri = Uri.parse(baseUrl);
|
||||
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||
return uri.replace(scheme: scheme).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile-time environment config via --dart-define.
|
||||
///
|
||||
/// Run with:
|
||||
/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000
|
||||
///
|
||||
/// Or create a `.env.json` and use --dart-define-from-file=.env.json
|
||||
class Env {
|
||||
static const relayUrl = String.fromEnvironment(
|
||||
'BUZZ_RELAY_URL',
|
||||
defaultValue: 'http://localhost:3000',
|
||||
);
|
||||
}
|
||||
|
||||
class RelayConfigNotifier extends Notifier<RelayConfig> {
|
||||
@override
|
||||
RelayConfig build() {
|
||||
// Watch the active community so that when it changes (community switch),
|
||||
// the config rebuilds, triggering the full provider cascade.
|
||||
final activeAsync = ref.watch(activeCommunityProvider);
|
||||
final active = activeAsync.value;
|
||||
if (active != null) {
|
||||
return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec);
|
||||
}
|
||||
|
||||
// Fallback to compile-time env config (dev mode).
|
||||
return const RelayConfig(baseUrl: Env.relayUrl);
|
||||
}
|
||||
|
||||
void update({required String baseUrl, String? nsec}) {
|
||||
state = RelayConfig(baseUrl: baseUrl, nsec: nsec);
|
||||
}
|
||||
}
|
||||
|
||||
final relayConfigProvider = NotifierProvider<RelayConfigNotifier, RelayConfig>(
|
||||
RelayConfigNotifier.new,
|
||||
);
|
||||
|
||||
/// Derive the hex pubkey from a bech32 nsec, or null on any failure.
|
||||
String? pubkeyFromNsec(String? nsec) {
|
||||
if (nsec == null || nsec.isEmpty) return null;
|
||||
try {
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) return null;
|
||||
return nostr.Keys(privkeyHex).public;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The current user's hex pubkey, derived from the active community nsec.
|
||||
final myPubkeyProvider = Provider<String?>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
return pubkeyFromNsec(config.nsec);
|
||||
});
|
||||
|
||||
/// Provides a [RelayClient] that reacts to config changes.
|
||||
///
|
||||
/// Only used for the media upload HTTP endpoint now — all data flow goes
|
||||
/// through the relay WebSocket session.
|
||||
final relayClientProvider = Provider<RelayClient>((ref) {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final client = RelayClient(baseUrl: config.baseUrl);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
/// Creates a timer used by [RelayRateLimitGate].
|
||||
typedef RelayTimerFactory =
|
||||
Timer Function(Duration duration, void Function() callback);
|
||||
|
||||
/// Session-owned gate that pauses relay requests after back-pressure.
|
||||
class RelayRateLimitGate {
|
||||
/// Default gate duration when the relay omits a positive retry hint.
|
||||
static const defaultRetrySeconds = 10;
|
||||
|
||||
/// Longest retry hint accepted from a relay response.
|
||||
static const maxRetrySeconds = 300;
|
||||
|
||||
RelayRateLimitGate({
|
||||
DateTime Function()? now,
|
||||
RelayTimerFactory timerFactory = Timer.new,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_timerFactory = timerFactory;
|
||||
|
||||
final DateTime Function() _now;
|
||||
final RelayTimerFactory _timerFactory;
|
||||
DateTime? _expiresAt;
|
||||
Timer? _timer;
|
||||
Completer<void>? _completer;
|
||||
|
||||
/// Whether a rate-limit window is currently active.
|
||||
bool get isActive {
|
||||
final expiresAt = _expiresAt;
|
||||
return expiresAt != null && _now().isBefore(expiresAt);
|
||||
}
|
||||
|
||||
/// Activates or extends the gate without shrinking an existing window.
|
||||
void activate(int? retryInSeconds) {
|
||||
final seconds = retryInSeconds != null && retryInSeconds > 0
|
||||
? min(retryInSeconds, maxRetrySeconds)
|
||||
: defaultRetrySeconds;
|
||||
final duration = Duration(seconds: seconds);
|
||||
final newExpiry = _now().add(duration);
|
||||
final currentExpiry = _expiresAt;
|
||||
if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return;
|
||||
|
||||
_expiresAt = newExpiry;
|
||||
_timer?.cancel();
|
||||
_completer ??= Completer<void>();
|
||||
_timer = _timerFactory(duration, _expire);
|
||||
}
|
||||
|
||||
/// Resolves when the active rate-limit window expires.
|
||||
Future<void> wait() {
|
||||
if (!isActive) return Future.value();
|
||||
return _completer!.future;
|
||||
}
|
||||
|
||||
/// Milliseconds remaining in the active window, or zero when inactive.
|
||||
int remainingMs() {
|
||||
final expiresAt = _expiresAt;
|
||||
if (expiresAt == null) return 0;
|
||||
return max(0, expiresAt.difference(_now()).inMilliseconds);
|
||||
}
|
||||
|
||||
/// Clears the gate and releases all current waiters.
|
||||
void reset() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_expiresAt = null;
|
||||
final completer = _completer;
|
||||
_completer = null;
|
||||
if (completer != null && !completer.isCompleted) completer.complete();
|
||||
}
|
||||
|
||||
void _expire() {
|
||||
_timer = null;
|
||||
_expiresAt = null;
|
||||
final completer = _completer;
|
||||
_completer = null;
|
||||
if (completer != null && !completer.isCompleted) completer.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:pointycastle/digests/sha256.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../auth/auth.dart';
|
||||
import 'nostr_models.dart';
|
||||
import 'relay_client.dart';
|
||||
import 'relay_closed_policy.dart';
|
||||
import 'relay_provider.dart';
|
||||
import 'relay_rate_limit_gate.dart';
|
||||
import 'relay_socket.dart';
|
||||
|
||||
enum SessionStatus { disconnected, connecting, connected, reconnecting }
|
||||
|
||||
@immutable
|
||||
class SessionState {
|
||||
final SessionStatus status;
|
||||
final int reconnectAttempt;
|
||||
|
||||
const SessionState({required this.status, this.reconnectAttempt = 0});
|
||||
}
|
||||
|
||||
class _HistorySubscription {
|
||||
final List<NostrEvent> events = [];
|
||||
final Completer<List<NostrEvent>> completer;
|
||||
final Timer timeout;
|
||||
|
||||
_HistorySubscription({required this.completer, required this.timeout});
|
||||
}
|
||||
|
||||
class _LiveSubscription {
|
||||
final NostrFilter filter;
|
||||
final void Function(NostrEvent) onEvent;
|
||||
final void Function(String message)? onClosed;
|
||||
Completer<void>? readyCompleter;
|
||||
int? lastSeenCreatedAt;
|
||||
int closedRetryAttempt = 0;
|
||||
Timer? closedRetryTimer;
|
||||
|
||||
_LiveSubscription({
|
||||
required this.filter,
|
||||
required this.onEvent,
|
||||
this.onClosed,
|
||||
this.readyCompleter,
|
||||
});
|
||||
}
|
||||
|
||||
class _ClosedRetry {
|
||||
final _LiveSubscription subscription;
|
||||
final int generation;
|
||||
|
||||
_ClosedRetry({required this.subscription, required this.generation});
|
||||
}
|
||||
|
||||
class _PendingEvent {
|
||||
final Completer<NostrEvent> completer;
|
||||
final Timer timeout;
|
||||
|
||||
_PendingEvent({required this.completer, required this.timeout});
|
||||
}
|
||||
|
||||
class _BufferedEvent {
|
||||
final String subId;
|
||||
final NostrEvent event;
|
||||
|
||||
_BufferedEvent(this.subId, this.event);
|
||||
}
|
||||
|
||||
/// Manages websocket subscriptions, event batching, reconnection with replay,
|
||||
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
|
||||
typedef RelaySocketFactory =
|
||||
RelaySocket Function({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
});
|
||||
|
||||
class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
RelaySessionNotifier({
|
||||
http.Client? httpClient,
|
||||
RelaySocketFactory socketFactory = RelaySocket.new,
|
||||
DateTime Function()? now,
|
||||
RelayRateLimitGate? rateLimitGate,
|
||||
RelayTimerFactory retryTimerFactory = Timer.new,
|
||||
Future<void> Function(Duration) replayDelay = Future.delayed,
|
||||
}) : _httpClient = httpClient,
|
||||
_socketFactory = socketFactory,
|
||||
_now = now ?? DateTime.now,
|
||||
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
|
||||
_retryTimerFactory = retryTimerFactory,
|
||||
_replayDelay = replayDelay;
|
||||
|
||||
final http.Client? _httpClient;
|
||||
final RelaySocketFactory _socketFactory;
|
||||
final DateTime Function() _now;
|
||||
final RelayRateLimitGate _rateLimitGate;
|
||||
final RelayTimerFactory _retryTimerFactory;
|
||||
final Future<void> Function(Duration) _replayDelay;
|
||||
|
||||
static const _baseReconnectDelayMs = 1000;
|
||||
static const _maxReconnectDelayMs = 30000;
|
||||
static const _eventBatchMs = 16;
|
||||
static const _reconnectReplaySkewSeconds = 5;
|
||||
static const _replayBatchSize = 8;
|
||||
static const _replayInterBatchDelay = Duration(milliseconds: 50);
|
||||
static const _maxRecentDeliveryKeys = 5000;
|
||||
static const _backgroundGraceDuration = Duration(seconds: 5);
|
||||
|
||||
RelaySocket? _socket;
|
||||
final Map<String, _HistorySubscription> _historySubscriptions = {};
|
||||
final Map<String, _LiveSubscription> _liveSubscriptions = {};
|
||||
final Map<String, _ClosedRetry> _pendingClosedRetries = {};
|
||||
final Map<String, _PendingEvent> _pendingEvents = {};
|
||||
final List<_BufferedEvent> _eventBuffer = [];
|
||||
final Set<String> _recentDeliveryKeys = {};
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _flushTimer;
|
||||
Timer? _backgroundGraceTimer;
|
||||
DateTime? _backgroundedAt;
|
||||
int _reconnectDelayMs = _baseReconnectDelayMs;
|
||||
int _subIdCounter = 0;
|
||||
bool _disposed = false;
|
||||
bool _paused = false;
|
||||
bool _hasConnectedOnce = false;
|
||||
int _connectionGeneration = 0;
|
||||
final Map<Object, String> _visibleChannelsByOwner = {};
|
||||
bool _socketConnected = false;
|
||||
bool _closedRetryReplayScheduled = false;
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// Reset disposed flag — build() may re-run on the same Notifier instance
|
||||
// after a provider dependency changes (e.g. auth completing).
|
||||
_disposed = false;
|
||||
|
||||
ref.onDispose(_dispose);
|
||||
|
||||
// Auto-connect when authenticated and we have a signing key (NIP-42 AUTH).
|
||||
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
|
||||
if (isAuthenticated && config.nsec != null) {
|
||||
// Schedule connection after build completes.
|
||||
Future.microtask(() => _connect(config));
|
||||
}
|
||||
|
||||
return const SessionState(status: SessionStatus.disconnected);
|
||||
}
|
||||
|
||||
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
|
||||
Future<List<NostrEvent>> queryRelay(
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final config = ref.read(relayConfigProvider);
|
||||
final url = Uri.parse(config.baseUrl).resolve('/query').toString();
|
||||
final bodyBytes = utf8.encode(
|
||||
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
|
||||
);
|
||||
final client = _httpClient ?? http.Client();
|
||||
final shouldCloseClient = _httpClient == null;
|
||||
final response = await client
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'Authorization': buildNip98AuthHeader(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
bodyBytes: bodyBytes,
|
||||
nsec: config.nsec,
|
||||
),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: bodyBytes,
|
||||
)
|
||||
.timeout(timeout)
|
||||
.whenComplete(() {
|
||||
if (shouldCloseClient) client.close();
|
||||
});
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
_activateRateLimitGateFromHttpError(response.body);
|
||||
throw RelayException(response.statusCode, response.body);
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! List) {
|
||||
throw const FormatException('relay returned malformed query response');
|
||||
}
|
||||
try {
|
||||
return [
|
||||
for (final eventJson in decoded)
|
||||
if (eventJson is Map<String, dynamic>)
|
||||
NostrEvent.fromJson(eventJson)
|
||||
else
|
||||
throw const FormatException('relay returned malformed query event'),
|
||||
];
|
||||
} catch (error) {
|
||||
if (error is FormatException) rethrow;
|
||||
throw FormatException('relay returned malformed query event: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _activateRateLimitGateFromHttpError(String body) {
|
||||
final dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} on FormatException {
|
||||
return;
|
||||
}
|
||||
if (decoded is! Map<String, dynamic>) return;
|
||||
final message = decoded['error'];
|
||||
if (message is! String ||
|
||||
classifyRelayClosed(message) != RelayClosedClass.rateLimited) {
|
||||
return;
|
||||
}
|
||||
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
||||
}
|
||||
|
||||
/// Fetch historical events matching [filter]. Sends REQ, collects events
|
||||
/// until EOSE, then resolves. One-shot subscription.
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
||||
if (_disposed) throw StateError('Relay session is disposed');
|
||||
final subId = _nextSubId('h');
|
||||
final completer = Completer<List<NostrEvent>>();
|
||||
|
||||
final timer = Timer(timeout, () {
|
||||
final sub = _historySubscriptions.remove(subId);
|
||||
if (sub != null && !sub.completer.isCompleted) {
|
||||
sub.completer.completeError(
|
||||
TimeoutException('Relay history request timed out after $timeout'),
|
||||
);
|
||||
}
|
||||
_sendClose(subId);
|
||||
});
|
||||
|
||||
_historySubscriptions[subId] = _HistorySubscription(
|
||||
completer: completer,
|
||||
timeout: timer,
|
||||
);
|
||||
|
||||
_sendReq(subId, filter);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Subscribe to live events matching [filter]. Returns an unsubscribe
|
||||
/// function. Live subscriptions survive reconnects — they are replayed with
|
||||
/// `since: lastSeenCreatedAt - 5s` on reconnect.
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
if (_disposed) throw StateError('Relay session is disposed');
|
||||
final subId = _nextSubId('l');
|
||||
final readyCompleter = Completer<void>();
|
||||
|
||||
_liveSubscriptions[subId] = _LiveSubscription(
|
||||
filter: filter,
|
||||
onEvent: onEvent,
|
||||
onClosed: onClosed,
|
||||
readyCompleter: readyCompleter,
|
||||
);
|
||||
|
||||
_sendReq(subId, filter);
|
||||
|
||||
// Wait for EOSE or a short fallback timeout.
|
||||
try {
|
||||
await readyCompleter.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
} catch (_) {
|
||||
_liveSubscriptions.remove(subId);
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
rethrow;
|
||||
}
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
|
||||
return () => _unsubscribe(subId);
|
||||
}
|
||||
|
||||
/// Publish an event and wait for the relay's OK confirmation.
|
||||
Future<NostrEvent> publish(
|
||||
NostrEvent event, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) {
|
||||
final completer = Completer<NostrEvent>();
|
||||
|
||||
final timer = Timer(timeout, () {
|
||||
final pending = _pendingEvents.remove(event.id);
|
||||
if (pending != null && !pending.completer.isCompleted) {
|
||||
pending.completer.completeError(
|
||||
TimeoutException(
|
||||
'Event ${event.id} not acknowledged within $timeout',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
_pendingEvents[event.id] = _PendingEvent(
|
||||
completer: completer,
|
||||
timeout: timer,
|
||||
);
|
||||
|
||||
_socket?.send(['EVENT', event.toJson()]);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Send a raw message over the WebSocket without waiting for acknowledgement.
|
||||
/// Used for ephemeral events like typing indicators.
|
||||
void sendRaw(List<dynamic> payload) {
|
||||
_socket?.send(payload);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
|
||||
|
||||
@visibleForTesting
|
||||
void debugFlushEventBuffer() => _flushEventBuffer();
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugHandleConnected() =>
|
||||
_handleConnected(_connectionGeneration);
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> debugReplayLiveSubscriptions() =>
|
||||
_replayLiveSubscriptions(_connectionGeneration);
|
||||
|
||||
@visibleForTesting
|
||||
void debugDispose() => _dispose();
|
||||
|
||||
@visibleForTesting
|
||||
void debugSupersedeConnection() => _connectionGeneration++;
|
||||
|
||||
@visibleForTesting
|
||||
void debugHandleDisconnected([Object? error]) {
|
||||
_socketConnected = false;
|
||||
_handleDisconnected(_connectionGeneration, error);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugResetClosedRetriesForDisconnect() {
|
||||
_socketConnected = false;
|
||||
_resetAllClosedRetries();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugSetSessionStatus(SessionStatus status) {
|
||||
_socketConnected = status == SessionStatus.connected;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugPauseNow() => _pauseNow();
|
||||
|
||||
@visibleForTesting
|
||||
void debugHandleSocketMessageForTest(List<dynamic> data) =>
|
||||
_handleMessage(data);
|
||||
|
||||
@visibleForTesting
|
||||
void debugAttachSocketForTest(RelaySocket socket) {
|
||||
_socket?.dispose();
|
||||
_socket = socket;
|
||||
_socketConnected = true;
|
||||
}
|
||||
|
||||
/// Registers a visible channel and returns an owner-scoped release callback.
|
||||
/// The most recently registered owner is prioritized during reconnect replay.
|
||||
void Function() registerVisibleChannel(String channelId) {
|
||||
final owner = Object();
|
||||
_visibleChannelsByOwner[owner] = channelId;
|
||||
return () => _visibleChannelsByOwner.remove(owner);
|
||||
}
|
||||
|
||||
/// Force a reconnect (e.g., returning from background).
|
||||
Future<void> reconnect() async {
|
||||
_socketConnected = false;
|
||||
await _socket?.disconnect();
|
||||
_reconnectDelayMs = _baseReconnectDelayMs;
|
||||
final config = ref.read(relayConfigProvider);
|
||||
await _connect(config);
|
||||
}
|
||||
|
||||
/// Called by the app lifecycle provider when the app goes to background.
|
||||
void onAppPaused() {
|
||||
_backgroundedAt = _now();
|
||||
_backgroundGraceTimer?.cancel();
|
||||
_backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow);
|
||||
}
|
||||
|
||||
void _pauseNow() {
|
||||
_paused = true;
|
||||
_socketConnected = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_cancelAllHistory(Exception('App moved to background'));
|
||||
_rejectAllPending(Exception('App moved to background'));
|
||||
_socket?.disconnect();
|
||||
state = const SessionState(status: SessionStatus.disconnected);
|
||||
}
|
||||
|
||||
/// Called by the app lifecycle provider when the app returns to foreground.
|
||||
void onAppResumed() {
|
||||
_paused = false;
|
||||
final backgroundedAt = _backgroundedAt;
|
||||
_backgroundedAt = null;
|
||||
_backgroundGraceTimer?.cancel();
|
||||
_backgroundGraceTimer = null;
|
||||
|
||||
final backgroundedLongEnoughToRequireReconnect =
|
||||
backgroundedAt != null &&
|
||||
_now().difference(backgroundedAt) >= _backgroundGraceDuration;
|
||||
if (!backgroundedLongEnoughToRequireReconnect &&
|
||||
state.status == SessionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
|
||||
// instead of waiting for the (possibly large) exponential delay.
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectDelayMs = _baseReconnectDelayMs;
|
||||
final config = ref.read(relayConfigProvider);
|
||||
_connect(config);
|
||||
}
|
||||
|
||||
Future<void> _connect(RelayConfig config) async {
|
||||
if (_disposed) return;
|
||||
|
||||
final generation = ++_connectionGeneration;
|
||||
state = SessionState(
|
||||
status: _hasConnectedOnce
|
||||
? SessionStatus.reconnecting
|
||||
: SessionStatus.connecting,
|
||||
reconnectAttempt: state.reconnectAttempt,
|
||||
);
|
||||
|
||||
_socket?.dispose();
|
||||
final socket = _socketFactory(
|
||||
wsUrl: config.wsUrl,
|
||||
nsec: config.nsec,
|
||||
onMessage: (message) {
|
||||
if (generation == _connectionGeneration) _handleMessage(message);
|
||||
},
|
||||
onConnected: () => _handleConnected(generation),
|
||||
onDisconnected: (error) => _handleDisconnected(generation, error),
|
||||
);
|
||||
_socket = socket;
|
||||
|
||||
await socket.connect();
|
||||
}
|
||||
|
||||
Future<void> _handleConnected(int generation) async {
|
||||
if (_disposed || generation != _connectionGeneration) return;
|
||||
_socketConnected = true;
|
||||
_hasConnectedOnce = true;
|
||||
_reconnectDelayMs = _baseReconnectDelayMs;
|
||||
state = const SessionState(status: SessionStatus.connected);
|
||||
await _replayLiveSubscriptions(generation);
|
||||
}
|
||||
|
||||
void _handleDisconnected(int generation, Object? error) {
|
||||
if (_disposed || generation != _connectionGeneration) return;
|
||||
_socketConnected = false;
|
||||
_cancelAllHistory(error);
|
||||
_rejectAllPending(error);
|
||||
_resetAllClosedRetries();
|
||||
_eventBuffer.clear();
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
if (error is RelayAuthRejectedException) {
|
||||
_reconnectTimer?.cancel();
|
||||
state = const SessionState(status: SessionStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed || _paused) return;
|
||||
final attempt = state.reconnectAttempt + 1;
|
||||
state = SessionState(
|
||||
status: SessionStatus.reconnecting,
|
||||
reconnectAttempt: attempt,
|
||||
);
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () {
|
||||
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
|
||||
final config = ref.read(relayConfigProvider);
|
||||
_connect(config);
|
||||
});
|
||||
}
|
||||
|
||||
/// Replay all live subscriptions after a reconnect, with a time skew to
|
||||
/// catch events that occurred during the disconnect.
|
||||
Future<void> _replayLiveSubscriptions(int generation) async {
|
||||
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
||||
if (!_isActiveConnection(generation)) return;
|
||||
|
||||
final entries = _liveSubscriptions.entries.toList();
|
||||
final visibleChannelId = _visibleChannelsByOwner.isEmpty
|
||||
? null
|
||||
: _visibleChannelsByOwner.values.last;
|
||||
if (visibleChannelId != null) {
|
||||
entries.sort((left, right) {
|
||||
final leftVisible =
|
||||
left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
||||
final rightVisible =
|
||||
right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
||||
if (leftVisible == rightVisible) return 0;
|
||||
return leftVisible ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
await _sendReplayBatches(entries, generation);
|
||||
}
|
||||
|
||||
Future<void> _replayPendingClosedRetries(int generation) async {
|
||||
if (!_isActiveConnection(generation)) return;
|
||||
final entries = _pendingClosedRetries.entries
|
||||
.where((entry) => entry.value.generation == generation)
|
||||
.map(
|
||||
(entry) => MapEntry<String, _LiveSubscription>(
|
||||
entry.key,
|
||||
entry.value.subscription,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
await _sendReplayBatches(entries, generation, pendingClosedRetries: true);
|
||||
}
|
||||
|
||||
Future<void> _sendReplayBatches(
|
||||
List<MapEntry<String, _LiveSubscription>> entries,
|
||||
int generation, {
|
||||
bool pendingClosedRetries = false,
|
||||
}) async {
|
||||
for (var i = 0; i < entries.length; i += _replayBatchSize) {
|
||||
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
||||
if (!_isActiveConnection(generation)) return;
|
||||
final batch = entries.sublist(
|
||||
i,
|
||||
min(i + _replayBatchSize, entries.length),
|
||||
);
|
||||
for (final entry in batch) {
|
||||
if (_liveSubscriptions[entry.key] != entry.value) continue;
|
||||
if (pendingClosedRetries) {
|
||||
final pendingRetry = _pendingClosedRetries[entry.key];
|
||||
if (pendingRetry?.subscription != entry.value ||
|
||||
pendingRetry?.generation != generation) {
|
||||
continue;
|
||||
}
|
||||
_pendingClosedRetries.remove(entry.key);
|
||||
}
|
||||
_sendReq(entry.key, _replayFilter(entry.value));
|
||||
}
|
||||
if (i + _replayBatchSize < entries.length) {
|
||||
await _replayDelay(_replayInterBatchDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isActiveConnection(int generation) =>
|
||||
!_disposed && generation == _connectionGeneration;
|
||||
|
||||
NostrFilter _replayFilter(_LiveSubscription subscription) {
|
||||
final since = subscription.lastSeenCreatedAt;
|
||||
return since == null
|
||||
? subscription.filter
|
||||
: subscription.filter.copyWithSince(
|
||||
max(0, since - _reconnectReplaySkewSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMessage(List<dynamic> data) {
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
switch (type) {
|
||||
case 'EVENT':
|
||||
_handleEvent(data);
|
||||
case 'EOSE':
|
||||
_handleEose(data);
|
||||
case 'CLOSED':
|
||||
_handleClosed(data);
|
||||
case 'OK':
|
||||
_handleOk(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final subId = data[1] as String;
|
||||
final eventJson = data[2] as Map<String, dynamic>;
|
||||
final event = NostrEvent.fromJson(eventJson);
|
||||
|
||||
// History subscriptions accumulate immediately.
|
||||
final historySub = _historySubscriptions[subId];
|
||||
if (historySub != null) {
|
||||
historySub.events.add(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live subscriptions get batched.
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null) {
|
||||
_resetClosedRetry(liveSub);
|
||||
// Track last seen timestamp for reconnect replay.
|
||||
if (liveSub.lastSeenCreatedAt == null ||
|
||||
event.createdAt > liveSub.lastSeenCreatedAt!) {
|
||||
liveSub.lastSeenCreatedAt = event.createdAt;
|
||||
}
|
||||
_eventBuffer.add(_BufferedEvent(subId, event));
|
||||
_scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEose(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final subId = data[1] as String;
|
||||
|
||||
// History subscription: resolve with collected events.
|
||||
final historySub = _historySubscriptions.remove(subId);
|
||||
if (historySub != null) {
|
||||
historySub.timeout.cancel();
|
||||
if (!historySub.completer.isCompleted) {
|
||||
historySub.completer.complete(historySub.events);
|
||||
}
|
||||
_sendClose(subId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live subscription: signal ready.
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null) {
|
||||
_resetClosedRetry(liveSub);
|
||||
}
|
||||
if (liveSub != null &&
|
||||
liveSub.readyCompleter != null &&
|
||||
!liveSub.readyCompleter!.isCompleted) {
|
||||
// EOSE is the boundary between replay and live delivery. Flush any
|
||||
// replay events before resolving subscribe(), so callers that begin a
|
||||
// one-shot query immediately afterwards cannot classify a delayed batch
|
||||
// callback as having arrived during that query.
|
||||
_flushBufferedEventsNow();
|
||||
liveSub.readyCompleter!.complete();
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleClosed(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final subId = data[1] as String;
|
||||
final message = data.length >= 3 && data[2] is String
|
||||
? data[2] as String
|
||||
: 'subscription closed by relay';
|
||||
final closedClass = classifyRelayClosed(message);
|
||||
|
||||
final historySub = _historySubscriptions.remove(subId);
|
||||
if (historySub != null) {
|
||||
if (closedClass == RelayClosedClass.rateLimited) {
|
||||
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
||||
}
|
||||
historySub.timeout.cancel();
|
||||
if (!historySub.completer.isCompleted) {
|
||||
historySub.completer.completeError(Exception(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub == null) return;
|
||||
final readyCompleter = liveSub.readyCompleter;
|
||||
if (closedClass == RelayClosedClass.terminal) {
|
||||
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
||||
readyCompleter.completeError(Exception(message));
|
||||
}
|
||||
liveSub.onClosed?.call(message);
|
||||
_removeLiveSubscription(subId, liveSub);
|
||||
return;
|
||||
}
|
||||
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
||||
readyCompleter.complete();
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
if (liveSub.closedRetryTimer != null) return;
|
||||
|
||||
final attempt = liveSub.closedRetryAttempt;
|
||||
final backoffMs = attempt >= 5
|
||||
? _maxReconnectDelayMs
|
||||
: _baseReconnectDelayMs * (1 << attempt);
|
||||
var delayMs = backoffMs;
|
||||
if (closedClass == RelayClosedClass.rateLimited) {
|
||||
final retrySeconds = parseRateLimitRetrySeconds(message);
|
||||
_rateLimitGate.activate(retrySeconds);
|
||||
final fallbackMs =
|
||||
(retrySeconds != null && retrySeconds > 0
|
||||
? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds)
|
||||
: RelayRateLimitGate.defaultRetrySeconds) *
|
||||
1000;
|
||||
delayMs = max(
|
||||
backoffMs,
|
||||
_rateLimitGate.remainingMs() == 0
|
||||
? fallbackMs
|
||||
: _rateLimitGate.remainingMs(),
|
||||
);
|
||||
}
|
||||
|
||||
liveSub.closedRetryAttempt = attempt + 1;
|
||||
final retryGeneration = _connectionGeneration;
|
||||
liveSub.closedRetryTimer = _retryTimerFactory(
|
||||
Duration(milliseconds: delayMs),
|
||||
() async {
|
||||
liveSub.closedRetryTimer = null;
|
||||
if (!_isActiveConnection(retryGeneration) ||
|
||||
_liveSubscriptions[subId] != liveSub) {
|
||||
return;
|
||||
}
|
||||
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
||||
if (!_isActiveConnection(retryGeneration) ||
|
||||
_liveSubscriptions[subId] != liveSub ||
|
||||
!_socketConnected) {
|
||||
return;
|
||||
}
|
||||
_pendingClosedRetries[subId] = _ClosedRetry(
|
||||
subscription: liveSub,
|
||||
generation: retryGeneration,
|
||||
);
|
||||
_scheduleClosedRetryReplay(retryGeneration);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleClosedRetryReplay(int generation) {
|
||||
if (_closedRetryReplayScheduled) return;
|
||||
_closedRetryReplayScheduled = true;
|
||||
scheduleMicrotask(() async {
|
||||
try {
|
||||
await _replayPendingClosedRetries(generation);
|
||||
} finally {
|
||||
_closedRetryReplayScheduled = false;
|
||||
_pendingClosedRetries.removeWhere(
|
||||
(_, retry) => retry.generation != _connectionGeneration,
|
||||
);
|
||||
if (_pendingClosedRetries.values.any(
|
||||
(retry) => retry.generation == _connectionGeneration,
|
||||
)) {
|
||||
_scheduleClosedRetryReplay(_connectionGeneration);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleOk(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final eventId = data[1] as String;
|
||||
final accepted = data[2] as bool;
|
||||
final message = data.length > 3 && data[3] is String
|
||||
? data[3] as String
|
||||
: '';
|
||||
|
||||
final pending = _pendingEvents.remove(eventId);
|
||||
if (pending == null) return;
|
||||
pending.timeout.cancel();
|
||||
|
||||
if (accepted) {
|
||||
// We don't have the full event here; create a minimal placeholder.
|
||||
// Command kinds (e.g. 41010, 30620, 46020) return "response:{...}" in
|
||||
// the OK message — preserve it in `content` so callers can parse it.
|
||||
if (!pending.completer.isCompleted) {
|
||||
pending.completer.complete(
|
||||
NostrEvent(
|
||||
id: eventId,
|
||||
pubkey: '',
|
||||
createdAt: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: message,
|
||||
sig: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!pending.completer.isCompleted) {
|
||||
pending.completer.completeError(
|
||||
Exception(message.isNotEmpty ? message : 'Event rejected'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleFlush() {
|
||||
_flushTimer ??= Timer(
|
||||
const Duration(milliseconds: _eventBatchMs),
|
||||
_flushEventBuffer,
|
||||
);
|
||||
}
|
||||
|
||||
void _flushBufferedEventsNow() {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
_flushEventBuffer();
|
||||
}
|
||||
|
||||
void _flushEventBuffer() {
|
||||
_flushTimer = null;
|
||||
if (_eventBuffer.isEmpty) return;
|
||||
|
||||
final batch = List<_BufferedEvent>.from(_eventBuffer);
|
||||
_eventBuffer.clear();
|
||||
|
||||
for (final buffered in batch) {
|
||||
final sub = _liveSubscriptions[buffered.subId];
|
||||
if (sub == null) continue;
|
||||
|
||||
// Deduplicate per subscription. The same relay event can legitimately
|
||||
// match multiple live subscriptions, e.g. the channel list unread listener
|
||||
// and the open channel message listener.
|
||||
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
|
||||
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
|
||||
|
||||
// Cap the dedup set to prevent unbounded memory growth.
|
||||
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
|
||||
_recentDeliveryKeys.clear();
|
||||
}
|
||||
_recentDeliveryKeys.add(deliveryKey);
|
||||
|
||||
sub.onEvent(buffered.event);
|
||||
}
|
||||
}
|
||||
|
||||
String _nextSubId(String prefix) {
|
||||
_subIdCounter++;
|
||||
return '$prefix-$_subIdCounter';
|
||||
}
|
||||
|
||||
void _sendReq(String subId, NostrFilter filter) {
|
||||
_socket?.send(['REQ', subId, filter.toJson()]);
|
||||
}
|
||||
|
||||
void _sendClose(String subId) {
|
||||
_socket?.send(['CLOSE', subId]);
|
||||
}
|
||||
|
||||
void _unsubscribe(String subId) {
|
||||
final subscription = _liveSubscriptions[subId];
|
||||
if (subscription != null) {
|
||||
_removeLiveSubscription(subId, subscription);
|
||||
}
|
||||
_sendClose(subId);
|
||||
}
|
||||
|
||||
void _removeLiveSubscription(String subId, _LiveSubscription subscription) {
|
||||
if (_liveSubscriptions[subId] != subscription) return;
|
||||
_liveSubscriptions.remove(subId);
|
||||
_pendingClosedRetries.remove(subId);
|
||||
subscription.closedRetryTimer?.cancel();
|
||||
subscription.closedRetryTimer = null;
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
}
|
||||
|
||||
void _resetClosedRetry(_LiveSubscription subscription) {
|
||||
subscription.closedRetryAttempt = 0;
|
||||
subscription.closedRetryTimer?.cancel();
|
||||
subscription.closedRetryTimer = null;
|
||||
}
|
||||
|
||||
void _cancelAllClosedRetries() {
|
||||
_pendingClosedRetries.clear();
|
||||
for (final subscription in _liveSubscriptions.values) {
|
||||
subscription.closedRetryTimer?.cancel();
|
||||
subscription.closedRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAllClosedRetries() {
|
||||
_pendingClosedRetries.clear();
|
||||
for (final subscription in _liveSubscriptions.values) {
|
||||
_resetClosedRetry(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelAllHistory(Object? error) {
|
||||
for (final entry in _historySubscriptions.values) {
|
||||
entry.timeout.cancel();
|
||||
if (!entry.completer.isCompleted) {
|
||||
entry.completer.completeError(error ?? Exception('Connection lost'));
|
||||
}
|
||||
}
|
||||
_historySubscriptions.clear();
|
||||
}
|
||||
|
||||
void _rejectAllPending(Object? error) {
|
||||
for (final entry in _pendingEvents.values) {
|
||||
entry.timeout.cancel();
|
||||
if (!entry.completer.isCompleted) {
|
||||
entry.completer.completeError(error ?? Exception('Connection lost'));
|
||||
}
|
||||
}
|
||||
_pendingEvents.clear();
|
||||
}
|
||||
|
||||
void _dispose() {
|
||||
_disposed = true;
|
||||
_connectionGeneration++;
|
||||
_reconnectTimer?.cancel();
|
||||
_flushTimer?.cancel();
|
||||
_backgroundGraceTimer?.cancel();
|
||||
_backgroundedAt = null;
|
||||
_cancelAllClosedRetries();
|
||||
_rateLimitGate.reset();
|
||||
_visibleChannelsByOwner.clear();
|
||||
_socketConnected = false;
|
||||
_cancelAllHistory(null);
|
||||
_rejectAllPending(null);
|
||||
final subscriptions = _liveSubscriptions.values.toList();
|
||||
_liveSubscriptions.clear();
|
||||
for (final subscription in subscriptions) {
|
||||
subscription.closedRetryTimer?.cancel();
|
||||
subscription.closedRetryTimer = null;
|
||||
}
|
||||
_recentDeliveryKeys.clear();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_httpClient?.close();
|
||||
}
|
||||
}
|
||||
|
||||
final relaySessionProvider =
|
||||
NotifierProvider<RelaySessionNotifier, SessionState>(
|
||||
RelaySessionNotifier.new,
|
||||
);
|
||||
|
||||
String buildNip98AuthHeader({
|
||||
required String method,
|
||||
required String url,
|
||||
required List<int> bodyBytes,
|
||||
required String? nsec,
|
||||
}) {
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw Exception('Cannot query relay: no signing key available');
|
||||
}
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
final payloadHash = SHA256Digest()
|
||||
.process(Uint8List.fromList(bodyBytes))
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final event = nostr.Event.from(
|
||||
kind: 27235,
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method.toUpperCase()],
|
||||
['payload', payloadHash],
|
||||
['nonce', const Uuid().v4()],
|
||||
],
|
||||
secretKey: privkeyHex,
|
||||
verify: false,
|
||||
);
|
||||
return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}';
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'nostr_models.dart';
|
||||
|
||||
/// Low-level websocket connection with NIP-42 authentication.
|
||||
///
|
||||
/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42
|
||||
/// challenge/response, send/receive JSON frames, and disconnect.
|
||||
///
|
||||
/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job.
|
||||
enum SocketState { disconnected, connecting, authenticating, connected }
|
||||
|
||||
class RelayAuthRejectedException implements Exception {
|
||||
final String message;
|
||||
|
||||
const RelayAuthRejectedException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'Relay authentication rejected: $message';
|
||||
}
|
||||
|
||||
Exception classifyRelayAuthFailure(String message) {
|
||||
if (message.startsWith('error:')) return Exception(message);
|
||||
return RelayAuthRejectedException(message);
|
||||
}
|
||||
|
||||
class RelaySocket {
|
||||
/// Interval for sending a ping and awaiting its pong before disconnecting.
|
||||
static const pingInterval = Duration(seconds: 30);
|
||||
|
||||
@visibleForTesting
|
||||
static Duration debugPingInterval = pingInterval;
|
||||
|
||||
final String _wsUrl;
|
||||
final String? _nsec;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function() _onConnected;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
SocketState _state = SocketState.disconnected;
|
||||
Completer<void>? _authCompleter;
|
||||
Timer? _authTimeout;
|
||||
String? _pendingAuthEventId;
|
||||
|
||||
SocketState get state => _state;
|
||||
|
||||
RelaySocket({
|
||||
required String wsUrl,
|
||||
required String? nsec,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function() onConnected,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_nsec = nsec,
|
||||
_onMessage = onMessage,
|
||||
_onConnected = onConnected,
|
||||
_onDisconnected = onDisconnected;
|
||||
|
||||
/// Connect to the relay and complete NIP-42 authentication.
|
||||
Future<void> connect() async {
|
||||
if (_state != SocketState.disconnected) return;
|
||||
_state = SocketState.connecting;
|
||||
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
Uri.parse(_wsUrl),
|
||||
pingInterval: debugPingInterval,
|
||||
);
|
||||
await _channel!.ready;
|
||||
} catch (e) {
|
||||
_state = SocketState.disconnected;
|
||||
_onDisconnected(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// The channel may have been disposed while we were awaiting ready
|
||||
// (e.g. provider rebuild triggered dispose() concurrently).
|
||||
if (_channel == null) {
|
||||
_state = SocketState.disconnected;
|
||||
return;
|
||||
}
|
||||
|
||||
_state = SocketState.authenticating;
|
||||
_authCompleter = Completer<void>();
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleRawMessage,
|
||||
onError: (Object error) {
|
||||
_failAuth(error);
|
||||
_resetConnection();
|
||||
_onDisconnected(error);
|
||||
},
|
||||
onDone: () {
|
||||
_failAuth(null);
|
||||
_resetConnection();
|
||||
_onDisconnected(null);
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for auth to complete (or timeout).
|
||||
_authTimeout = Timer(const Duration(seconds: 8), () {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(
|
||||
TimeoutException('NIP-42 auth timed out after 8s'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await _authCompleter!.future;
|
||||
_authTimeout?.cancel();
|
||||
_state = SocketState.connected;
|
||||
_onConnected();
|
||||
} catch (e) {
|
||||
_authTimeout?.cancel();
|
||||
await disconnect();
|
||||
_onDisconnected(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a raw JSON array over the websocket.
|
||||
void send(List<dynamic> payload) {
|
||||
_channel?.sink.add(jsonEncode(payload));
|
||||
}
|
||||
|
||||
/// Gracefully close the connection.
|
||||
Future<void> disconnect() async {
|
||||
_resetConnection();
|
||||
final channel = _channel;
|
||||
_channel = null;
|
||||
if (channel != null) {
|
||||
await channel.sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_resetConnection();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
void _resetConnection() {
|
||||
_state = SocketState.disconnected;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_authTimeout?.cancel();
|
||||
_authTimeout = null;
|
||||
_pendingAuthEventId = null;
|
||||
}
|
||||
|
||||
void _failAuth(Object? error) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(error ?? Exception('Connection closed'));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRawMessage(dynamic raw) {
|
||||
final String text;
|
||||
if (raw is String) {
|
||||
text = raw;
|
||||
} else {
|
||||
return; // Binary frames are not part of the Nostr protocol.
|
||||
}
|
||||
|
||||
final List<dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(text) as List<dynamic>;
|
||||
} catch (_) {
|
||||
return; // Malformed JSON.
|
||||
}
|
||||
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
switch (type) {
|
||||
case 'AUTH':
|
||||
_handleAuthChallenge(data);
|
||||
case 'OK':
|
||||
_handleOk(data);
|
||||
default:
|
||||
// Pass EVENT, EOSE, NOTICE, etc. upstream.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the relay's AUTH challenge: sign a kind:22242 event and respond.
|
||||
void _handleAuthChallenge(List<dynamic> data) {
|
||||
if (data.length < 2) return;
|
||||
final challenge = data[1] as String;
|
||||
|
||||
if (_nsec == null) {
|
||||
_failAuth(Exception('No nsec available for NIP-42 auth'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode bech32 nsec to hex private key.
|
||||
final privkeyHex = nostr.Nip19.decode(payload: _nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
_failAuth(Exception('Invalid nsec'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the auth tags.
|
||||
final tags = <List<String>>[
|
||||
['relay', _wsUrl],
|
||||
['challenge', challenge],
|
||||
];
|
||||
|
||||
// Create and sign the kind:22242 AUTH event.
|
||||
final event = nostr.Event.from(
|
||||
kind: EventKind.auth,
|
||||
content: '',
|
||||
tags: tags,
|
||||
secretKey: privkeyHex,
|
||||
);
|
||||
|
||||
_pendingAuthEventId = event.id;
|
||||
send(['AUTH', event.toMap()]);
|
||||
} catch (e) {
|
||||
_failAuth(e);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugHandleOkForTest(List<dynamic> data) => _onMessage(data);
|
||||
|
||||
/// Handle OK frames. During auth, complete the auth flow.
|
||||
void _handleOk(List<dynamic> data) {
|
||||
if (data.length < 3) return;
|
||||
final eventId = data[1] as String;
|
||||
final accepted = data[2] as bool;
|
||||
|
||||
// Check if this OK is for our pending AUTH event.
|
||||
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
||||
_pendingAuthEventId = null;
|
||||
if (accepted) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
}
|
||||
} else {
|
||||
final message = data.length > 3
|
||||
? data[3] as String
|
||||
: 'Auth rejected by relay';
|
||||
_failAuth(classifyRelayAuthFailure(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass non-auth OK frames upstream for pending event tracking.
|
||||
_onMessage(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Validates an untrusted relay origin before the mobile app connects to it.
|
||||
///
|
||||
/// Production relay URLs must use TLS and may not contain local or otherwise
|
||||
/// non-public IP literals. Debug builds retain plaintext localhost support for
|
||||
/// local development, but other non-public destinations remain blocked.
|
||||
void validateInviteRelayUri(
|
||||
Uri uri, {
|
||||
bool allowInsecureLocalhost = kDebugMode,
|
||||
}) {
|
||||
if (uri.host.isEmpty ||
|
||||
uri.userInfo.isNotEmpty ||
|
||||
uri.hasFragment ||
|
||||
(uri.path.isNotEmpty && uri.path != '/') ||
|
||||
uri.hasQuery) {
|
||||
throw const FormatException('Relay URL must be an origin');
|
||||
}
|
||||
|
||||
if (uri.scheme != 'ws' && uri.scheme != 'wss') {
|
||||
throw FormatException('Invalid relay URL scheme: ${uri.scheme}');
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
final isLocalhost = host == 'localhost' || host.endsWith('.localhost');
|
||||
if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) {
|
||||
throw const FormatException('Relay URL must use WSS');
|
||||
}
|
||||
if (isLocalhost) {
|
||||
if (!allowInsecureLocalhost) {
|
||||
throw const FormatException('Relay URL cannot target localhost');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final address = InternetAddress.tryParse(host);
|
||||
if (_looksLikeNumericAddress(host)) {
|
||||
// Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms
|
||||
// such as a single integer, hexadecimal, shortened components, or leading
|
||||
// zeroes have varied interpretation across URI/network stacks.
|
||||
final ipv4Parts = host.split('.');
|
||||
final hasLeadingZero = ipv4Parts.any(
|
||||
(part) => part.length > 1 && part.startsWith('0'),
|
||||
);
|
||||
if (address == null ||
|
||||
address.type != InternetAddressType.IPv4 ||
|
||||
address.address != host ||
|
||||
ipv4Parts.length != 4 ||
|
||||
hasLeadingZero) {
|
||||
throw const FormatException('Relay URL contains an invalid IP address');
|
||||
}
|
||||
}
|
||||
if (address != null) {
|
||||
if (!_isPublicAddress(address)) {
|
||||
throw const FormatException(
|
||||
'Relay URL cannot target non-public network addresses',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS hostnames are allowed here. Preventing a hostname from resolving or
|
||||
// rebinding to a non-public address requires connect-time resolution and
|
||||
// address pinning in the networking layer.
|
||||
}
|
||||
|
||||
bool _looksLikeNumericAddress(String host) {
|
||||
if (RegExp(r'^\d+$').hasMatch(host) ||
|
||||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) {
|
||||
return true;
|
||||
}
|
||||
final parts = host.split('.');
|
||||
return parts.length > 1 &&
|
||||
parts.every(
|
||||
(part) =>
|
||||
RegExp(r'^\d+$').hasMatch(part) ||
|
||||
RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPublicAddress(InternetAddress address) {
|
||||
final bytes = address.rawAddress;
|
||||
if (address.type == InternetAddressType.IPv4) {
|
||||
return !_isNonPublicIpv4(bytes);
|
||||
}
|
||||
if (address.type != InternetAddressType.IPv6 || bytes.length != 16) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded
|
||||
// IPv4 address's classification.
|
||||
final firstTenZero = bytes.take(10).every((byte) => byte == 0);
|
||||
if (firstTenZero &&
|
||||
((bytes[10] == 0 && bytes[11] == 0) ||
|
||||
(bytes[10] == 0xff && bytes[11] == 0xff))) {
|
||||
return !_isNonPublicIpv4(bytes.sublist(12));
|
||||
}
|
||||
|
||||
// Accept only globally reachable IPv6 literals. Global unicast space is
|
||||
// 2000::/3, with special-purpose exclusions and narrow globally reachable
|
||||
// exceptions tracked by IANA (reconciled 2026-07-26):
|
||||
// https://www.iana.org/assignments/iana-ipv6-special-registry/
|
||||
if (!_hasPrefix(bytes, [0x20], 3)) return false;
|
||||
|
||||
// 2001::/23 is reserved for IETF protocol assignments. Only these entries
|
||||
// are currently classified by IANA as globally reachable.
|
||||
if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) &&
|
||||
!_isGloballyReachableIetfAssignment(bytes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Documentation and transition prefixes are not public relay destinations.
|
||||
if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false;
|
||||
if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false;
|
||||
if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _isGloballyReachableIetfAssignment(List<int> bytes) {
|
||||
final isGlobalAnycast =
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) &&
|
||||
bytes.sublist(8, 15).every((byte) => byte == 0) &&
|
||||
bytes[15] >= 1 &&
|
||||
bytes[15] <= 3;
|
||||
return isGlobalAnycast ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) ||
|
||||
_hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28);
|
||||
}
|
||||
|
||||
bool _hasPrefix(List<int> address, List<int> prefix, int prefixLength) {
|
||||
final wholeBytes = prefixLength ~/ 8;
|
||||
for (var i = 0; i < wholeBytes; i++) {
|
||||
if (address[i] != prefix[i]) return false;
|
||||
}
|
||||
final remainingBits = prefixLength % 8;
|
||||
if (remainingBits == 0) return true;
|
||||
final mask = (0xff << (8 - remainingBits)) & 0xff;
|
||||
return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask);
|
||||
}
|
||||
|
||||
bool _isNonPublicIpv4(List<int> bytes) {
|
||||
if (bytes.length != 4) return true;
|
||||
final a = bytes[0];
|
||||
final b = bytes[1];
|
||||
final c = bytes[2];
|
||||
|
||||
return a == 0 ||
|
||||
a == 10 ||
|
||||
a == 127 ||
|
||||
(a == 100 && b >= 64 && b <= 127) ||
|
||||
(a == 169 && b == 254) ||
|
||||
(a == 172 && b >= 16 && b <= 31) ||
|
||||
(a == 192 && b == 0 && c == 0) ||
|
||||
(a == 192 && b == 0 && c == 2) ||
|
||||
(a == 192 && b == 168) ||
|
||||
(a == 198 && (b == 18 || b == 19)) ||
|
||||
(a == 198 && b == 51 && c == 100) ||
|
||||
(a == 203 && b == 0 && c == 113) ||
|
||||
a >= 224;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import 'nostr_models.dart';
|
||||
import 'relay_session.dart';
|
||||
|
||||
/// Signs and submits Nostr events through the relay WebSocket connection.
|
||||
class SignedEventRelay {
|
||||
final RelaySessionNotifier _session;
|
||||
final String? _nsec;
|
||||
|
||||
SignedEventRelay({
|
||||
required RelaySessionNotifier session,
|
||||
required String? nsec,
|
||||
}) : _session = session,
|
||||
_nsec = nsec;
|
||||
|
||||
/// The hex pubkey derived from the signing key, or null if no key.
|
||||
String? get pubkey {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) return null;
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) return null;
|
||||
return nostr.Keys(privkeyHex).public;
|
||||
}
|
||||
|
||||
/// Sign and submit an event. Returns the relay's OK response as a [NostrEvent]
|
||||
/// whose `content` field contains the OK message (e.g. `"response:{...}"`
|
||||
/// for command kinds).
|
||||
Future<NostrEvent> submit({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
void Function(NostrEvent event)? onSigned,
|
||||
}) async {
|
||||
final nsec = _nsec;
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
throw Exception('Cannot submit event: no signing key available');
|
||||
}
|
||||
|
||||
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
|
||||
if (privkeyHex.isEmpty) {
|
||||
throw Exception('Invalid nsec');
|
||||
}
|
||||
|
||||
final event = nostr.Event.from(
|
||||
kind: kind,
|
||||
content: content,
|
||||
tags: tags,
|
||||
secretKey: privkeyHex,
|
||||
createdAt: createdAt,
|
||||
verify: false,
|
||||
);
|
||||
|
||||
final nostrEvent = NostrEvent.fromJson(event.toMap());
|
||||
onSigned?.call(nostrEvent);
|
||||
return _session.publish(nostrEvent);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user