Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ System tests must be run manually by the developer in WSL. Prompt them when rele

## Flutter Rust Bridge

FRB callback futures are NOT cancellation safe and must NOT be used as select branches.

After changing public `telepathy-core` members, run exactly:

```sh
Expand Down
77 changes: 68 additions & 9 deletions lib/controllers/profiles_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,21 @@ class ProfilesController with ChangeNotifier {

/// This can still throw when the peer id is invalid because the historical API
/// returns a non-null [Contact]. Use [tryAddContact] when taking user input.
Contact addContact(String nickname, String peerId) {
Contact addContact(
String nickname,
String peerId, {
String? directInvitation,
}) {
final Profile profile = _currentProfile();

late final Contact contact;
late final String contactId;
try {
contact = Contact(nickname: nickname, peerId: peerId);
if (directInvitation != null) {
contact.setDirectInvitation(invitation: directInvitation);
contact.setDirect(isDirect: true);
}
contactId = contact.id();
} catch (error, stackTrace) {
DebugConsole.warn('invalid contact: $error\n$stackTrace');
Expand All @@ -184,9 +192,17 @@ class ProfilesController with ChangeNotifier {
return contact;
}

Contact? tryAddContact(String nickname, String peerId) {
Contact? tryAddContact(
String nickname,
String peerId, {
String? directInvitation,
}) {
try {
return addContact(nickname, peerId);
return addContact(
nickname,
peerId,
directInvitation: directInvitation,
);
} catch (error) {
DebugConsole.warn('contact was not added: $error');
return null;
Expand Down Expand Up @@ -460,6 +476,7 @@ class ProfilesController with ChangeNotifier {

Future<Map<String, Contact>> loadContacts(String id) async {
final Map<String, Contact> contacts = <String, Contact>{};
bool needsDirectInvitationMigration = false;
final String? contactsStr = await _readStorage('$id-contacts');

if (contactsStr == null || contactsStr.trim().isEmpty) {
Expand Down Expand Up @@ -491,18 +508,49 @@ class ProfilesController with ChangeNotifier {
continue;
}

final bool isDirect = contactMap['isDirect'] == true;
final bool hasLegacyInvitationKey =
contactMap.containsKey('directConnectionString');
final Object? persistedInvitation =
contactMap.containsKey('directInvitation')
? contactMap['directInvitation']
: contactMap['directConnectionString'];
final String? directInvitation =
persistedInvitation is String ? persistedInvitation : null;

try {
contacts[entry.key] = Contact.fromParts(
final Contact contact = Contact.fromParts(
id: entry.key,
nickname: nickname,
peerId: peerId,
outputVolume: outputVolume,
isDirect: isDirect,
directInvitation: directInvitation,
);
contacts[entry.key] = contact;
needsDirectInvitationMigration |= hasLegacyInvitationKey ||
(persistedInvitation != null && persistedInvitation is! String) ||
directInvitation != contact.directInvitation() ||
isDirect != contact.isDirect();
} catch (error) {
DebugConsole.warn('invalid contact format for ${entry.key}: $error');
}
}

if (needsDirectInvitationMigration) {
try {
await _writeStorage(
key: '$id-contacts',
value: jsonEncode(_serializeContacts(contacts)),
);
} catch (error) {
DebugConsole.warn(
'failed to persist direct invitation migration for profile $id: '
'$error',
);
}
}

return contacts;
}

Expand Down Expand Up @@ -593,25 +641,36 @@ class ProfilesController with ChangeNotifier {
_safeNotifyListeners();
}

await _writeStorage(
key: '$profileId-contacts',
value: jsonEncode(_serializeContacts(profile.contacts)),
);
}

Map<String, Map<String, dynamic>> _serializeContacts(
Map<String, Contact> contacts,
) {
final Map<String, Map<String, dynamic>> contactsMap =
<String, Map<String, dynamic>>{};

for (final MapEntry<String, Contact> entry in profile.contacts.entries) {
for (final MapEntry<String, Contact> entry in contacts.entries) {
try {
final String? directInvitation = entry.value.directInvitation();
final bool hasCanonicalInvitation =
directInvitation?.startsWith('tp1:') == true;
contactsMap[entry.key] = <String, dynamic>{
'nickname': entry.value.nickname(),
'peerId': entry.value.peerId(),
'outputVolume': entry.value.outputVolume(),
'isDirect': hasCanonicalInvitation && entry.value.isDirect(),
if (hasCanonicalInvitation) 'directInvitation': directInvitation,
};
} catch (error) {
DebugConsole.warn('skipping contact ${entry.key} during save: $error');
}
}

await _writeStorage(
key: '$profileId-contacts',
value: jsonEncode(contactsMap),
);
return contactsMap;
}

Future<void> _saveRoomsFor(String profileId, {bool notify = true}) async {
Expand Down
9 changes: 8 additions & 1 deletion lib/core/rust/flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ abstract class Telepathy implements RustOpaqueInterface {
/// Creates an operation token that can cancel one pending call or room start.
StartOperation newStartOperation();

/// Returns the local endpoint's opaque `tp1:` direct invitation, or
/// `None` if the session manager is not active.
Future<String?> nodeAddr();

void pauseStatistics();

Future<PreparedIdentitySwitch> prepareIdentitySwitch(
Expand Down Expand Up @@ -140,7 +144,10 @@ abstract class Telepathy implements RustOpaqueInterface {
{required Contact contact, required StartOperation operation});

/// Non-blocking: spawns the manager task and returns. The Dart side observes
/// the eventual `Active` transition via the `managerActive` callback.
/// the eventual `Active` transition via the `managerActive` callback. The
/// non-blocking contract is validated by the CLI system test
/// `test_start_manager_ack_precedes_active_event`; the `()` return type
/// prevents silent reintroduction of blocking semantics.
Future<void> startManager();

Future<void> startScreenshare({required Contact contact});
Expand Down
Loading
Loading