From b14884980e9ff70c201ba97d459bed05286fdfd9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 27 Oct 2026 15:09:22 +0000 Subject: [PATCH] Speed up mobile Solo chat creation and agent discovery (#8547) ## Summary Solo compose now opens before agent discovery finishes, and chat creation avoids full channel-list reloads while overlapping independent reads. Durable retries, ownership checks, and relay confirmation remain intact. The agent picker uses the owner's saved inventory, excludes known archived identities, verifies profiles off the UI thread, and reuses avatar artwork. ### Related issue No matching issue found. Related: #8546 adds project selection to the same compose surface. ### Testing - Full mobile suite: 3,755 passed, 19 skipped; four additional artifact tests passed. - Analysis, formatting, file-size checks, and iOS Release build passed. - Regression tests fail when the old blocking paths are restored. - Installed on a physical iPhone with existing profile/community settings preserved. Owner tested the earlier creation/picker improvements; final compose timing awaits device feedback. No timing recording captured. --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../MeAgentAccessibilityTests.swift | 41 ++- .../ios/Runner/NativeProfileTextEditor.swift | 47 +++- .../channels/channel_management_actions.dart | 50 ++++ .../channels/me/me_assistant_list.dart | 1 + mobile/lib/features/channels/me/me_page.dart | 1 + .../channels/me/native_new_me_chat.dart | 113 ++++++--- .../lib/features/channels/me/new_me_chat.dart | 11 +- mobile/lib/shared/me/me_agent_inventory.dart | 89 +++++++ mobile/lib/shared/me/me_archived_agents.dart | 94 +++++++ mobile/lib/shared/me/me_assistants.dart | 141 ++++++----- mobile/lib/shared/me/me_chat_creation.dart | 6 +- .../mentions/agent_identity_provider.dart | 3 + .../channels/me/chat_preflight_test.dart | 19 ++ .../channels/me/solo_creation_tags_test.dart | 235 ++++++++++++++---- .../channels/solo_creation_latency_test.dart | 187 ++++++++++++++ .../shared/me/me_archived_agents_test.dart | 146 +++++++++++ mobile/test/shared/me/me_assistants_test.dart | 215 ++++++++++++---- .../test/shared/me/me_chat_creation_test.dart | 61 +++++ mobile/test/shared/me/me_sync_test.dart | 2 + 19 files changed, 1251 insertions(+), 211 deletions(-) create mode 100644 mobile/lib/shared/me/me_agent_inventory.dart create mode 100644 mobile/lib/shared/me/me_archived_agents.dart create mode 100644 mobile/test/features/channels/solo_creation_latency_test.dart create mode 100644 mobile/test/shared/me/me_archived_agents_test.dart diff --git a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift index 4b5bdcc414..3261ba27f8 100644 --- a/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift +++ b/mobile/ios/NativeControlTests/MeAgentAccessibilityTests.swift @@ -6,6 +6,8 @@ struct MeAgentAccessibilityTests { @MainActor static func main() { for locked in [false, true] { for empty in [false, true] { + let savedName = "Saved agent" + var submitted: [String: String]? let controller = NativeProfileTextEditorViewController( title: "New chat", initialValue: "Chat", placeholder: "Chat name", multiline: false, pageBackgroundColor: .systemGroupedBackground, @@ -14,8 +16,8 @@ struct MeAgentAccessibilityTests { channelDescription: nil, originalName: nil, originalDescription: nil, canEditDetails: true, canEditCanvas: false, canvasContent: "", canvasLoaded: false, - chatOptions: ["fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], - onCancel: {}, onSet: { _ in } + chatOptions: ["selectedAssistant": "agent", "savedAssistant": ["id": "agent", "name": savedName], "presentationId": "test", "assistantsLoading": empty, "fieldsLocked": locked, "assistants": empty ? [] : [["id": "agent", "name": "Agent"]]], + onCancel: {}, onSet: { submitted = $0 as? [String: String] } ) controller.loadViewIfNeeded() precondition(controller.numberOfSections(in: controller.tableView) == 3) @@ -27,10 +29,45 @@ struct MeAgentAccessibilityTests { precondition(project.isEnabled == !locked) precondition(project.showsMenuAsPrimaryAction && project.menu != nil) let cell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let content = cell.contentConfiguration as! UIListContentConfiguration + precondition(content.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) + precondition((content.image != nil) == (locked || !empty)) let disabled = locked || empty precondition(cell.accessibilityTraits.contains(.notEnabled) == disabled) precondition(cell.accessibilityTraits.contains(.button) == !disabled) precondition((cell.selectionStyle == .none) == disabled) + let canSubmit = locked || !empty + precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) + // Invoke the action even when disabled to check its independent gate. + _ = controller.perform(NSSelectorFromString("setTapped")) + precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) + if empty && !locked { + controller.tableView.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.tableView.reloadData() + controller.tableView.layoutIfNeeded() + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 1) == nil) + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == "Loading agents…") + controller.updateChatAssistants([ + "presentationId": "test", + "assistants": [["id": "agent", "name": "Agent"]], + ]) + controller.tableView.layoutIfNeeded() + let updated = controller.tableView.cellForRow(at: IndexPath(row: 0, section: 2))! + precondition(updated.accessibilityTraits.contains(.button)) + precondition(!updated.accessibilityTraits.contains(.notEnabled)) + precondition(controller.tableView(controller.tableView, titleForFooterInSection: 2) == nil) + } + submitted = nil + controller.updateChatAssistants([ + "presentationId": "test", + "assistants": empty ? [] : [["id": "agent", "name": "Agent"]], + ]) + let refreshedCell = controller.tableView(controller.tableView, cellForRowAt: IndexPath(row: 0, section: 2)) + let refreshed = refreshedCell.contentConfiguration as! UIListContentConfiguration + precondition(refreshed.text == (empty ? (locked ? savedName : "Choose an agent") : "Agent")) + precondition(controller.navigationItem.rightBarButtonItem?.isEnabled == canSubmit) + _ = controller.perform(NSSelectorFromString("setTapped")) + precondition(submitted?["assistant"] == (canSubmit ? "agent" : nil)) } } avatarTests() diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index a771c7aacb..c8544b926f 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -28,6 +28,17 @@ final class NativeProfileTextEditorCoordinator: NSObject, _ call: FlutterMethodCall, result: @escaping FlutterResult ) { + if call.method == "updateChatAssistants" { + DispatchQueue.main.async { [weak self] in + if let arguments = call.arguments as? [String: Any], + let navigation = self?.presentedController as? UINavigationController, + let editor = navigation.viewControllers.first as? NativeProfileTextEditorViewController { + editor.updateChatAssistants(arguments) + } + result(nil) + } + return + } guard call.method == "present" || call.method == "presentChannel" || call.method == "presentChat" || call.method == "presentProject" else { result(FlutterMethodNotImplemented) return @@ -241,7 +252,7 @@ private final class NativeProfileTextEditorViewController: private var hasCanvas: Bool { canvasLoaded && !canvasContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - private let chatOptions: [String: Any]? + private var chatOptions: [String: Any]? private var hasFocusedEditor = false private var selectedAssistantAvatar: UIImage? private var selectedAssistant: String? @@ -254,6 +265,8 @@ private final class NativeProfileTextEditorViewController: private var assistants: [[String: String]] { chatOptions?["assistants"] as? [[String: String]] ?? [] } + // Keep decoded artwork while switching between the form and its agent list. + private let assistantAvatars = MeAvatarLoader() private var fieldsLocked: Bool { chatOptions?["fieldsLocked"] as? Bool ?? false } private var isChannelEditor: Bool { channelDescription != nil } @@ -426,13 +439,30 @@ private final class NativeProfileTextEditorViewController: updateNavigation() } + func updateChatAssistants(_ arguments: [String: Any]) { + guard let token = arguments["presentationId"] as? String, + token == chatOptions?["presentationId"] as? String else { return } + chatOptions?["assistants"] = arguments["assistants"] + chatOptions?["assistantsLoading"] = false + chatOptions?["assistantError"] = arguments["assistantError"] + tableView.reloadSections(IndexSet(integer: 2), with: .none) + updateNavigation() + } + + // A durable retry keeps its saved recipient even if discovery hides it. + // Flutter revalidates ownership before any creation writes. + private var canSubmitAssistant: Bool { + guard let selectedAssistant else { return false } + return fieldsLocked || assistants.contains { $0["id"] == selectedAssistant } + } + private func updateNavigation() { navigationItem.rightBarButtonItem?.isEnabled = canEditDetails && hasUnsavedChanges && (!isChannelEditor || !canonicalChannelName.isEmpty) && (!isChatEditor || ( !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && currentValue.count <= 120 - && (isProjectEditor || assistants.contains { $0["id"] == selectedAssistant }) + && (isProjectEditor || canSubmitAssistant) )) navigationController?.isModalInPresentation = hasUnsavedChanges placeholderLabel.isHidden = !textView.text.isEmpty @@ -474,7 +504,7 @@ private final class NativeProfileTextEditorViewController: onSet(name) } else if isChatEditor { guard let selectedAssistant, - assistants.contains(where: { $0["id"] == selectedAssistant }), + canSubmitAssistant, !currentValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, currentValue.count <= 120 else { return } onSet(["name": currentValue, "assistant": selectedAssistant, "project": selectedProject ?? ""]) @@ -511,7 +541,7 @@ private final class NativeProfileTextEditorViewController: if isChatEditor && indexPath.section == 2 { guard !fieldsLocked && !assistants.isEmpty else { return } view.endEditing(true) - let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor) { [weak self] id, avatar in + let picker = MeAssistantListController(assistants: assistants, selected: selectedAssistant, pageColor: pageBackgroundColor, rowColor: containerBackgroundColor, avatars: assistantAvatars) { [weak self] id, avatar in self?.selectedAssistantAvatar = avatar self?.selectedAssistant = id self?.tableView.reloadSections(IndexSet(integer: 2), with: .none) @@ -538,6 +568,8 @@ private final class NativeProfileTextEditorViewController: override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { guard isChatEditor && section == 2 else { return nil } + if chatOptions?["assistantsLoading"] as? Bool == true { return "Loading agents…" } + if let error = chatOptions?["assistantError"] as? String { return error } if let error = chatOptions?["error"] as? String { return error } return assistants.isEmpty ? "No agents are available for this account. Connect an agent on desktop, then try again." @@ -600,8 +632,13 @@ private final class NativeProfileTextEditorViewController: cell.accessoryView = button } else if isChatEditor && indexPath.section == 2 { var content = cell.defaultContentConfiguration() - let selected = assistants.first { $0["id"] == selectedAssistant } + let saved = chatOptions?["savedAssistant"] as? [String: String] + let fallback = fieldsLocked ? selectedAssistant.map { + saved.flatMap { $0["id"] == selectedAssistant ? $0 : nil } ?? ["id": $0, "name": $0] + } : nil + let selected = assistants.first { $0["id"] == selectedAssistant } ?? fallback content.text = selected?["name"] ?? "Choose an agent" + content.textProperties.numberOfLines = 0 if let selected { content.image = selectedAssistantAvatar ?? MeAssistantListController.avatarImage(selected, photo: nil) } diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 2608ee97bb..f8d4b18453 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -41,6 +41,7 @@ class ChannelActions { List labels = const [], String? description, int? ttlSeconds, + bool waitForChannelList = true, }) async { final resolvedChannelId = channelId ?? _newUuidV4(); final tags = buildCreateChannelTags( @@ -55,9 +56,58 @@ class ChannelActions { _ensureCommunityValid(); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); _ensureCommunityValid(); + if (!waitForChannelList) { + // Solo can open from its own confirmed metadata and roster. Reconcile + // the sidebar independently of unrelated channels' message history. + _ref.invalidate(channelsProvider); + return await findJoinedChannel(resolvedChannelId) ?? + (throw StateError('Channel was created but is not visible yet')); + } return _refreshChannelsAndRead(resolvedChannelId); } + /// Read one channel for creation recovery without reloading every membership + /// and every channel's recent messages. Missing membership fails closed. + Future findJoinedChannel(String channelId) async { + _ensureCommunityValid(); + final events = await _session.fetchHistory( + NostrFilter( + kinds: const [39000, 39002], + tags: { + '#d': [channelId], + }, + limit: 2, + ), + ); + _ensureCommunityValid(); + final metadata = events + .where((e) => e.kind == 39000 && e.getTagValue('d') == channelId) + .firstOrNull; + final roster = events + .where((e) => e.kind == 39002 && e.getTagValue('d') == channelId) + .firstOrNull; + if (metadata == null || roster == null) return null; + final members = membersFromEvent(roster); + if (!members.any((m) => m.pubkey == _currentPubkey)) return null; + final details = channelDetailsFromEvent(metadata); + return Channel( + id: channelId, + name: details.name, + channelType: details.channelType, + labels: details.labels, + visibility: details.visibility, + description: details.description, + topic: details.topic, + createdBy: details.createdBy, + createdAt: details.createdAt, + memberCount: members.length, + isMember: true, + archivedAt: details.archivedAt, + ttlSeconds: details.ttlSeconds, + ttlDeadline: details.ttlDeadline, + ); + } + /// Open (or create) a DM channel with the given pubkeys. /// /// This submits a kind:41010 command event; the relay responds with an OK diff --git a/mobile/lib/features/channels/me/me_assistant_list.dart b/mobile/lib/features/channels/me/me_assistant_list.dart index 5927a6374e..16183e863f 100644 --- a/mobile/lib/features/channels/me/me_assistant_list.dart +++ b/mobile/lib/features/channels/me/me_assistant_list.dart @@ -45,6 +45,7 @@ class MeAssistantList extends ConsumerWidget { child: AppListRowRaw( leading: AvatarImage( imageUrl: + agent.avatarUrl ?? (profiles[agent.pubkey] ?? ref .read(userCacheProvider.notifier) diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 434c0d3ace..b418d14bf4 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../../../shared/profile/user_cache_provider.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/animated_avatar.dart'; import '../../../shared/widgets/app_list.dart'; import '../../../shared/widgets/concentric_sheet_surface.dart'; diff --git a/mobile/lib/features/channels/me/native_new_me_chat.dart b/mobile/lib/features/channels/me/native_new_me_chat.dart index dc979aea66..9bf932e38c 100644 --- a/mobile/lib/features/channels/me/native_new_me_chat.dart +++ b/mobile/lib/features/channels/me/native_new_me_chat.dart @@ -48,16 +48,23 @@ Future<(Channel, String)?> _showNewMeChat( var retry = pending != null; while (context.mounted) { sync.check(); - final directory = await ref.read(meAssistantsProvider.future); + final directory = ref.read(meAssistantsProvider).asData?.value; + final directoryRequest = ref.read(meAssistantsProvider).hasError + ? ref.refresh(meAssistantsProvider.future) + : ref.read(meAssistantsProvider.future); + final presentationId = const Uuid().v4(); + var active = true; if (!context.mounted) return null; sync.check(); if (!context.mounted) return null; final theme = utilitySurfaceThemeData(Theme.of(context)); Map? draft; try { - draft = await const MethodChannel('buzz/profile_text_editor') + final presentation = const MethodChannel('buzz/profile_text_editor') .invokeMethod>('presentChat', { 'title': 'New chat', + 'presentationId': presentationId, + 'assistantsLoading': directory == null, 'initialValue': name, 'placeholder': 'Chat name', 'multiline': false, @@ -69,6 +76,19 @@ Future<(Channel, String)?> _showNewMeChat( .toARGB32(), 'containerCornerRadius': Radii.container, 'selectedAssistant': assistant, + if (retry && assistant != null) + 'savedAssistant': { + 'id': assistant, + 'name': + ref.read(userCacheProvider)[assistant]?.label ?? + directory + ?.where((a) => a.pubkey == assistant) + .firstOrNull + ?.displayName ?? + assistant, + 'avatarUrl': + ref.read(userCacheProvider)[assistant]?.avatarUrl ?? '', + }, 'selectedProject': project, 'projects': [ for (final group @@ -77,31 +97,45 @@ Future<(Channel, String)?> _showNewMeChat( ], 'fieldsLocked': retry, 'error': error, - 'assistants': [ - for (final agent in directory) - { - 'id': agent.pubkey, - 'name': agent.displayName ?? 'Agent', - 'artworkScale': - usesInsetAgentArtwork( - ref - .read(userCacheProvider.notifier) - .get(agent.pubkey) - ?.avatarUrl, - ) - ? '$animatedAvatarArtworkScale' - : '1', - 'avatarUrl': - ref - .read(userCacheProvider.notifier) - .get(agent.pubkey) - ?.avatarUrl ?? - '', - }, - ], + 'assistants': _nativeMeAssistants(directory ?? []), }); + // Send updates only to this presentation; late results cannot populate + // a reopened sheet or a different community's editor. + Future loadAssistants() async { + try { + final agents = await directoryRequest; + if (!active || !context.mounted || sync.disposed) return; + await const MethodChannel( + 'buzz/profile_text_editor', + ).invokeMethod('updateChatAssistants', { + 'presentationId': presentationId, + 'assistants': _nativeMeAssistants(agents), + }); + } catch (_) { + if (!active || !context.mounted || sync.disposed) return; + try { + await const MethodChannel( + 'buzz/profile_text_editor', + ).invokeMethod('updateChatAssistants', { + 'presentationId': presentationId, + 'assistants': [], + 'assistantError': + 'Couldn’t load agents. Close this sheet and try again.', + }); + } on PlatformException { + // The native editor may already be dismissed. + } on MissingPluginException { + // Flutter fallback has no native editor. + } + } + } + + loadAssistants(); + draft = await presentation; } on MissingPluginException { break; + } finally { + active = false; } if (draft == null || !context.mounted) return null; name = (draft['name'] as String? ?? '').trim(); @@ -150,28 +184,31 @@ Future _createMeChat( sync = _currentMeSync(ref, sync); final intent = MeChatCreation(channelId, name, assistant, project: project); await intent.save(sync); - final owned = await isOwnedMeAssistant(sync, assistant); + final actions = ref.read(channelActionsProvider); + final preflight = await Future.wait([ + isOwnedMeAssistant(sync, assistant), + sync.head(), + actions.findJoinedChannel(channelId), + ]); + final owned = preflight[0] as bool; sync.check(); if (!owned) { throw StateError('This agent is no longer available.'); } - final head = await sync.head(); + final head = preflight[1] as NostrEvent?; (head == null ? const MeGroups() : sync.decode(head)) .place(channelId, group: project) .encode(sync.community); - final actions = ref.read(channelActionsProvider); return intent.complete( sync, - findChannel: () async { - final all = await ref.read(channelsProvider.notifier).fetchFresh(); - return all.where((c) => c.id == channelId).firstOrNull; - }, + findChannel: () async => preflight[2] as Channel?, createChannel: () => actions.createChannel( channelId: channelId, name: name, channelType: 'stream', visibility: 'private', labels: const ['buzz.solo'], + waitForChannelList: false, ), hasAgent: () async { ref.invalidate(channelMembersProvider(channelId)); @@ -191,3 +228,17 @@ Future _createMeChat( sync.update((current) => current.place(channelId, group: project)), ); } + +List> _nativeMeAssistants( + List agents, +) => [ + for (final agent in agents) + { + 'id': agent.pubkey, + 'name': agent.displayName ?? 'Agent', + 'artworkScale': usesInsetAgentArtwork(agent.avatarUrl) + ? '$animatedAvatarArtworkScale' + : '1', + 'avatarUrl': agent.avatarUrl ?? '', + }, +]; diff --git a/mobile/lib/features/channels/me/new_me_chat.dart b/mobile/lib/features/channels/me/new_me_chat.dart index 57e83407b2..efd8c36055 100644 --- a/mobile/lib/features/channels/me/new_me_chat.dart +++ b/mobile/lib/features/channels/me/new_me_chat.dart @@ -28,10 +28,15 @@ class _NewMeChat extends HookConsumerWidget { ); } final choices = directory.asData?.value ?? []; - final chosen = choices - .where((agent) => agent.pubkey == selected.value) - .firstOrNull; final profile = ref.watch(userCacheProvider)[selected.value]; + final chosen = + choices.where((agent) => agent.pubkey == selected.value).firstOrNull ?? + (frozenAgent.value == null + ? null + : AgentDirectoryEntry( + pubkey: frozenAgent.value!, + displayName: profile?.label ?? frozenAgent.value!, + )); return SingleChildScrollView( padding: EdgeInsets.fromLTRB( Grid.gutter, diff --git a/mobile/lib/shared/me/me_agent_inventory.dart b/mobile/lib/shared/me/me_agent_inventory.dart new file mode 100644 index 0000000000..f5e89c6cd9 --- /dev/null +++ b/mobile/lib/shared/me/me_agent_inventory.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/relay.dart'; +import 'me_sync.dart'; + +/// Desktop's owner-authored agent inventory. A historical kind:0 ownership +/// attestation alone does not mean an agent is still in this inventory. +Future> loadMeAgentInventory(MeSync sync) async { + const pageSize = 200; + final events = []; + int? until; + String? beforeId; + for (var page = 0; page < 50; page++) { + sync.check(); + final batch = await sync.session.queryRelay([ + NostrFilter( + kinds: const [30177], + authors: [sync.pubkey], + limit: pageSize, + until: until, + extensions: {'before_id': ?beforeId}, + ), + ]); + sync.check(); + if (batch.length > pageSize || + batch.any((e) => e.kind != 30177 || e.pubkey != sync.pubkey)) { + throw StateError('Agent inventory response is outside its owner scope.'); + } + events.addAll(batch); + if (batch.length < pageSize) { + final owner = sync.pubkey; + return _parseInventory(events, owner); + } + final ordered = [...batch] + ..sort((a, b) { + final time = b.createdAt.compareTo(a.createdAt); + return time != 0 ? time : a.id.compareTo(b.id); + }); + final last = ordered.last; + if (until != null && + (last.createdAt > until || + (last.createdAt == until && last.id.compareTo(beforeId!) <= 0))) { + throw StateError('Agent inventory pagination did not advance.'); + } + until = last.createdAt; + beforeId = last.id; + } + throw StateError('Agent inventory exceeds the read budget.'); +} + +Future> _parseInventory( + List events, + String owner, +) => Isolate.run(() => _inventoryNames(events, owner)); + +Map _inventoryNames(List events, String owner) { + final heads = {}; + for (final event in events) { + if (event.pubkey != owner) continue; + final id = event.getTagValue('d'); + if (id == null || !RegExp(r'^[0-9a-f]{64}$').hasMatch(id)) continue; + // Verify before allowing a newer tombstone or record to replace a head. + try { + nostr.Event.fromJson(jsonEncode(event.toJson())); + } catch (_) { + // A corrupt record must not hide unrelated verified agents. + continue; + } + final previous = heads[id]; + if (previous == null || meEventIsNewer(event, previous)) heads[id] = event; + } + final names = {}; + for (final entry in heads.entries) { + try { + final body = jsonDecode(entry.value.content); + if (body is! Map) continue; + final name = body['name']; + if (name is String && name.trim().isNotEmpty) { + names[entry.key] = name.trim(); + } + } on FormatException { + // Empty/deleted and malformed heads never resurrect an earlier listing. + } + } + return names; +} diff --git a/mobile/lib/shared/me/me_archived_agents.dart b/mobile/lib/shared/me/me_archived_agents.dart new file mode 100644 index 0000000000..9171a8814f --- /dev/null +++ b/mobile/lib/shared/me/me_archived_agents.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/relay.dart'; +import 'me_sync.dart'; + +final meRelayInformationClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// Matches desktop's known-archived filtering. Unknown archive evidence does +/// not hide identities; this is presentation, never ownership or admission. +final meArchivedAgentsProvider = FutureProvider>((ref) async { + final sync = ref.watch(meSyncProvider); + final client = ref.watch(meRelayInformationClientProvider); + try { + final uri = Uri.parse(sync.community); + final response = await client + .send( + http.Request('GET', uri) + ..headers['Accept'] = 'application/nostr+json', + ) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 200) return {}; + final chunks = StreamIterator( + response.stream.timeout(const Duration(seconds: 3)), + ); + final bytes = []; + try { + await (() async { + while (await chunks.moveNext()) { + final chunk = chunks.current; + if (bytes.length + chunk.length > 256 * 1024) { + throw const FormatException( + 'Relay information exceeds size limit.', + ); + } + bytes.addAll(chunk); + } + })().timeout(const Duration(seconds: 3)); + } finally { + await chunks.cancel(); + } + sync.check(); + final info = jsonDecode(utf8.decode(bytes)); + // NIP-11 self is the signing authority; pubkey is only a contact key. + final authority = info is Map ? info['self'] : null; + if (authority is! String || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(authority)) { + return {}; + } + final events = await sync.session.fetchHistory( + NostrFilter(kinds: const [13535], authors: [authority], limit: 1), + timeout: const Duration(seconds: 3), + ); + sync.check(); + return await _parseArchives(events, authority); + } catch (_) { + return {}; + } +}); + +Future> _parseArchives(List events, String authority) => + Isolate.run(() => _archived(events, authority)); + +Set _archived(List events, String authority) { + if (events.length != 1) return {}; + final event = events.single; + final markers = event.tags.where((tag) => tag.isNotEmpty && tag.first == '-'); + if (event.kind != 13535 || + event.pubkey != authority || + event.content.isNotEmpty || + markers.length != 1 || + markers.single.length != 1) { + return {}; + } + final encoded = jsonEncode(event.toJson()); + if (utf8.encode(encoded).length > 2 * 1024 * 1024) return {}; + nostr.Event.fromJson(encoded); + return { + for (final tag in event.tags) + if (tag.length >= 2 && + tag[0] == 'p' && + RegExp(r'^[0-9a-f]{64}$').hasMatch(tag[1])) + tag[1], + }; +} diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 03575adfe1..6f89e42f17 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:isolate'; import 'package:nostr/nostr.dart' as nostr; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,11 +7,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../crypto/nip_oa.dart'; import '../mentions/agent_identity_provider.dart'; import '../relay/relay.dart'; +import 'me_agent_inventory.dart'; +import 'me_archived_agents.dart'; import 'me_sync.dart'; -/// Owned assistants from verified profiles, including agents without kind 10100. -/// Agents inherit their owner's community access and need not be listed in the -/// community's human membership snapshot. +/// The owner's current inventory, with verified ownership and desktop's +/// known-archived exclusion. Never scan the community's historical profiles. final meAssistantsProvider = FutureProvider>(( ref, ) async { @@ -18,8 +20,32 @@ final meAssistantsProvider = FutureProvider>(( if (sync.connectionState != null) { ref.watch(relaySessionProvider.select((state) => state.status)); } - final session = ref.watch(relaySessionProvider.notifier); - return _loadAssistants(sync, session); + // Refresh archive evidence with the inventory, including explicit retries. + // Ordinary sheet openings reuse this provider's completed directory. + final archivedRequest = ref.refresh(meArchivedAgentsProvider.future); + final inventory = await loadMeAgentInventory(sync); + sync.check(); + final profiles = await _loadProfiles(sync, inventory.keys.toList()); + final archived = await archivedRequest; + sync.check(); + final choices = [ + for (final profile in profiles) + if (!archived.contains(profile.pubkey)) + AgentDirectoryEntry( + pubkey: profile.pubkey, + displayName: inventory[profile.pubkey], + avatarUrl: profile.avatarUrl, + respondTo: profile.respondTo, + respondToAllowlist: profile.respondToAllowlist, + channelIds: profile.channelIds, + ), + ]; + choices.sort( + (a, b) => (a.displayName ?? '').toLowerCase().compareTo( + (b.displayName ?? '').toLowerCase(), + ), + ); + return choices; }, retry: (_, _) => null); /// Freshness-controlled ownership check for one personal-chat recipient. @@ -34,76 +60,63 @@ final meAssistantOwnershipProvider = FutureProvider.family(( return isOwnedMeAssistant(sync, pubkey); }); -/// Revalidates only the chosen assistant before creating a chat. +/// Revalidate the selected recipient, including existing chats whose agent may +/// no longer be offered in the new-chat inventory. Discovery is not admission. Future isOwnedMeAssistant(MeSync sync, String pubkey) async => - (await _loadAssistants( - sync, - sync.session, - pubkey: pubkey, - )).any((agent) => agent.pubkey == pubkey); + (await _loadProfiles(sync, [ + pubkey, + ])).any((agent) => agent.pubkey == pubkey); -Future> _loadAssistants( +Future> _loadProfiles( MeSync sync, - RelaySessionNotifier session, { - String? pubkey, -}) async { - final profiles = {}; - final seen = {}; - const pageSize = 100; - // The current relay only indexes single-letter NIP-01 tag filters. It - // silently drops #auth, so owner-scoped discovery needs relay support. - // Until then fail visibly at this bound rather than drain the directory. - const maxPages = 10; - for (var page = 1; page <= maxPages; page++) { + List pubkeys, +) async { + final profiles = []; + const batchSize = 100; + for (var start = 0; start < pubkeys.length; start += batchSize) { sync.check(); - final events = await session.queryRelay([ - NostrFilter( - kinds: const [0], - authors: pubkey == null ? null : [pubkey], - limit: pageSize, - extensions: {'page': page}, - ), + final batch = pubkeys.skip(start).take(batchSize).toList(); + final events = await sync.session.queryRelay([ + NostrFilter(kinds: const [0], authors: batch, limit: batchSize), ]); sync.check(); - if (events.length > pageSize) { - throw StateError('Agent directory exceeded the requested page size.'); - } - var added = false; - for (final event in events) { - if (event.kind != 0 || (pubkey != null && event.pubkey != pubkey)) { - continue; - } - if (seen.contains(event.id)) continue; - try { - nostr.Event.fromJson(jsonEncode(event.toJson())); - } catch (_) { - continue; - } - if (!seen.add(event.id)) continue; - added = true; - final previous = profiles[event.pubkey]; - if (previous == null || - event.createdAt > previous.createdAt || - (event.createdAt == previous.createdAt && - event.id.compareTo(previous.id) < 0)) { - profiles[event.pubkey] = event; - } - } - if (events.length < pageSize) break; - if (!added || page == maxPages) { - throw StateError('Could not finish loading assistants. Try again.'); + if (events.length > batchSize) { + throw StateError('Agent profiles exceeded the requested batch size.'); } + profiles.addAll( + events.where((e) => e.kind == 0 && batch.contains(e.pubkey)), + ); } - final agents = []; - for (final event in profiles.values) { + final owner = sync.pubkey; + final result = await _parseProfiles(profiles, owner); + sync.check(); + return result; +} + +Future> _parseProfiles( + List events, + String owner, +) => Isolate.run(() => _verifiedProfiles(events, owner)); + +List _verifiedProfiles( + List events, + String owner, +) { + final latest = {}; + for (final event in events) { try { - if (verifiedProfileOaOwnerPubkey(event) == sync.pubkey) { - agents.add(AgentDirectoryEntry.fromEvent(event)); - } + nostr.Event.fromJson(jsonEncode(event.toJson())); } catch (_) { - // A malformed profile must not hide unrelated verified agents. continue; } + final previous = latest[event.pubkey]; + if (previous == null || meEventIsNewer(event, previous)) { + latest[event.pubkey] = event; + } } - return agents; + return [ + for (final event in latest.values) + if (verifiedProfileOaOwnerPubkey(event) == owner) + AgentDirectoryEntry.fromEvent(event), + ]; } diff --git a/mobile/lib/shared/me/me_chat_creation.dart b/mobile/lib/shared/me/me_chat_creation.dart index 8fb657b6ac..f366fb0df5 100644 --- a/mobile/lib/shared/me/me_chat_creation.dart +++ b/mobile/lib/shared/me/me_chat_creation.dart @@ -50,6 +50,7 @@ class MeChatCreation { previous.project != project)) { throw StateError('Resume the pending chat before starting another.'); } + // A matching cached value does not prove a previous disk write succeeded. if (sync.prefs == null || !await sync.prefs!.setString( storageKey(sync), @@ -108,11 +109,12 @@ class MeChatCreation { sync.check(); final result = existing ?? await createChannel(); sync.check(); - final member = await hasAgent(); + final status = await Future.wait([hasAgent(), isPlaced()]); + final member = status[0]; sync.check(); if (!member) await addAgent(); sync.check(); - final placed = await isPlaced(); + final placed = status[1]; sync.check(); // Placement may be confirmed while its inner journal cleanup failed. if (!placed || sync.prefs?.getString(sync.pendingKey) != null) { diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 75a9673a23..d98e937c43 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -16,6 +16,7 @@ import '../../shared/utils/string_utils.dart'; class AgentDirectoryEntry { final String pubkey; final String? displayName; + final String? avatarUrl; final String? respondTo; final List respondToAllowlist; final List channelIds; @@ -23,6 +24,7 @@ class AgentDirectoryEntry { const AgentDirectoryEntry({ required this.pubkey, this.displayName, + this.avatarUrl, this.respondTo, this.respondToAllowlist = const [], this.channelIds = const [], @@ -38,6 +40,7 @@ class AgentDirectoryEntry { return AgentDirectoryEntry( pubkey: event.pubkey.toLowerCase(), displayName: text('display_name') ?? text('name'), + avatarUrl: text('picture'), respondTo: text('respond_to'), respondToAllowlist: strings( 'respond_to_allowlist', diff --git a/mobile/test/features/channels/me/chat_preflight_test.dart b/mobile/test/features/channels/me/chat_preflight_test.dart index a136d55b17..ab0ef8495e 100644 --- a/mobile/test/features/channels/me/chat_preflight_test.dart +++ b/mobile/test/features/channels/me/chat_preflight_test.dart @@ -1,4 +1,5 @@ import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_assistants.dart'; @@ -23,6 +24,12 @@ class _Channels extends ChannelsNotifier { class _Relay extends RelaySessionNotifier { VoidCallback? beforeRead; int reads = 0; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + @override Future> queryRelay( List filters, { @@ -58,6 +65,7 @@ void main() { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, ) async { + if (call.method == 'updateChatAssistants') return null; forms++; if (forms > 1) return null; if (disconnect) { @@ -86,6 +94,17 @@ void main() { ), meAssistantsProvider.overrideWith((ref) async => []), channelsProvider.overrideWith(_Channels.new), + channelActionsProvider.overrideWith( + (ref) => ChannelActions( + ref: ref, + session: relay, + signedEventRelay: SignedEventRelay( + session: relay, + nsec: config.nsec, + ), + currentPubkey: sync.pubkey, + ), + ), ], child: MaterialApp(theme: AppTheme.light(), home: const MePage()), ), diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 009f5eba35..1e30cbb48e 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:buzz/features/channels/channel.dart'; @@ -6,9 +7,14 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/me/me_page.dart'; import 'package:buzz/shared/me/me_assistants.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:flutter/foundation.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; @@ -20,48 +26,27 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _EmptyChannels extends ChannelsNotifier { - _EmptyChannels(this.existing); - final bool existing; - int freshReads = 0; @override Future> build() async => []; - @override - Future> fetchFresh({bool fetchDirectory = false}) async { - freshReads++; - return [ - if (existing) - Channel( - id: '10000000-0000-4000-8000-000000000001', - name: 'Mobile Solo', - channelType: 'stream', - visibility: 'private', - description: '', - createdBy: 'me', - createdAt: DateTime.utc(2026), - memberCount: 1, - isMember: true, - ), - ]; - } } class _Relay extends RelaySessionNotifier { final NostrEvent profile; final submitted = []; - _Relay(this.profile); + _Relay(this.profile, this.existing); + final List existing; @override Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), - }) async => - filters.any((filter) => filter.kinds.contains(0)) ? [profile] : []; + }) async => [profile]; @override Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => []; + }) async => filter.kinds.contains(39000) ? existing : []; @override Future publish( @@ -82,6 +67,7 @@ void main() { ? 'interrupted Solo recovery uses fresh membership before creating' : 'New Solo chat submits its discovery label through the production action', (tester) async { + final flutterSheet = defaultTargetPlatform == TargetPlatform.android; SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final owner = nostr.Keys.generate(), agent = nostr.Keys.generate(); @@ -108,29 +94,89 @@ void main() { ], ).toMap(), ); - final relay = _Relay(profile); + const channelId = '10000000-0000-4000-8000-000000000001'; + final relay = _Relay(profile, [ + if (recoverExisting) + for (final kind in [39000, 39002]) + NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: '', + secretKey: owner.secret, + tags: [ + ['d', channelId], + if (kind == 39000) ['name', 'Mobile Solo'], + if (kind == 39002) ['p', owner.public], + ], + ).toMap(), + ), + ]); final config = RelayConfig( baseUrl: 'https://relay.test', nsec: owner.nsec, ); final sync = MeSync(relay, config, prefs: prefs); - final channels = _EmptyChannels(recoverExisting); if (recoverExisting) { await MeChatCreation( - '10000000-0000-4000-8000-000000000001', + channelId, 'Mobile Solo', agent.public, ).save(sync); } const bridge = MethodChannel('buzz/profile_text_editor'); + // Let native chrome mount while the real verification isolate runs. + const platformViews = MethodChannel('flutter/platform_views'); + final nativeViews = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + platformViews, + (call) async { + if (call.method == 'create') { + final args = call.arguments as Map; + final channel = MethodChannel( + '${args['viewType']}/${args['id']}', + ); + nativeViews.add(channel); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => null, + ); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + platformViews, + null, + ), + ); + addTearDown(() { + for (final channel in nativeViews) { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + } + }); + final directory = Completer>(); + if (flutterSheet) directory.complete([]); + final draft = Completer>(); + String? presentationId; + Map? agentUpdate; + Object? savedAssistant; var forms = 0; - final formErrors = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, ( call, ) async { - formErrors.add((call.arguments as Map)['error']); + if (call.method == 'updateChatAssistants') { + agentUpdate = call.arguments as Map; + return null; + } if (++forms > 1) return null; - return {'name': 'Mobile Solo', 'assistant': agent.public}; + final arguments = call.arguments as Map; + presentationId = arguments['presentationId'] as String; + savedAssistant = arguments['savedAssistant']; + return draft.future; }); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( @@ -142,14 +188,13 @@ void main() { ProviderScope( overrides: [ meSyncProvider.overrideWithValue(sync), + relaySessionProvider.overrideWith(() => relay), meGroupsProvider.overrideWith( (ref) => Stream.value(const MeGroups()), ), - meAssistantsProvider.overrideWith((ref) async => []), - channelsProvider.overrideWith(() => channels), - channelMembersProvider( - '10000000-0000-4000-8000-000000000001', - ).overrideWith((ref) async => []), + meAssistantsProvider.overrideWith((ref) => directory.future), + channelsProvider.overrideWith(_EmptyChannels.new), + channelMembersProvider(channelId).overrideWith((ref) async => []), channelActionsProvider.overrideWith( (ref) => ChannelActions( ref: ref, @@ -166,30 +211,120 @@ void main() { ), ); await tester.pumpAndSettle(); - tester + final start = tester .widget(find.byType(FrostedAppBar)) .nativeActions! .firstWhere((action) => action.label == 'New chat') - .onPressed!(); + .onPressed!; + // Ownership verification runs in a real isolate; pumpAndSettle alone + // only drains the widget test's fake clock, not that computation. + start(); await tester.pumpAndSettle(); if (recoverExisting) { await tester.tap(find.text('Resume')); await tester.pumpAndSettle(); - // Continue to member setup using the existing channel. A stale lookup - // would instead submit a second kind:9007 creation command. - expect(channels.freshReads, 1, reason: formErrors.toString()); - expect(relay.submitted.where((e) => e.kind == 9007), isEmpty); - return; } + if (flutterSheet) { + final row = find.byKey(const ValueKey('new-session-assistant')); + if (!recoverExisting) { + expect(find.text('Choose an agent'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('new-session-name')), + 'Mobile Solo', + ); + await tester.tap(find.text('Create chat')); + await tester.pumpAndSettle(); + expect( + find.text('Enter a name and choose an agent.'), + findsOneWidget, + ); + expect(relay.submitted, isEmpty); + expect(MeChatCreation.pending(sync), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + return; + } + final semantics = tester.ensureSemantics(); + await tester.pump(); + expect(find.text('Choose an agent'), findsNothing); + expect(find.text(agent.public), findsOneWidget); + expect(find.bySemanticsLabel(agent.public), findsOneWidget); + expect(tester.widget(row).onTap, isNull); + final container = ProviderScope.containerOf(tester.element(row)); + container + .read(userCacheProvider.notifier) + .put( + UserProfile(pubkey: agent.public, displayName: 'Saved agent'), + ); + await tester.pumpAndSettle(); + expect(find.text('Saved agent'), findsOneWidget); + expect(find.bySemanticsLabel('Saved agent'), findsOneWidget); + expect(tester.widget(row).onTap, isNull); + semantics.dispose(); + await tester.tap(find.text('Retry')); + } else { + expect( + forms, + 1, + reason: 'Compose opens even while agents are loading', + ); + if (recoverExisting) { + expect(savedAssistant, { + 'id': agent.public, + 'name': agent.public, + 'avatarUrl': '', + }); + } + directory.complete([ + AgentDirectoryEntry( + pubkey: agent.public, + displayName: 'Owned agent', + ), + ]); + await tester.pumpAndSettle(); + expect(agentUpdate?['presentationId'], presentationId); + expect( + (agentUpdate?['assistants'] as List).single['id'], + agent.public, + ); + draft.complete({'name': 'Mobile Solo', 'assistant': agent.public}); + } + for ( + var attempts = 0; + attempts < 100 && relay.submitted.isEmpty; + attempts++ + ) { + await tester.pumpAndSettle(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + } + await tester.pumpAndSettle(); final command = relay.submitted.single; - expect(command.kind, 9007); - expect(command.pubkey, owner.public); - expect(command.tags, contains(equals(['t', 'buzz.solo']))); - expect(command.tags, contains(equals(['channel_type', 'stream']))); - expect(command.tags, contains(equals(['visibility', 'private']))); - expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + if (recoverExisting) { + expect(relay.submitted.where((event) => event.kind == 9007), isEmpty); + expect( + command.kind, + 9000, + reason: 'Recovery continues with member setup', + ); + expect(MeChatCreation.pending(sync)?.id, channelId); + expect(command.tags, contains(equals(['p', agent.public]))); + } else { + expect(command.kind, 9007); + expect(command.pubkey, owner.public); + expect(command.tags, contains(equals(['t', 'buzz.solo']))); + expect(command.tags, contains(equals(['channel_type', 'stream']))); + expect(command.tags, contains(equals(['visibility', 'private']))); + expect(command.tags.where((tag) => tag.first == 'P'), isEmpty); + } + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), + variant: TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), ); } } diff --git a/mobile/test/features/channels/solo_creation_latency_test.dart b/mobile/test/features/channels/solo_creation_latency_test.dart new file mode 100644 index 0000000000..fda46d9b3f --- /dev/null +++ b/mobile/test/features/channels/solo_creation_latency_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'Solo creation finishes while the full channel list is stalled', + () async { + final keys = nostr.Keys.generate(); + final session = _SoloSession(keys.public); + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(keys.public), + ], + ); + addTearDown(container.dispose); + container.listen(channelsProvider, (_, _) {}); + await session.listStarted.future; + final actions = container.read( + Provider( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + ), + ), + ); + + expect(await actions.findJoinedChannel('solo-id'), isNull); + final created = await actions + .createChannel( + channelId: 'solo-id', + name: 'New chat', + channelType: 'stream', + visibility: 'private', + labels: const ['buzz.solo'], + waitForChannelList: false, + ) + .timeout(const Duration(seconds: 2)); + expect(session.stalledList.isCompleted, isFalse); + expect(container.read(channelsProvider).isLoading, isTrue); + expect(created.isPersonalChat(keys.public, const []), isTrue); + expect(created.isMember, isTrue); + expect(created.memberCount, 1); + expect(session.reads, 2); + expect(session.published.single.kind, 9007); + expect( + session.published.single.tags, + contains(equals(['t', 'buzz.solo'])), + ); + expect(session.published.single.tags.any((t) => t.first == 'P'), isFalse); + // A retry reads the same channel, without a broad membership query. + expect((await actions.findJoinedChannel('solo-id'))?.id, created.id); + expect(session.published, hasLength(1)); + }, + ); + + test( + 'targeted recovery requires membership and preserves failures', + () async { + final keys = nostr.Keys.generate(); + final session = _SoloSession('another-owner')..exists = true; + var valid = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + final actions = container.read( + Provider( + (ref) => ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay( + session: session, + nsec: keys.nsec, + ), + currentPubkey: keys.public, + isCommunityValid: () => valid, + ), + ), + ); + expect(await actions.findJoinedChannel('solo-id'), isNull); + session.failure = TimeoutException('relay unavailable'); + await expectLater( + actions.findJoinedChannel('solo-id'), + throwsA(isA()), + ); + session.failure = null; + session.onRead = () => valid = false; + await expectLater(actions.findJoinedChannel('solo-id'), throwsStateError); + expect(session.published, isEmpty); + }, + ); +} + +class _SoloSession extends RelaySessionNotifier { + _SoloSession(this.owner); + final String owner; + bool exists = false; + int reads = 0; + Object? failure; + void Function()? onRead; + final published = []; + final stalledList = Completer>(); + final listStarted = Completer(); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filters.single.tags, { + '#p': [owner], + }); + if (!listStarted.isCompleted) listStarted.complete(); + return stalledList.future; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + exists = true; + return event; + } + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + reads++; + expect(filter.kinds, [39000, 39002]); + expect(filter.tags, { + '#d': ['solo-id'], + }); + expect(filter.limit, 2); + onRead?.call(); + if (failure != null) throw failure!; + if (!exists) return []; + return [ + NostrEvent( + id: 'metadata', + pubkey: 'relay', + createdAt: 1, + kind: 39000, + tags: [ + ['d', 'solo-id'], + ['name', 'New chat'], + ['t', 'stream'], + ['t', 'buzz.solo'], + ['private'], + ['P', owner], + ], + content: '', + sig: '', + ), + NostrEvent( + id: 'roster', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: [ + ['d', 'solo-id'], + ['p', owner, '', 'owner'], + ], + content: '', + sig: '', + ), + ]; + } +} diff --git a/mobile/test/shared/me/me_archived_agents_test.dart b/mobile/test/shared/me/me_archived_agents_test.dart new file mode 100644 index 0000000000..99a53534ba --- /dev/null +++ b/mobile/test/shared/me/me_archived_agents_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/me/me_sync.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test( + 'only a verified snapshot from NIP-11 self hides archived agents', + () async { + final owner = nostr.Keys.generate(); + final authority = nostr.Keys.generate(); + final impostor = nostr.Keys.generate(); + final target = 'a' * 64; + NostrEvent snapshot(nostr.Keys signer, {bool marker = true}) => + NostrEvent.fromJson( + nostr.Event.from( + kind: 13535, + content: '', + secretKey: signer.secret, + tags: [ + if (marker) ['-'], + ['p', target], + ], + ).toMap(), + ); + final relay = _ArchiveRelay(snapshot(authority)); + final client = MockClient((request) async { + expect(request.url.origin, 'https://relay.test'); + expect(request.headers['Accept'], 'application/nostr+json'); + return http.Response( + jsonEncode({'self': authority.public, 'pubkey': impostor.public}), + 200, + ); + }); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + expect(await container.read(meArchivedAgentsProvider.future), {target}); + expect(relay.author, authority.public); + for (final invalid in [ + snapshot(impostor), + snapshot(authority, marker: false), + NostrEvent.fromJson(snapshot(authority).toJson()..['sig'] = '0' * 128), + ]) { + relay.event = invalid; + container.invalidate(meArchivedAgentsProvider); + expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + } + relay.event = snapshot(authority); + container.invalidate(meArchivedAgentsProvider); + expect(await container.read(meArchivedAgentsProvider.future), {target}); + }, + ); + + test('slow metadata has a total deadline and cancels its stream', () async { + final owner = nostr.Keys.generate(); + final relay = _ArchiveRelay(null); + var cancelled = false; + final body = StreamController>(onCancel: () => cancelled = true); + final client = MockClient.streaming( + (_, _) async => http.StreamedResponse(body.stream, 200), + ); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + addTearDown(body.close); + final trickle = Timer.periodic(const Duration(milliseconds: 100), (_) { + body.add([32]); + }); + addTearDown(trickle.cancel); + final result = await container + .read(meArchivedAgentsProvider.future) + .timeout(const Duration(seconds: 5)); + expect(result, isEmpty); + expect(cancelled, isTrue); + expect(relay.author, isNull); + }); + + test( + 'contact keys and unavailable metadata are not archive authority', + () async { + final owner = nostr.Keys.generate(); + final relay = _ArchiveRelay(null); + final client = MockClient( + (_) async => http.Response(jsonEncode({'pubkey': owner.public}), 200), + ); + final container = ProviderContainer( + overrides: [ + meSyncProvider.overrideWithValue( + MeSync( + relay, + RelayConfig(baseUrl: 'https://relay.test', nsec: owner.nsec), + ), + ), + meRelayInformationClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + addTearDown(client.close); + expect(await container.read(meArchivedAgentsProvider.future), isEmpty); + expect(relay.author, isNull); + }, + ); +} + +class _ArchiveRelay extends RelaySessionNotifier { + _ArchiveRelay(this.event); + NostrEvent? event; + String? author; + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filter.kinds, [13535]); + author = filter.authors!.single; + return [?event]; + } +} diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 62aee1b2a5..3e952feace 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_archived_agents.dart'; +import 'package:buzz/shared/me/me_agent_inventory.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,10 +12,12 @@ import 'package:pointycastle/digests/sha256.dart'; class _Relay extends RelaySessionNotifier { final List> pages; - final requestedPages = []; + List? inventory; + List>? inventoryPages; + int inventoryReads = 0; final requestedTags = >[]; final requestedAuthors = ?>[]; - _Relay(this.pages); + _Relay(this.pages, {this.inventory}); @override SessionState build() => const SessionState(status: SessionStatus.connected); @override @@ -21,12 +25,19 @@ class _Relay extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { - expect(filters.single.kinds, [0]); - requestedTags.add(filters.single.toJson()); - requestedAuthors.add(filters.single.authors); - final page = filters.single.toJson()['page'] as int; - requestedPages.add(page); - return pages[page - 1]; + final filter = filters.single; + requestedTags.add(filter.toJson()); + if (filter.kinds.single == 30177) { + return inventoryPages?[inventoryReads++] ?? inventory ?? const []; + } + expect(filter.kinds, [0]); + expect( + filter.authors, + isNotEmpty, + reason: 'Never scan all community profiles', + ); + requestedAuthors.add(filter.authors); + return pages.expand((p) => p).toList(); } } @@ -68,7 +79,25 @@ void main() { ); } - ProviderContainer setup(_Relay relay) { + NostrEvent listing( + String pubkey, { + String name = 'Saved agent', + int time = 1, + String? content, + }) => NostrEvent.fromJson( + nostr.Event.from( + kind: 30177, + content: content ?? jsonEncode({'name': name}), + secretKey: owner.secret, + createdAt: time, + tags: [ + ['d', pubkey], + ], + ).toMap(), + ); + + ProviderContainer setup(_Relay relay, {Set archived = const {}}) { + relay.inventory ??= [listing(agent.public)]; final sync = MeSync( relay, RelayConfig( @@ -83,29 +112,41 @@ void main() { overrides: [ relaySessionProvider.overrideWith(() => relay), meSyncProvider.overrideWithValue(sync), + meArchivedAgentsProvider.overrideWith((ref) async => archived), ], ); addTearDown(container.dispose); return container; } - test('finds owned kind 0 agents without legacy directory entries', () async { - final valid = profile(agent, ownedBy: owner); - final forged = valid.toJson()..['pubkey'] = other.public; - final container = setup( - _Relay([ - [ - profile(owner), - profile(nostr.Keys.generate(), ownedBy: other), - NostrEvent.fromJson(forged), - valid, - ], - ]), - ); - final choices = await container.read(meAssistantsProvider.future); - expect(choices.map((a) => a.pubkey), [agent.public]); - expect(choices.single.displayName, 'My assistant'); - }); + test( + 'lists current inventory with verified ownership, name and artwork', + () async { + final valid = profile( + agent, + ownedBy: owner, + content: { + 'display_name': 'Profile name', + 'picture': 'https://example.com/agent.png', + }, + ); + final forged = valid.toJson()..['pubkey'] = other.public; + final container = setup( + _Relay([ + [ + profile(owner), + profile(nostr.Keys.generate(), ownedBy: other), + NostrEvent.fromJson(forged), + valid, + ], + ]), + ); + final choices = await container.read(meAssistantsProvider.future); + expect(choices.map((a) => a.pubkey), [agent.public]); + expect(choices.single.displayName, 'Saved agent'); + expect(choices.single.avatarUrl, 'https://example.com/agent.png'); + }, + ); for (final malformed in [7, true, 'wrong', {}, []]) { test( 'malformed optional profile fields do not poison directory: $malformed', @@ -135,7 +176,7 @@ void main() { final choices = await container.read(meAssistantsProvider.future); expect( choices.singleWhere((a) => a.pubkey == agent.public).displayName, - 'My assistant', + 'Saved agent', ); expect( await isOwnedMeAssistant( @@ -147,6 +188,32 @@ void main() { }, ); } + test( + 'inventory pagination advances and rejects a repeated full page', + () async { + final first = listing(agent.public, time: 2); + final last = listing(other.public, time: 1); + final page = List.filled(200, first); + final relay = _Relay([]) + ..inventoryPages = [ + page, + [last], + ]; + final sync = setup(relay).read(meSyncProvider); + expect( + (await loadMeAgentInventory(sync)).keys, + unorderedEquals([agent.public, other.public]), + ); + expect(relay.requestedTags.last['until'], 2); + expect(relay.requestedTags.last['before_id'], first.id); + expect(relay.requestedTags.first['authors'], [owner.public]); + relay.inventoryReads = 0; + relay.inventoryPages = [page, page]; + await expectLater(loadMeAgentInventory(sync), throwsStateError); + expect(relay.inventoryReads, 2); + }, + ); + test('latest profile removes obsolete ownership', () async { final container = setup( _Relay([ @@ -155,38 +222,78 @@ void main() { ); expect(await container.read(meAssistantsProvider.future), isEmpty); }); - test('reads beyond the first directory page', () async { - final relay = _Relay([ - [for (var i = 0; i < 100; i++) profile(owner, time: i + 1)], - [profile(agent, ownedBy: owner)], - ]); - expect( - (await setup(relay).read(meAssistantsProvider.future)).single.pubkey, - agent.public, - ); - expect(relay.requestedPages, [1, 2]); - }); test( - 'directory discovery is bounded even when the relay keeps paging', + 'omits historical, removed and archived agents without losing ownership checks', () async { - final repeated = [ - for (var i = 0; i < 99; i++) profile(agent, ownedBy: owner, time: i), - ]; - final relay = _Relay([ - for (var page = 0; page < 11; page++) - [...repeated, profile(agent, ownedBy: owner, time: 100 + page)], - ]); - await expectLater( - setup(relay).read(meAssistantsProvider.future), - throwsStateError, + final archivedAgent = nostr.Keys.generate(); + final removed = nostr.Keys.generate(); + final historical = nostr.Keys.generate(); + final relay = _Relay( + [ + [ + for (final key in [agent, archivedAgent, removed, historical]) + profile(key, ownedBy: owner), + ], + ], + inventory: [ + listing(agent.public), + for (final field in ['id', 'sig']) + NostrEvent.fromJson( + listing(agent.public, time: 3, content: '{}').toJson() + ..[field] = '0' * (field == 'id' ? 64 : 128), + ), + listing(archivedAgent.public), + listing(removed.public), + listing(removed.public, time: 2, content: '{}'), + ], + ); + final container = setup(relay, archived: {archivedAgent.public}); + expect( + (await container.read( + meAssistantsProvider.future, + )).map((a) => a.pubkey), + [agent.public], + ); + expect( + relay.requestedAuthors.single, + unorderedEquals([agent.public, archivedAgent.public]), + ); + expect( + await isOwnedMeAssistant( + container.read(meSyncProvider), + historical.public, + ), + isTrue, ); - expect(relay.requestedPages.length, 10); - for (final filter in relay.requestedTags) { - expect(filter.containsKey('#auth'), isFalse); - expect(filter['limit'], 100); - } }, ); + test( + 'empty inventory is authoritative and does not scan historical profiles', + () async { + final relay = _Relay([ + [profile(agent, ownedBy: owner)], + ], inventory: []); + expect(await setup(relay).read(meAssistantsProvider.future), isEmpty); + expect(relay.requestedAuthors, isEmpty); + }, + ); + test('inventory rejects records outside owner scope', () async { + final foreign = NostrEvent.fromJson( + nostr.Event.from( + kind: 30177, + content: '{"name":"Foreign"}', + secretKey: other.secret, + tags: [ + ['d', agent.public], + ], + ).toMap(), + ); + final container = setup(_Relay([], inventory: [foreign])); + await expectLater( + loadMeAgentInventory(container.read(meSyncProvider)), + throwsStateError, + ); + }); for (final conditions in ['kind=1', 'created_at>1', 'created_at<1']) { test( 'discovery and send revalidation reject inapplicable $conditions', diff --git a/mobile/test/shared/me/me_chat_creation_test.dart b/mobile/test/shared/me/me_chat_creation_test.dart index 7aac3f116f..a7e79ccdc6 100644 --- a/mobile/test/shared/me/me_chat_creation_test.dart +++ b/mobile/test/shared/me/me_chat_creation_test.dart @@ -1,11 +1,59 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { + test( + 'retry confirms persistence after a failed cached journal write', + () async { + SharedPreferences.setMockInitialValues({}); + final store = _FailingStore(); + final previousStore = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = store; + addTearDown( + () => SharedPreferencesStorePlatform.instance = previousStore, + ); + final prefs = await SharedPreferences.getInstance(); + final sync = MeSync( + RelaySessionNotifier(), + RelayConfig( + baseUrl: 'https://relay.test', + nsec: nostr.Keys.generate().nsec, + ), + prefs: prefs, + ); + const intent = MeChatCreation('id', 'Name', 'agent'); + var reachedRelay = false; + Future run() => intent.complete( + sync, + findChannel: () async { + reachedRelay = true; + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + return null; + }, + createChannel: () async => throw StateError('Simulated process stop'), + hasAgent: () async => false, + addAgent: () async {}, + isPlaced: () async => false, + place: () async {}, + ); + await expectLater(run(), throwsStateError); + expect(reachedRelay, isFalse); + expect( + MeChatCreation.pending(sync)?.id, + intent.id, + ); // Failed write remains cached. + await expectLater(run(), throwsStateError); + expect(reachedRelay, isTrue); + await prefs.reload(); + expect(MeChatCreation.pending(sync)?.id, intent.id); + }, + ); test('missing durable storage blocks every relay side effect', () async { final sync = MeSync( RelaySessionNotifier(), @@ -150,3 +198,16 @@ void main() { }); } } + +class _FailingStore extends InMemorySharedPreferencesStore { + _FailingStore() : super.empty(); + bool failNext = true; + @override + Future setValue(String valueType, String key, Object value) async { + if (failNext && key.startsWith('flutter.me-chat-creation:')) { + failNext = false; + return false; + } + return super.setValue(valueType, key, value); + } +} diff --git a/mobile/test/shared/me/me_sync_test.dart b/mobile/test/shared/me/me_sync_test.dart index 5675ec23bb..4d028b9c66 100644 --- a/mobile/test/shared/me/me_sync_test.dart +++ b/mobile/test/shared/me/me_sync_test.dart @@ -9,6 +9,7 @@ import 'package:buzz/shared/crypto/nip44.dart'; import 'package:buzz/shared/me/me_groups.dart'; import 'package:buzz/shared/me/me_chat_creation.dart'; import 'package:buzz/shared/me/me_assistants.dart'; +import 'package:buzz/shared/me/me_archived_agents.dart'; import 'package:buzz/shared/me/me_sync.dart'; import 'package:buzz/shared/me/me_recovery.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -203,6 +204,7 @@ void main() { final config = _Config(RelayConfig(baseUrl: origin, nsec: nsec)); final container = ProviderContainer( overrides: [ + meArchivedAgentsProvider.overrideWith((ref) async => {}), relayConfigProvider.overrideWith(() => config), relaySessionProvider.overrideWith(() => relay), savedPrefsProvider.overrideWithValue(prefs),