mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add section workspace migration lane
Signed-off-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../../../shared/crypto/nip44.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../../../shared/read_state/read_state_time.dart';
|
||||
import 'section_workspace_sync.dart';
|
||||
import 'channel_sections_storage.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
@@ -38,6 +39,7 @@ class ChannelSectionsManager {
|
||||
final RelaySessionNotifier? _relaySession;
|
||||
final SignedEventRelay? _signedEventRelay;
|
||||
final bool _remoteEnabled;
|
||||
final SectionWorkspaceSyncManager? _workspaceSync;
|
||||
final VoidCallback _onChanged;
|
||||
|
||||
ChannelSectionStore _store;
|
||||
@@ -51,6 +53,7 @@ class ChannelSectionsManager {
|
||||
/// Base delay for the startup-sync retry backoff. Overridable in tests.
|
||||
final Duration _startupRetryBaseDelay;
|
||||
Timer? _startupRetryTimer;
|
||||
Timer? _workspaceRetryTimer;
|
||||
int _startupRetryAttempt = 0;
|
||||
bool _startupFetchSucceeded = false;
|
||||
Future<void>? _syncInFlight;
|
||||
@@ -64,22 +67,165 @@ class ChannelSectionsManager {
|
||||
required RelaySessionNotifier? relaySession,
|
||||
required SignedEventRelay? signedEventRelay,
|
||||
required bool remoteEnabled,
|
||||
SectionWorkspaceSyncManager? workspaceSync,
|
||||
required VoidCallback onChanged,
|
||||
@visibleForTesting
|
||||
Duration startupRetryBaseDelay = const Duration(seconds: 2),
|
||||
}) : _storage = ChannelSectionsStorage(prefs),
|
||||
}) : _storage = ChannelSectionsStorage(
|
||||
prefs,
|
||||
relayAuthority: workspaceSync?.relayStorageScope,
|
||||
),
|
||||
_crypto = crypto,
|
||||
_relaySession = relaySession,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_remoteEnabled = remoteEnabled,
|
||||
_workspaceSync = workspaceSync,
|
||||
_onChanged = onChanged,
|
||||
_startupRetryBaseDelay = startupRetryBaseDelay,
|
||||
_store = ChannelSectionsStorage(prefs).read(pubkey);
|
||||
_store = ChannelSectionsStorage(
|
||||
prefs,
|
||||
relayAuthority: workspaceSync?.relayStorageScope,
|
||||
).read(pubkey);
|
||||
|
||||
ChannelSectionStore get store => _store;
|
||||
|
||||
bool applyWorkspaceProjection(SectionWorkspaceProjection projection) {
|
||||
if (_disposed) return false;
|
||||
try {
|
||||
final key = SectionWorkspaceKeyEnvelopeCrypto(
|
||||
nsec: _workspaceNsec,
|
||||
ownerPubkey: projection.ownerPubkey,
|
||||
).unwrap(projection.readerKeyEnvelope);
|
||||
final metadata = SectionWorkspaceMetadataCrypto(key);
|
||||
final sections = <ChannelSection>[];
|
||||
final projectedSections = projection.sections.toList()
|
||||
..sort((a, b) => a.rank.compareTo(b.rank));
|
||||
for (final projected in projectedSections) {
|
||||
final name = metadata.decrypt(
|
||||
envelope: projected.encryptedLabel,
|
||||
community: _workspaceAuthority,
|
||||
ownerPubkey: projection.ownerPubkey,
|
||||
sectionId: projected.id,
|
||||
keyEpoch: projection.keyEpoch,
|
||||
purpose: 'label',
|
||||
);
|
||||
final icon = projected.encryptedIcon == null
|
||||
? null
|
||||
: metadata.decrypt(
|
||||
envelope: projected.encryptedIcon!,
|
||||
community: _workspaceAuthority,
|
||||
ownerPubkey: projection.ownerPubkey,
|
||||
sectionId: projected.id,
|
||||
keyEpoch: projection.keyEpoch,
|
||||
purpose: 'icon',
|
||||
);
|
||||
sections.add(
|
||||
ChannelSection(
|
||||
id: projected.id,
|
||||
name: name,
|
||||
icon: icon,
|
||||
order: projected.rank,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Workspace state is read-only in Stage 1. Do not overwrite the local
|
||||
// legacy store when rendering a verified projection; it remains the
|
||||
// rollback source used to construct an import until cutover completes.
|
||||
_store = ChannelSectionStore(
|
||||
sections: sections,
|
||||
assignments: {
|
||||
for (final assignment in projection.assignments)
|
||||
assignment.channelId: assignment.sectionId,
|
||||
},
|
||||
);
|
||||
_onChanged();
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[ChannelSectionsManager] workspace projection rejected: $error',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String get _workspaceNsec => _workspaceSync?.nsec ?? '';
|
||||
String get _workspaceAuthority => _workspaceSync?.relayAuthority ?? '';
|
||||
|
||||
bool get _legacyMutationAllowed =>
|
||||
_workspaceSync == null || _workspaceSync.legacyReadAllowed;
|
||||
|
||||
void stopLegacySync() {
|
||||
if (_disposed) return;
|
||||
_startupRetryTimer?.cancel();
|
||||
_startupRetryTimer = null;
|
||||
_workspaceRetryTimer?.cancel();
|
||||
_workspaceRetryTimer = null;
|
||||
_publishDebounce?.cancel();
|
||||
_publishDebounce = null;
|
||||
_unsubscribe?.call();
|
||||
_unsubscribe = null;
|
||||
_subscriptionGeneration++;
|
||||
_startupFetchSucceeded = true;
|
||||
_syncAgain = false;
|
||||
}
|
||||
|
||||
Future<void> retryWorkspaceSubscription() async {
|
||||
if (_disposed || _workspaceSync == null) return;
|
||||
if (!_workspaceSync.probeCompleted) {
|
||||
await _workspaceSync.probe();
|
||||
if (_workspaceSync.workspaceKnown) {
|
||||
stopLegacySync();
|
||||
_onChanged();
|
||||
return;
|
||||
}
|
||||
if (!_workspaceSync.probeCompleted) {
|
||||
scheduleWorkspaceRetry();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_workspaceSync.subscriptionActive) {
|
||||
await _workspaceSync.retryPendingImport();
|
||||
return;
|
||||
}
|
||||
final subscribed = await _workspaceSync.startSubscription();
|
||||
if (subscribed) {
|
||||
await _workspaceSync.retryPendingImport();
|
||||
} else {
|
||||
scheduleWorkspaceRetry();
|
||||
}
|
||||
}
|
||||
|
||||
void scheduleWorkspaceRetry() {
|
||||
if (_disposed || _workspaceRetryTimer != null) return;
|
||||
_workspaceRetryTimer = Timer(_startupRetryBaseDelay, () {
|
||||
_workspaceRetryTimer = null;
|
||||
unawaited(retryWorkspaceSubscription());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_disposed) return;
|
||||
if (_workspaceSync != null) {
|
||||
// Render the last verified projection before touching the network.
|
||||
if (_workspaceSync.cache != null &&
|
||||
applyWorkspaceProjection(_workspaceSync.cache!.projection)) {
|
||||
_workspaceSync.markCacheVerified();
|
||||
}
|
||||
if (_remoteEnabled && _relaySession != null) {
|
||||
await _workspaceSync.probe();
|
||||
final subscribed = await _workspaceSync.startSubscription();
|
||||
if (!subscribed) scheduleWorkspaceRetry();
|
||||
await _workspaceSync.retryPendingImport();
|
||||
}
|
||||
// A verified cache or projection is authoritative. Do not even open a
|
||||
// legacy subscription after cutover; the old blob is a read-only
|
||||
// rollback artifact, not a second source of truth.
|
||||
if (_workspaceSync.workspaceKnown) {
|
||||
stopLegacySync();
|
||||
_onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_remoteEnabled || _relaySession == null) {
|
||||
_onChanged();
|
||||
@@ -120,11 +266,30 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
Future<void> _runSyncWithRelay() async {
|
||||
if (_workspaceSync != null && !_workspaceSync.probeCompleted) {
|
||||
await _workspaceSync.probe();
|
||||
await _workspaceSync.startSubscription();
|
||||
if (_disposed) return;
|
||||
if (_workspaceSync.workspaceKnown) {
|
||||
stopLegacySync();
|
||||
_onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!_startupFetchSucceeded) {
|
||||
final fetched = await _fetchAndMerge();
|
||||
if (_disposed) return;
|
||||
_startupFetchSucceeded = fetched;
|
||||
}
|
||||
// A live workspace projection can arrive while the legacy history fetch is
|
||||
// in flight. Re-check the cutover boundary before opening any legacy
|
||||
// subscription; otherwise a projection callback could stop a subscription
|
||||
// that this continuation immediately recreates.
|
||||
if (_workspaceSync?.workspaceKnown == true) {
|
||||
stopLegacySync();
|
||||
_onChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
final subscribed = _unsubscribe != null || await _startLiveSubscription();
|
||||
if (_disposed) return;
|
||||
@@ -170,11 +335,14 @@ class ChannelSectionsManager {
|
||||
void dispose({bool flushPending = true}) {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_workspaceSync?.dispose();
|
||||
_subscriptionGeneration++;
|
||||
_syncAgain = false;
|
||||
|
||||
_startupRetryTimer?.cancel();
|
||||
_startupRetryTimer = null;
|
||||
_workspaceRetryTimer?.cancel();
|
||||
_workspaceRetryTimer = null;
|
||||
|
||||
final hadPending = _publishDebounce != null;
|
||||
_publishDebounce?.cancel();
|
||||
@@ -189,7 +357,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void createSection(String name) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final maxOrder = _store.sections.fold<int>(
|
||||
-1,
|
||||
(max, s) => s.order > max ? s.order : max,
|
||||
@@ -208,7 +376,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void renameSection(String sectionId, String newName) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
_store = ChannelSectionStore(
|
||||
sections: [
|
||||
for (final s in _store.sections)
|
||||
@@ -229,7 +397,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void deleteSection(String sectionId) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final updatedAssignments = Map<String, String>.from(_store.assignments)
|
||||
..removeWhere((_, sid) => sid == sectionId);
|
||||
_store = ChannelSectionStore(
|
||||
@@ -244,7 +412,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void moveSectionUp(String sectionId) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final sorted = _sortedSections();
|
||||
final idx = sorted.indexWhere((s) => s.id == sectionId);
|
||||
if (idx <= 0) return;
|
||||
@@ -253,7 +421,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void moveSectionDown(String sectionId) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final sorted = _sortedSections();
|
||||
final idx = sorted.indexWhere((s) => s.id == sectionId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) return;
|
||||
@@ -262,7 +430,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void assignChannel(String channelId, String sectionId) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final updated = Map<String, String>.from(_store.assignments)
|
||||
..[channelId] = sectionId;
|
||||
_store = ChannelSectionStore(
|
||||
@@ -274,7 +442,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void unassignChannel(String channelId) {
|
||||
if (_disposed) return;
|
||||
if (_disposed || !_legacyMutationAllowed) return;
|
||||
final updated = Map<String, String>.from(_store.assignments)
|
||||
..remove(channelId);
|
||||
_store = ChannelSectionStore(
|
||||
@@ -286,7 +454,7 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void markDirty() {
|
||||
if (!_remoteEnabled || _disposed) return;
|
||||
if (!_remoteEnabled || _disposed || !_legacyMutationAllowed) return;
|
||||
_publishDebounce?.cancel();
|
||||
_publishDebounce = Timer(const Duration(seconds: 5), () {
|
||||
_publishDebounce = null;
|
||||
@@ -311,7 +479,12 @@ class ChannelSectionsManager {
|
||||
);
|
||||
if (_disposed && !allowDisposed) return false;
|
||||
_mergeEvents(events);
|
||||
_persist();
|
||||
// A workspace projection or migration marker can become authoritative
|
||||
// while the legacy fetch is in flight. Never write the rendered
|
||||
// workspace state back into the legacy blob after that boundary.
|
||||
if (_workspaceSync == null || _workspaceSync.legacyReadAllowed) {
|
||||
_persist();
|
||||
}
|
||||
if (!_disposed) _onChanged();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -375,17 +548,54 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void _mergeEvent(NostrEvent event) {
|
||||
// Only process channel-sections d-tag events.
|
||||
final dTag = event.getTagValue('d');
|
||||
if (dTag != 'channel-sections') return;
|
||||
if (event.pubkey != pubkey || event.kind != EventKind.readState) return;
|
||||
final pendingSource =
|
||||
_workspaceSync?.isPendingImportSource(event.id) ?? false;
|
||||
if (_workspaceSync != null &&
|
||||
!_workspaceSync.legacyReadAllowed &&
|
||||
!pendingSource) {
|
||||
return;
|
||||
}
|
||||
if (_workspaceSync?.workspaceKnown == true) return;
|
||||
if (_workspaceSync?.migrationStarted == true && !pendingSource) {
|
||||
return;
|
||||
}
|
||||
// A persisted import command is already the exact retry source. Do not
|
||||
// re-render or persist the legacy event while the one-way cutover is
|
||||
// pending; this keeps the workspace/import lane authoritative.
|
||||
if (pendingSource) {
|
||||
unawaited(_workspaceSync!.retryPendingImport());
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process exactly one channel-sections d-tag event.
|
||||
final dTags = event.tags
|
||||
.where(
|
||||
(tag) =>
|
||||
tag.length == 2 && tag[0] == 'd' && tag[1] == 'channel-sections',
|
||||
)
|
||||
.length;
|
||||
if (dTags != 1) return;
|
||||
|
||||
try {
|
||||
final plaintext = _crypto.decrypt(event.content);
|
||||
final parsed = jsonDecode(plaintext);
|
||||
final parsed = parseSectionWorkspaceJson(plaintext);
|
||||
if (parsed is! Map<String, dynamic>) return;
|
||||
|
||||
final incoming = ChannelSectionStore.fromJson(parsed);
|
||||
|
||||
if (_workspaceSync != null &&
|
||||
_workspaceSync.probeCompleted &&
|
||||
!_workspaceSync.workspaceKnown) {
|
||||
unawaited(
|
||||
_workspaceSync.tryImportLegacy(
|
||||
event: event,
|
||||
plaintext: parsed,
|
||||
store: incoming,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Last-write-wins: newer createdAt wins; tie-break by event ID.
|
||||
final isNewer =
|
||||
event.createdAt > _lastRemoteCreatedAt ||
|
||||
@@ -396,7 +606,9 @@ class ChannelSectionsManager {
|
||||
_lastRemoteCreatedAt = event.createdAt;
|
||||
_lastRemoteEventId = event.id;
|
||||
_store = incoming;
|
||||
_persist();
|
||||
if (_workspaceSync == null || _workspaceSync.legacyReadAllowed) {
|
||||
_persist();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Decryption failure or parse error — keep existing state.
|
||||
@@ -433,12 +645,22 @@ class ChannelSectionsManager {
|
||||
Future<void> _publish({bool allowDisposed = false}) async {
|
||||
if ((!allowDisposed && _disposed) ||
|
||||
!_remoteEnabled ||
|
||||
_signedEventRelay == null) {
|
||||
_signedEventRelay == null ||
|
||||
(_workspaceSync != null && !_workspaceSync.legacyPublishAllowed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-before-write: merge remote state before publishing
|
||||
await _fetchAndMerge(allowDisposed: allowDisposed);
|
||||
// Probe/subscription callbacks can discover a workspace while the legacy
|
||||
// fetch is in flight. Recheck immediately before signing so migration
|
||||
// never races with a legacy whole-blob write.
|
||||
if ((_workspaceSync != null &&
|
||||
(!_workspaceSync.legacyPublishAllowed ||
|
||||
_workspaceSync.workspaceKnown)) ||
|
||||
(!_remoteEnabled && !allowDisposed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No-op suppression: skip if nothing changed
|
||||
if (_isIdenticalToLastPublished()) return;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../../../shared/theme/theme_provider.dart';
|
||||
import '../../../shared/community/community_provider.dart';
|
||||
import 'section_workspace_sync.dart';
|
||||
import 'channel_sections_manager.dart';
|
||||
import 'channel_sections_storage.dart';
|
||||
|
||||
@@ -56,8 +59,30 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
nsec: nsec,
|
||||
);
|
||||
|
||||
late final ChannelSectionsManager manager;
|
||||
final workspaceSync = SectionWorkspaceSyncManager(
|
||||
ownerPubkey: pubkey,
|
||||
nsec: nsec,
|
||||
prefs: prefs,
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
signedEventRelay: signedRelay,
|
||||
relayAuthority:
|
||||
ref.read(activeCommunityProvider).value?.relayUrl ??
|
||||
relayConfig.baseUrl,
|
||||
onWorkspaceDiscovered: () {
|
||||
if (_manager != manager) return;
|
||||
manager.stopLegacySync();
|
||||
_emitManagerState(manager);
|
||||
},
|
||||
onSubscriptionLost: () {
|
||||
if (_manager != manager) return;
|
||||
manager.scheduleWorkspaceRetry();
|
||||
},
|
||||
onProjection: (projection) {
|
||||
if (_manager != manager) return false;
|
||||
return manager.applyWorkspaceProjection(projection);
|
||||
},
|
||||
);
|
||||
manager = ChannelSectionsManager(
|
||||
pubkey: pubkey,
|
||||
prefs: prefs,
|
||||
@@ -65,6 +90,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
|
||||
relaySession: ref.read(relaySessionProvider.notifier),
|
||||
signedEventRelay: signedRelay,
|
||||
remoteEnabled: sessionState.status == SessionStatus.connected,
|
||||
workspaceSync: workspaceSync,
|
||||
onChanged: () => _emitManagerState(manager),
|
||||
);
|
||||
_manager = manager;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey';
|
||||
String channelSectionsKey(String pubkey, {String? relayAuthority}) {
|
||||
if (relayAuthority == null || relayAuthority.isEmpty) {
|
||||
return 'buzz.channel-sections.v1:$pubkey';
|
||||
}
|
||||
final normalized = relayAuthority
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'/+$'), '')
|
||||
.toLowerCase();
|
||||
return 'buzz.channel-sections.v1:$pubkey:${Uri.encodeComponent(normalized)}';
|
||||
}
|
||||
|
||||
class ChannelSection {
|
||||
final String id;
|
||||
@@ -86,31 +96,55 @@ class ChannelSectionStore {
|
||||
}
|
||||
|
||||
class ChannelSectionsStorage {
|
||||
final String? _relayAuthority;
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ChannelSectionsStorage(this._prefs);
|
||||
ChannelSectionsStorage(this._prefs, {String? relayAuthority})
|
||||
: _relayAuthority = relayAuthority;
|
||||
|
||||
ChannelSectionStore read(String pubkey) {
|
||||
final raw = _prefs.getString(channelSectionsKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const ChannelSectionStore();
|
||||
final key = channelSectionsKey(pubkey, relayAuthority: _relayAuthority);
|
||||
var raw = _prefs.getString(key);
|
||||
if ((raw == null || raw.isEmpty) &&
|
||||
_relayAuthority != null &&
|
||||
_relayAuthority.isNotEmpty) {
|
||||
final legacyKey = channelSectionsKey(pubkey);
|
||||
final legacyRaw = _prefs.getString(legacyKey);
|
||||
final legacyStore = _parse(legacyRaw);
|
||||
if (legacyStore != null) {
|
||||
final scopedPersisted = _prefs.setString(
|
||||
key,
|
||||
jsonEncode(legacyStore.toJson()),
|
||||
);
|
||||
unawaited(
|
||||
scopedPersisted.then((persisted) {
|
||||
if (persisted) unawaited(_prefs.remove(legacyKey));
|
||||
}),
|
||||
);
|
||||
return legacyStore;
|
||||
}
|
||||
raw = legacyRaw;
|
||||
}
|
||||
return _parse(raw) ?? const ChannelSectionStore();
|
||||
}
|
||||
|
||||
ChannelSectionStore? _parse(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final parsed = jsonDecode(raw);
|
||||
if (parsed is! Map<String, dynamic>) {
|
||||
return const ChannelSectionStore();
|
||||
}
|
||||
if (parsed['version'] != 1) {
|
||||
return const ChannelSectionStore();
|
||||
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) {
|
||||
return null;
|
||||
}
|
||||
return ChannelSectionStore.fromJson(parsed);
|
||||
} catch (_) {
|
||||
return const ChannelSectionStore();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void write(String pubkey, ChannelSectionStore store) {
|
||||
_prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson()));
|
||||
_prefs.setString(
|
||||
channelSectionsKey(pubkey, relayAuthority: _relayAuthority),
|
||||
jsonEncode(store.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:buzz/features/channels/channel_sections/section_workspace_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
const owner =
|
||||
'1111111111111111111111111111111111111111111111111111111111111111';
|
||||
const sourceEvent =
|
||||
'2222222222222222222222222222222222222222222222222222222222222222';
|
||||
const sourceHash =
|
||||
'3333333333333333333333333333333333333333333333333333333333333333';
|
||||
const sectionA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const sectionB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const channel = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
|
||||
Map<String, dynamic> projection({int revision = 4}) => {
|
||||
'version': 1,
|
||||
'owner_pubkey': owner,
|
||||
'revision': revision,
|
||||
'layout_revision': 1,
|
||||
'key_epoch': 2,
|
||||
'migration': {'source_event_id': sourceEvent, 'source_hash': sourceHash},
|
||||
'reader_key_envelope': 'nip44-envelope-reader-epoch-2',
|
||||
'sections': [
|
||||
{
|
||||
'id': sectionA,
|
||||
'rank': 0,
|
||||
'encrypted_label': 'ciphertext-alpha',
|
||||
'encrypted_icon': null,
|
||||
},
|
||||
{
|
||||
'id': sectionB,
|
||||
'rank': 1,
|
||||
'encrypted_label': 'ciphertext-beta',
|
||||
'encrypted_icon': 'ciphertext-icon',
|
||||
},
|
||||
],
|
||||
'assignments': [
|
||||
{'channel_id': channel, 'section_id': sectionA, 'revision': 1},
|
||||
],
|
||||
};
|
||||
|
||||
test(
|
||||
'shared fixture projection cases and canonical command vectors remain exact',
|
||||
() {
|
||||
final fixture =
|
||||
jsonDecode(
|
||||
File('../docs/nips/NIP-SW.fixtures.json').readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final cases = fixture['projection_cases'] as List<dynamic>;
|
||||
for (final rawCase in cases) {
|
||||
final item = rawCase as Map<String, dynamic>;
|
||||
final incoming =
|
||||
(item['projection'] as Map<String, dynamic>)['revision'] as int;
|
||||
final previous = item['previous_revision'] as int?;
|
||||
final expected = switch (item['expect']) {
|
||||
'accept' => SectionWorkspaceRevisionAction.accept,
|
||||
'refetch' => SectionWorkspaceRevisionAction.refetch,
|
||||
'ignore' => SectionWorkspaceRevisionAction.ignore,
|
||||
_ => throw StateError('unknown fixture expectation'),
|
||||
};
|
||||
expect(sectionWorkspaceRevisionAction(previous, incoming), expected);
|
||||
}
|
||||
final canonicalization =
|
||||
fixture['canonicalization'] as Map<String, dynamic>;
|
||||
final legacy =
|
||||
canonicalization['legacy_plaintext'] as Map<String, dynamic>;
|
||||
final legacyCanonical = legacy['canonical'] as String;
|
||||
expect(sectionWorkspaceCanonicalJson(legacy['input']), legacyCanonical);
|
||||
expect(sectionWorkspaceSha256Hex(legacyCanonical), legacy['sha256']);
|
||||
final command =
|
||||
canonicalization['import_command'] as Map<String, dynamic>;
|
||||
final commandCanonical = command['canonical'] as String;
|
||||
expect(
|
||||
sectionWorkspaceCanonicalJson(jsonDecode(commandCanonical)),
|
||||
commandCanonical,
|
||||
);
|
||||
expect(sectionWorkspaceSha256Hex(commandCanonical), command['sha256']);
|
||||
},
|
||||
);
|
||||
|
||||
test('strictly parses the complete shared projection case', () {
|
||||
final parsed = parseSectionWorkspaceProjection(projection());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.sections.map((section) => section.id), [sectionA, sectionB]);
|
||||
expect(parsed.assignments.single.channelId, channel);
|
||||
});
|
||||
|
||||
test(
|
||||
'parses the frozen revision-zero import and rejects unknown fields and versions',
|
||||
() {
|
||||
final fixture =
|
||||
jsonDecode(
|
||||
File('../docs/nips/NIP-SW.fixtures.json').readAsStringSync(),
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
final canonicalization =
|
||||
fixture['canonicalization'] as Map<String, dynamic>;
|
||||
final import = parseSectionWorkspaceImportJson(
|
||||
(canonicalization['import_command']
|
||||
as Map<String, dynamic>)['canonical']
|
||||
as String,
|
||||
);
|
||||
expect(import, isNotNull);
|
||||
expect(import!.actionId, 'dddddddd-dddd-4ddd-8ddd-dddddddddddd');
|
||||
expect(import.sections.single.id, sectionA);
|
||||
expect(import.assignments.single.sectionId, sectionA);
|
||||
|
||||
final unknown = import.toJson()..['extra'] = true;
|
||||
expect(parseSectionWorkspaceImport(unknown), isNull);
|
||||
final wrongVersion = import.toJson()..['version'] = 2;
|
||||
expect(parseSectionWorkspaceImport(wrongVersion), isNull);
|
||||
final nestedUnknown = import.toJson();
|
||||
final firstSection = Map<String, dynamic>.from(
|
||||
((nestedUnknown['sections'] as List).single as Map),
|
||||
)..['extra'] = true;
|
||||
nestedUnknown['sections'] = [firstSection];
|
||||
expect(parseSectionWorkspaceImport(nestedUnknown), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('canonical relay authority feeds the exact metadata AAD vector', () {
|
||||
expect(
|
||||
sectionWorkspaceAuthority(' wss://Relay.Example/// '),
|
||||
'relay.example',
|
||||
);
|
||||
expect(
|
||||
sectionWorkspaceAuthority('https://RELAY.EXAMPLE:443/'),
|
||||
'relay.example',
|
||||
);
|
||||
expect(
|
||||
sectionWorkspaceAuthority('wss://Relay.Example:8443/'),
|
||||
'relay.example:8443',
|
||||
);
|
||||
|
||||
final crypto = SectionWorkspaceMetadataCrypto(
|
||||
Uint8List.fromList(List<int>.generate(32, (index) => index)),
|
||||
);
|
||||
final envelope = crypto.encrypt(
|
||||
plaintext: 'Alpha',
|
||||
community: sectionWorkspaceAuthority('wss://Relay.Example/'),
|
||||
ownerPubkey: owner,
|
||||
sectionId: sectionA,
|
||||
keyEpoch: 1,
|
||||
purpose: 'label',
|
||||
nonce: Uint8List.fromList(List<int>.generate(12, (index) => index)),
|
||||
);
|
||||
expect(envelope, 'aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7');
|
||||
});
|
||||
|
||||
test(
|
||||
'rejects unknown fields and unsupported versions at every typed level',
|
||||
() {
|
||||
final unknown = projection()..['extra'] = true;
|
||||
expect(parseSectionWorkspaceProjection(unknown), isNull);
|
||||
|
||||
final nestedUnknown = projection();
|
||||
final nestedMigration = Map<String, dynamic>.from(
|
||||
nestedUnknown['migration'] as Map,
|
||||
);
|
||||
nestedMigration['extra'] = true;
|
||||
nestedUnknown['migration'] = nestedMigration;
|
||||
expect(parseSectionWorkspaceProjection(nestedUnknown), isNull);
|
||||
|
||||
final sectionUnknown = projection();
|
||||
final firstSection = Map<String, dynamic>.from(
|
||||
((sectionUnknown['sections'] as List).first as Map),
|
||||
);
|
||||
firstSection['extra'] = true;
|
||||
sectionUnknown['sections'] = [
|
||||
firstSection,
|
||||
...(sectionUnknown['sections'] as List).skip(1),
|
||||
];
|
||||
expect(parseSectionWorkspaceProjection(sectionUnknown), isNull);
|
||||
|
||||
final assignmentUnknown = projection();
|
||||
final firstAssignment = Map<String, dynamic>.from(
|
||||
((assignmentUnknown['assignments'] as List).first as Map),
|
||||
);
|
||||
firstAssignment['extra'] = true;
|
||||
assignmentUnknown['assignments'] = [
|
||||
firstAssignment,
|
||||
...(assignmentUnknown['assignments'] as List).skip(1),
|
||||
];
|
||||
expect(parseSectionWorkspaceProjection(assignmentUnknown), isNull);
|
||||
|
||||
final wrongVersion = projection()..['version'] = 2;
|
||||
expect(parseSectionWorkspaceProjection(wrongVersion), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('rejects malformed shape, duplicate ids, and non-permutation ranks', () {
|
||||
final duplicateId = projection();
|
||||
(duplicateId['sections'] as List)[1]['id'] = sectionA;
|
||||
expect(parseSectionWorkspaceProjection(duplicateId), isNull);
|
||||
|
||||
final badRank = projection();
|
||||
(badRank['sections'] as List)[1]['rank'] = 0;
|
||||
expect(parseSectionWorkspaceProjection(badRank), isNull);
|
||||
|
||||
final unknownSection = projection();
|
||||
(unknownSection['assignments'] as List).single['section_id'] = sectionB;
|
||||
expect(parseSectionWorkspaceProjection(unknownSection), isNotNull);
|
||||
(unknownSection['assignments'] as List).single['section_id'] =
|
||||
'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||
expect(parseSectionWorkspaceProjection(unknownSection), isNull);
|
||||
});
|
||||
|
||||
test('duplicate JSON keys and fractional numbers fail strict decoding', () {
|
||||
final valid = jsonEncode(projection());
|
||||
expect(parseSectionWorkspaceProjectionJson(valid), isNotNull);
|
||||
expect(
|
||||
parseSectionWorkspaceProjectionJson(
|
||||
valid.replaceFirst('{"version":1', '{"version":1,"version":1'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
parseSectionWorkspaceProjectionJson(
|
||||
valid.replaceFirst('"revision":4', '"revision":4.5'),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'revision behavior matches accept, refetch, and ignore fixture cases',
|
||||
() {
|
||||
expect(
|
||||
sectionWorkspaceRevisionAction(null, 4),
|
||||
SectionWorkspaceRevisionAction.accept,
|
||||
);
|
||||
expect(
|
||||
sectionWorkspaceRevisionAction(4, 6),
|
||||
SectionWorkspaceRevisionAction.refetch,
|
||||
);
|
||||
expect(
|
||||
sectionWorkspaceRevisionAction(4, 3),
|
||||
SectionWorkspaceRevisionAction.ignore,
|
||||
);
|
||||
expect(
|
||||
sectionWorkspaceRevisionAction(4, 4),
|
||||
SectionWorkspaceRevisionAction.ignore,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('canonicalization matches the shared legacy plaintext vector', () {
|
||||
final input = {
|
||||
'version': 1,
|
||||
'sections': [
|
||||
{'id': sectionA, 'name': 'Alpha', 'icon': 'folder', 'order': 0},
|
||||
],
|
||||
'assignments': {channel: sectionA},
|
||||
};
|
||||
const expected =
|
||||
'{"assignments":{"cccccccc-cccc-4ccc-8ccc-cccccccccccc":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},"sections":[{"icon":"folder","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","name":"Alpha","order":0}],"version":1}';
|
||||
expect(sectionWorkspaceCanonicalJson(input), expected);
|
||||
expect(
|
||||
sectionWorkspaceSha256Hex(expected),
|
||||
'336ba846d8a2aef9ca7de80e494201e503b64fa364ec1a629873f3fa83232d5d',
|
||||
);
|
||||
});
|
||||
|
||||
test('AES-GCM metadata vector authenticates its complete AAD', () {
|
||||
final crypto = SectionWorkspaceMetadataCrypto(
|
||||
Uint8List.fromList(List<int>.generate(32, (index) => index)),
|
||||
);
|
||||
const kwargs = {
|
||||
'community': 'relay.example',
|
||||
'ownerPubkey': owner,
|
||||
'sectionId': sectionA,
|
||||
'keyEpoch': 1,
|
||||
};
|
||||
final envelope = crypto.encrypt(
|
||||
plaintext: 'Alpha',
|
||||
community: kwargs['community']! as String,
|
||||
ownerPubkey: kwargs['ownerPubkey']! as String,
|
||||
sectionId: kwargs['sectionId']! as String,
|
||||
keyEpoch: kwargs['keyEpoch']! as int,
|
||||
purpose: 'label',
|
||||
nonce: Uint8List.fromList(List<int>.generate(12, (index) => index)),
|
||||
);
|
||||
expect(envelope, 'aes256gcm:AAECAwQFBgcICQoL:Bm6mc6SHvhxcEB6Q1Lc56VuJZjD7');
|
||||
expect(
|
||||
crypto.decrypt(
|
||||
envelope: envelope,
|
||||
community: kwargs['community']! as String,
|
||||
ownerPubkey: kwargs['ownerPubkey']! as String,
|
||||
sectionId: kwargs['sectionId']! as String,
|
||||
keyEpoch: kwargs['keyEpoch']! as int,
|
||||
purpose: 'label',
|
||||
),
|
||||
'Alpha',
|
||||
);
|
||||
expect(
|
||||
() => crypto.decrypt(
|
||||
envelope: envelope,
|
||||
community: kwargs['community']! as String,
|
||||
ownerPubkey: kwargs['ownerPubkey']! as String,
|
||||
sectionId: kwargs['sectionId']! as String,
|
||||
keyEpoch: 2,
|
||||
purpose: 'label',
|
||||
),
|
||||
throwsA(anything),
|
||||
);
|
||||
});
|
||||
|
||||
test('cache persists the last verified projection and key epoch', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final parsed = parseSectionWorkspaceProjection(projection())!;
|
||||
final cache = SectionWorkspaceCache(
|
||||
eventId: sourceEvent,
|
||||
projection: parsed,
|
||||
);
|
||||
await writeSectionWorkspaceCache(prefs, owner, 'relay.example', cache);
|
||||
final restored = readSectionWorkspaceCache(prefs, owner, 'relay.example');
|
||||
expect(restored!.eventId, sourceEvent);
|
||||
expect(restored.projection.revision, 4);
|
||||
expect(restored.projection.keyEpoch, 2);
|
||||
expect(restored.toJson()['revision'], 4);
|
||||
expect(restored.toJson()['key_epoch'], 2);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user