diff --git a/AGENTS.md b/AGENTS.md index 6337f888..a0944d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/lib/controllers/profiles_controller.dart b/lib/controllers/profiles_controller.dart index 7c1467c7..114df7e2 100644 --- a/lib/controllers/profiles_controller.dart +++ b/lib/controllers/profiles_controller.dart @@ -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'); @@ -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; @@ -460,6 +476,7 @@ class ProfilesController with ChangeNotifier { Future> loadContacts(String id) async { final Map contacts = {}; + bool needsDirectInvitationMigration = false; final String? contactsStr = await _readStorage('$id-contacts'); if (contactsStr == null || contactsStr.trim().isEmpty) { @@ -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; } @@ -593,25 +641,36 @@ class ProfilesController with ChangeNotifier { _safeNotifyListeners(); } + await _writeStorage( + key: '$profileId-contacts', + value: jsonEncode(_serializeContacts(profile.contacts)), + ); + } + + Map> _serializeContacts( + Map contacts, + ) { final Map> contactsMap = >{}; - for (final MapEntry entry in profile.contacts.entries) { + for (final MapEntry entry in contacts.entries) { try { + final String? directInvitation = entry.value.directInvitation(); + final bool hasCanonicalInvitation = + directInvitation?.startsWith('tp1:') == true; contactsMap[entry.key] = { '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 _saveRoomsFor(String profileId, {bool notify = true}) async { diff --git a/lib/core/rust/flutter.dart b/lib/core/rust/flutter.dart index 4c9e95b6..ccf7f319 100644 --- a/lib/core/rust/flutter.dart +++ b/lib/core/rust/flutter.dart @@ -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 nodeAddr(); + void pauseStatistics(); Future prepareIdentitySwitch( @@ -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 startManager(); Future startScreenshare({required Contact contact}); diff --git a/lib/core/rust/frb_generated.dart b/lib/core/rust/frb_generated.dart index f14ba7a6..272142db 100644 --- a/lib/core/rust/frb_generated.dart +++ b/lib/core/rust/frb_generated.dart @@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 226036739; + int get rustContentHash => 1001975318; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -130,18 +130,24 @@ abstract class RustLibApi extends BaseApi { (bool, bool, double) crateTypesCodecConfigToValues( {required CodecConfig that}); + String? crateTypesContactDirectInvitation({required Contact that}); + Contact crateTypesContactFromParts( {required String id, required String nickname, required String peerId, - required double outputVolume}); + required double outputVolume, + required bool isDirect, + String? directInvitation}); - Future crateTypesContactGetPeerId({required Contact that}); + PublicKey crateTypesContactGetPeerId({required Contact that}); String crateTypesContactId({required Contact that}); bool crateTypesContactIdEq({required Contact that, required List id}); + bool crateTypesContactIsDirect({required Contact that}); + Contact crateTypesContactNew( {required String nickname, required String peerId}); @@ -153,6 +159,12 @@ abstract class RustLibApi extends BaseApi { Contact crateTypesContactPubClone({required Contact that}); + void crateTypesContactSetDirect( + {required Contact that, required bool isDirect}); + + void crateTypesContactSetDirectInvitation( + {required Contact that, String? invitation}); + void crateTypesContactSetNickname( {required Contact that, required String nickname}); @@ -328,6 +340,8 @@ abstract class RustLibApi extends BaseApi { StartOperation crateFlutterTelepathyNewStartOperation( {required Telepathy that}); + Future crateFlutterTelepathyNodeAddr({required Telepathy that}); + void crateFlutterTelepathyPauseStatistics({required Telepathy that}); Future crateFlutterTelepathyPrepareIdentitySwitch( @@ -1010,12 +1024,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that'], ); + @override + String? crateTypesContactDirectInvitation({required Contact that}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( + that, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: null, + ), + constMeta: kCrateTypesContactDirectInvitationConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateTypesContactDirectInvitationConstMeta => + const TaskConstMeta( + debugName: 'Contact_direct_invitation', + argNames: ['that'], + ); + @override Contact crateTypesContactFromParts( {required String id, required String nickname, required String peerId, - required double outputVolume}) { + required double outputVolume, + required bool isDirect, + String? directInvitation}) { return handler.executeSync(SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); @@ -1023,7 +1064,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(nickname, serializer); sse_encode_String(peerId, serializer); sse_encode_f_32(outputVolume, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; + sse_encode_bool(isDirect, serializer); + sse_encode_opt_String(directInvitation, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; }, codec: SseCodec( decodeSuccessData: @@ -1031,25 +1074,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_dart_error, ), constMeta: kCrateTypesContactFromPartsConstMeta, - argValues: [id, nickname, peerId, outputVolume], + argValues: [ + id, + nickname, + peerId, + outputVolume, + isDirect, + directInvitation + ], apiImpl: this, )); } TaskConstMeta get kCrateTypesContactFromPartsConstMeta => const TaskConstMeta( debugName: 'Contact_from_parts', - argNames: ['id', 'nickname', 'peerId', 'outputVolume'], + argNames: [ + 'id', + 'nickname', + 'peerId', + 'outputVolume', + 'isDirect', + 'directInvitation' + ], ); @override - Future crateTypesContactGetPeerId({required Contact that}) { - return handler.executeNormal(NormalTask( - callFfi: (port_) { + PublicKey crateTypesContactGetPeerId({required Contact that}) { + return handler.executeSync(SyncTask( + callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 19, port: port_); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; }, codec: SseCodec( decodeSuccessData: @@ -1074,7 +1130,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1099,7 +1155,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); sse_encode_list_prim_u_8_loose(id, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!; }, codec: SseCodec( decodeSuccessData: sse_decode_bool, @@ -1116,6 +1172,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that', 'id'], ); + @override + bool crateTypesContactIsDirect({required Contact that}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( + that, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateTypesContactIsDirectConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateTypesContactIsDirectConstMeta => const TaskConstMeta( + debugName: 'Contact_is_direct', + argNames: ['that'], + ); + @override Contact crateTypesContactNew( {required String nickname, required String peerId}) { @@ -1124,7 +1204,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(nickname, serializer); sse_encode_String(peerId, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!; }, codec: SseCodec( decodeSuccessData: @@ -1149,7 +1229,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1173,7 +1253,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; }, codec: SseCodec( decodeSuccessData: sse_decode_f_32, @@ -1198,7 +1278,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1222,7 +1302,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; }, codec: SseCodec( decodeSuccessData: @@ -1240,6 +1320,59 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that'], ); + @override + void crateTypesContactSetDirect( + {required Contact that, required bool isDirect}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( + that, serializer); + sse_encode_bool(isDirect, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateTypesContactSetDirectConstMeta, + argValues: [that, isDirect], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateTypesContactSetDirectConstMeta => const TaskConstMeta( + debugName: 'Contact_set_direct', + argNames: ['that', 'isDirect'], + ); + + @override + void crateTypesContactSetDirectInvitation( + {required Contact that, String? invitation}) { + return handler.executeSync(SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( + that, serializer); + sse_encode_opt_String(invitation, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_dart_error, + ), + constMeta: kCrateTypesContactSetDirectInvitationConstMeta, + argValues: [that, invitation], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateTypesContactSetDirectInvitationConstMeta => + const TaskConstMeta( + debugName: 'Contact_set_direct_invitation', + argNames: ['that', 'invitation'], + ); + @override void crateTypesContactSetNickname( {required Contact that, required String nickname}) { @@ -1249,7 +1382,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); sse_encode_String(nickname, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1276,7 +1409,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( that, serializer); sse_encode_f_32(decibel, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1328,7 +1461,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { managerActive, serializer); sse_encode_DartFn_Inputs_record_auto_owned_rust_opaque_flutter_rust_bridgefor_generated_rust_auto_opaque_inner_frontend_notify_bool_Output_unit_AnyhowException( screenshareStarted, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33)!; }, codec: SseCodec( decodeSuccessData: @@ -1374,7 +1507,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerFlutterSoundHandle( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1401,7 +1534,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerFrontendNotify( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 31, port: port_); + funcId: 35, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1426,7 +1559,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerFrontendNotify( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1450,7 +1583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 33, port: port_); + funcId: 37, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -1477,7 +1610,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; }, codec: SseCodec( decodeSuccessData: sse_decode_list_String, @@ -1502,7 +1635,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39)!; }, codec: SseCodec( decodeSuccessData: sse_decode_opt_String, @@ -1528,7 +1661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; }, codec: SseCodec( decodeSuccessData: sse_decode_opt_String, @@ -1553,7 +1686,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 41)!; }, codec: SseCodec( decodeSuccessData: sse_decode_u_16, @@ -1578,7 +1711,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42)!; }, codec: SseCodec( decodeSuccessData: sse_decode_opt_String, @@ -1604,7 +1737,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNetworkConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!; }, codec: SseCodec( decodeSuccessData: sse_decode_opt_list_String, @@ -1639,7 +1772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(dnsEndpoint, serializer); sse_encode_opt_String(dnsOriginDomain, serializer); sse_encode_opt_String(pkarrRelay, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 44)!; }, codec: SseCodec( decodeSuccessData: @@ -1691,7 +1824,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(dnsEndpoint, serializer); sse_encode_opt_String(dnsOriginDomain, serializer); sse_encode_opt_String(pkarrRelay, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 41)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 45)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1731,7 +1864,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 42, port: port_); + funcId: 46, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -1757,7 +1890,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOverlay( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 43, port: port_); + funcId: 47, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1782,7 +1915,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOverlay( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 44, port: port_); + funcId: 48, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1807,7 +1940,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOverlay( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 45, port: port_); + funcId: 49, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1841,7 +1974,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(width, serializer); sse_encode_i_32(height, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 46, port: port_); + funcId: 50, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1881,7 +2014,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(backgroundColor, serializer); sse_encode_u_32(fontColor, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 47, port: port_); + funcId: 51, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -1924,7 +2057,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOverlay( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 48)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!; }, codec: SseCodec( decodeSuccessData: sse_decode_record_i_32_i_32, @@ -1952,7 +2085,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_u_32(backgroundColor, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 49, port: port_); + funcId: 53, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -1980,7 +2113,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_u_32(fontColor, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 50, port: port_); + funcId: 54, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2008,7 +2141,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_i_32(height, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 51, port: port_); + funcId: 55, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2034,7 +2167,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerOverlay( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 52, port: port_); + funcId: 56, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2060,7 +2193,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedIdentitySwitch( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 53, port: port_); + funcId: 57, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2085,7 +2218,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRecordingConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 58)!; }, codec: SseCodec( decodeSuccessData: sse_decode_u_32, @@ -2110,7 +2243,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRecordingConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 59)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2135,7 +2268,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRecordingConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 60)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2160,7 +2293,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRecordingConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 57)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!; }, codec: SseCodec( decodeSuccessData: sse_decode_u_32, @@ -2185,7 +2318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRecordingConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 58)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!; }, codec: SseCodec( decodeSuccessData: sse_decode_opt_box_autoadd_u_32, @@ -2212,7 +2345,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerScreenshareConfig( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 59, port: port_); + funcId: 63, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2237,7 +2370,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 60, port: port_); + funcId: 64, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2264,7 +2397,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(buffer, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 61, port: port_); + funcId: 65, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2292,7 +2425,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerScreenshareConfig( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 62, port: port_); + funcId: 66, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2319,7 +2452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerScreenshareConfig( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 63)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 67)!; }, codec: SseCodec( decodeSuccessData: sse_decode_list_prim_u_8_strict, @@ -2356,7 +2489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(framerate, serializer); sse_encode_opt_box_autoadd_u_32(height, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 64, port: port_); + funcId: 68, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2389,7 +2522,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSoundPlayer( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 65)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!; }, codec: SseCodec( decodeSuccessData: @@ -2413,7 +2546,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_f_32(outputVolume, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 66)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; }, codec: SseCodec( decodeSuccessData: @@ -2441,7 +2574,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_list_prim_u_8_loose(bytes, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 67, port: port_); + funcId: 71, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2469,7 +2602,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_opt_String(deviceId, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 68, port: port_); + funcId: 72, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2496,7 +2629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSoundPlayer( that, serializer); sse_encode_f_32(volume, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 73)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2521,7 +2654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerStartOperation( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 74)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2547,7 +2680,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 71, port: port_); + funcId: 75, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2581,7 +2714,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(text, serializer); sse_encode_list_record_string_list_prim_u_8_strict( attachments, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 72)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 76)!; }, codec: SseCodec( decodeSuccessData: @@ -2608,7 +2741,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 73, port: port_); + funcId: 77, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2640,7 +2773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerStartOperation( operation, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 74, port: port_); + funcId: 78, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2667,7 +2800,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 75, port: port_); + funcId: 79, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2709,7 +2842,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { codecConfig, serializer); sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerFlutterCallbacks( callbacks, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 76)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 80)!; }, codec: SseCodec( decodeSuccessData: @@ -2749,7 +2882,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 77)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 81)!; }, codec: SseCodec( decodeSuccessData: @@ -2768,6 +2901,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that'], ); + @override + Future crateFlutterTelepathyNodeAddr({required Telepathy that}) { + return handler.executeNormal(NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( + that, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, + funcId: 82, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: null, + ), + constMeta: kCrateFlutterTelepathyNodeAddrConstMeta, + argValues: [that], + apiImpl: this, + )); + } + + TaskConstMeta get kCrateFlutterTelepathyNodeAddrConstMeta => + const TaskConstMeta( + debugName: 'Telepathy_node_addr', + argNames: ['that'], + ); + @override void crateFlutterTelepathyPauseStatistics({required Telepathy that}) { return handler.executeSync(SyncTask( @@ -2775,7 +2934,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 78)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 83)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2807,7 +2966,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( targetContacts, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 79, port: port_); + funcId: 84, port: port_); }, codec: SseCodec( decodeSuccessData: @@ -2834,7 +2993,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 80, port: port_); + funcId: 85, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2859,7 +3018,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 81)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 86)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2888,7 +3047,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerChatMessage( message, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 82, port: port_); + funcId: 87, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2916,7 +3075,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( contact, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 83)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 88)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2943,7 +3102,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(deafened, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 84)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 89)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2970,7 +3129,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(denoise, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 85)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 90)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -2997,7 +3156,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(enabled, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 86)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 91)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3025,7 +3184,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_list_prim_u_8_loose(key, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 87, port: port_); + funcId: 92, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3053,7 +3212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_opt_String(deviceId, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 88, port: port_); + funcId: 93, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3080,7 +3239,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_f_32(decibel, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 89)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 94)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3108,7 +3267,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_opt_list_prim_u_8_strict(model, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 90, port: port_); + funcId: 95, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3135,7 +3294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(muted, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 91)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 96)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3163,7 +3322,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { that, serializer); sse_encode_opt_String(deviceId, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 92, port: port_); + funcId: 97, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3190,7 +3349,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_f_32(decibel, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 93)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 98)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3217,7 +3376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(play, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 94)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 99)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3244,7 +3403,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_f_32(decimal, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 95)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 100)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3271,7 +3430,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); sse_encode_bool(send, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 96)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 101)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3297,7 +3456,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 97, port: port_); + funcId: 102, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3330,7 +3489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerStartOperation( operation, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 98, port: port_); + funcId: 103, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3356,7 +3515,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerTelepathy( that, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 99, port: port_); + funcId: 104, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3385,7 +3544,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( contact, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 100, port: port_); + funcId: 105, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3414,7 +3573,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( contact, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 101, port: port_); + funcId: 106, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3443,7 +3602,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerContact( contact, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 102, port: port_); + funcId: 107, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3468,7 +3627,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_StreamSink_String_Sse(s, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 103)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 108)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3492,7 +3651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return handler.executeSync(SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 104)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 109)!; }, codec: SseCodec( decodeSuccessData: sse_decode_record_string_list_prim_u_8_strict, @@ -3517,7 +3676,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(path, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 105, port: port_); + funcId: 110, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3540,7 +3699,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_String(peers, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 106)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 111)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -3562,7 +3721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return handler.executeSync(SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 107)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 112)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -3586,7 +3745,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 108, port: port_); + funcId: 113, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_bool, @@ -3610,7 +3769,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); pdeCallFfi(generalizedFrbRustBinding, serializer, - funcId: 109, port: port_); + funcId: 114, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_statistics, @@ -3634,7 +3793,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(peerId, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 110)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 115)!; }, codec: SseCodec( decodeSuccessData: sse_decode_bool, @@ -6996,8 +7155,12 @@ class ContactImpl extends RustOpaque implements Contact { RustLib.instance.api.rust_arc_decrement_strong_count_ContactPtr, ); - Future getPeerId() => - RustLib.instance.api.crateTypesContactGetPeerId( + String? directInvitation() => + RustLib.instance.api.crateTypesContactDirectInvitation( + that: this, + ); + + PublicKey getPeerId() => RustLib.instance.api.crateTypesContactGetPeerId( that: this, ); @@ -7008,6 +7171,10 @@ class ContactImpl extends RustOpaque implements Contact { bool idEq({required List id}) => RustLib.instance.api.crateTypesContactIdEq(that: this, id: id); + bool isDirect() => RustLib.instance.api.crateTypesContactIsDirect( + that: this, + ); + String nickname() => RustLib.instance.api.crateTypesContactNickname( that: this, ); @@ -7024,6 +7191,14 @@ class ContactImpl extends RustOpaque implements Contact { that: this, ); + void setDirect({required bool isDirect}) => RustLib.instance.api + .crateTypesContactSetDirect(that: this, isDirect: isDirect); + + /// Strictly validates interactive invitation input before changing state. + /// Passing `None` removes the current invitation and disables direct mode. + void setDirectInvitation({String? invitation}) => RustLib.instance.api + .crateTypesContactSetDirectInvitation(that: this, invitation: invitation); + void setNickname({required String nickname}) => RustLib.instance.api .crateTypesContactSetNickname(that: this, nickname: nickname); @@ -7236,7 +7411,7 @@ class OverlayImpl extends RustOpaque implements Overlay { RustLib.instance.api.crateOverlayOverlayMoveOverlay( that: this, x: x, y: y, width: width, height: height); - /// access the screen resolution for overlay positioning in the front end + /// non-windows platforms don't have an overlay (int, int) screenResolution() => RustLib.instance.api.crateOverlayOverlayScreenResolution( that: this, @@ -7544,6 +7719,13 @@ class TelepathyImpl extends RustOpaque implements Telepathy { that: this, ); + /// Returns the local endpoint's opaque `tp1:` direct invitation, or + /// `None` if the session manager is not active. + Future nodeAddr() => + RustLib.instance.api.crateFlutterTelepathyNodeAddr( + that: this, + ); + void pauseStatistics() => RustLib.instance.api.crateFlutterTelepathyPauseStatistics( that: this, @@ -7627,7 +7809,10 @@ class TelepathyImpl extends RustOpaque implements Telepathy { that: this, contact: contact, operation: 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 startManager() => RustLib.instance.api.crateFlutterTelepathyStartManager( that: this, diff --git a/lib/core/rust/overlay.dart b/lib/core/rust/overlay.dart index 8b2280cc..c3114850 100644 --- a/lib/core/rust/overlay.dart +++ b/lib/core/rust/overlay.dart @@ -6,7 +6,7 @@ import 'frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `_disable`, `_enable`, `_hide`, `_move_overlay`, `_show`, `controller`, `redraw`, `start_overlay` +// These functions are ignored because they are not marked as `pub`: `_disable`, `_enable`, `_hide`, `_move_overlay`, `_show` // These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `BACKGROUND_COLOR`, `CONNECTED`, `FONT_COLOR`, `FONT_HEIGHT`, `LATENCY`, `LOSS` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `deref`, `deref`, `deref`, `deref`, `deref`, `deref`, `initialize`, `initialize`, `initialize`, `initialize`, `initialize`, `initialize` @@ -32,6 +32,7 @@ abstract class Overlay implements RustOpaqueInterface { required int height}); // HINT: Make it `#[frb(sync)]` to let it become the default constructor of Dart class. + /// non-windows platforms don't have an overlay static Future newInstance( {required bool enabled, required int x, @@ -51,7 +52,7 @@ abstract class Overlay implements RustOpaqueInterface { backgroundColor: backgroundColor, fontColor: fontColor); - /// access the screen resolution for overlay positioning in the front end + /// non-windows platforms don't have an overlay (int, int) screenResolution(); /// change the background color of the overlay diff --git a/lib/core/rust/types.dart b/lib/core/rust/types.dart index 8336f663..c24fa90e 100644 --- a/lib/core/rust/types.dart +++ b/lib/core/rust/types.dart @@ -9,7 +9,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'types.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `clamp_contact_output_volume`, `contact_output_volume_from_parts`, `contact_output_volume_in_range`, `field_error`, `new`, `parse_bind_addresses`, `poison_field_error`, `relay_map_from_urls`, `serialize_timestamp_rfc3339_utc` +// These functions are ignored because they are not marked as `pub`: `clamp_contact_output_volume`, `contact_output_volume_from_parts`, `contact_output_volume_in_range`, `field_error`, `group_contact`, `new`, `parse_bind_addresses`, `poison_field_error`, `relay_map_from_urls`, `serialize_timestamp_rfc3339_utc` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `minimum_bytes_needed`, `read_from`, `write_to` // Rust type: RustOpaqueMoi> @@ -64,23 +64,33 @@ abstract class CodecConfig implements RustOpaqueInterface { // Rust type: RustOpaqueMoi> abstract class Contact implements RustOpaqueInterface { + String? directInvitation(); + + /// Reconstructs persisted contact data, discarding invalid invitation state + /// without discarding the contact's valid identity and preferences. static Contact fromParts( {required String id, required String nickname, required String peerId, - required double outputVolume}) => + required double outputVolume, + required bool isDirect, + String? directInvitation}) => RustLib.instance.api.crateTypesContactFromParts( id: id, nickname: nickname, peerId: peerId, - outputVolume: outputVolume); + outputVolume: outputVolume, + isDirect: isDirect, + directInvitation: directInvitation); - Future getPeerId(); + PublicKey getPeerId(); String id(); bool idEq({required List id}); + bool isDirect(); + factory Contact({required String nickname, required String peerId}) => RustLib.instance.api .crateTypesContactNew(nickname: nickname, peerId: peerId); @@ -93,6 +103,12 @@ abstract class Contact implements RustOpaqueInterface { Contact pubClone(); + void setDirect({required bool isDirect}); + + /// Strictly validates interactive invitation input before changing state. + /// Passing `None` removes the current invitation and disables direct mode. + void setDirectInvitation({String? invitation}); + void setNickname({required String nickname}); void setOutputVolume({required double decibel}); diff --git a/lib/screens/settings/sections/networking.dart b/lib/screens/settings/sections/networking.dart index c432d076..ed50554d 100644 --- a/lib/screens/settings/sections/networking.dart +++ b/lib/screens/settings/sections/networking.dart @@ -1,6 +1,7 @@ import 'dart:core'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart' hide Overlay; +import 'package:flutter/services.dart' hide TextInput; import 'package:provider/provider.dart'; import 'package:telepathy/controllers/index.dart'; import 'package:telepathy/core/rust/flutter.dart'; @@ -50,6 +51,10 @@ class NetworkSettingsState extends State { final TextEditingController _pkarrRelayInput = TextEditingController(); String? _pkarrRelayError; + // Connection strings + String? _nodeAddr; + bool _nodeAddrLoading = false; + // Critical backend error (e.g. a poisoned lock from the rust // runtime). Not tied to any one field; the rust atomic `update` // collapses every poison failure into `NetworkConfigField.backendError` @@ -131,6 +136,31 @@ class NetworkSettingsState extends State { _savedDnsEndpoint = _dnsEndpoint; _savedDnsOriginDomain = _dnsOriginDomain; _savedPkarrRelay = _pkarrRelay; + + _fetchNodeAddr(); + } + + Future _fetchNodeAddr() async { + if (mounted) setState(() => _nodeAddrLoading = true); + try { + final telepathy = context.read(); + final nodeAddr = await telepathy.nodeAddr(); + if (mounted) setState(() => _nodeAddr = nodeAddr); + } catch (_) { + // A missing manager is an expected inactive state. Keep any existing + // invitation visible if a later refresh fails. + } + if (mounted) setState(() => _nodeAddrLoading = false); + } + + Future _refreshNodeAddrAfterRestart() async { + if (mounted) setState(() => _nodeAddrLoading = true); + try { + final nodeAddr = await context.read().nodeAddr(); + if (mounted) setState(() => _nodeAddr = nodeAddr); + } finally { + if (mounted) setState(() => _nodeAddrLoading = false); + } } @override @@ -245,6 +275,8 @@ class NetworkSettingsState extends State { if (!kIsWeb) _buildDnsSection(width, isRestartSafe), if (!kIsWeb) const SizedBox(height: 8), _buildPkarrSection(width, isRestartSafe), + const SizedBox(height: 16), + _buildDirectInvitationSection(), if (unsavedChanges || _saveSucceeded) const SizedBox(height: 20), if (unsavedChanges) Button( @@ -472,6 +504,48 @@ class NetworkSettingsState extends State { ); } + Widget _buildDirectInvitationSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Direct invitation', + style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text( + 'Copy this invitation and ask recipients to paste it into Add direct contact.', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 8), + if (_nodeAddrLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 3), + ) + else if (_nodeAddr != null) + Button( + text: 'Copy invitation', + onPressed: _copyToClipboard, + ) + else + const Text( + 'Direct invitations are unavailable until the session manager is active.', + style: TextStyle(color: Colors.grey), + ), + ], + ); + } + + Future _copyToClipboard() async { + if (_nodeAddr == null) return; + await Clipboard.setData(ClipboardData(text: _nodeAddr!)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Direct invitation copied to clipboard')), + ); + } + String _formatBindAddresses(List addresses) { return addresses.join(', '); } @@ -854,6 +928,8 @@ class NetworkSettingsState extends State { try { await networkSettingsController.saveNetworkConfig(); await telepathy.restartManager(); + if (!mounted) return; + await _refreshNodeAddrAfterRestart(); } catch (error) { // Persistence or restart failed. The atomic `update` above // already mutated `networkConfig` in memory, so the visible diff --git a/lib/widgets/contacts/contact_form.dart b/lib/widgets/contacts/contact_form.dart index 7d27a3c8..c406d508 100644 --- a/lib/widgets/contacts/contact_form.dart +++ b/lib/widgets/contacts/contact_form.dart @@ -23,6 +23,7 @@ class ContactForm extends StatefulWidget { class ContactFormState extends State { final TextEditingController _nicknameInput = TextEditingController(); final TextEditingController _peerIdInput = TextEditingController(); + final TextEditingController _directConnInput = TextEditingController(); final List _peerIds = []; final FocusNode _nicknameFocusNode = FocusNode(); String? selectedPeer; @@ -31,6 +32,9 @@ class ContactFormState extends State { @override void dispose() { _nicknameFocusNode.dispose(); + _nicknameInput.dispose(); + _peerIdInput.dispose(); + _directConnInput.dispose(); super.dispose(); } @@ -98,6 +102,17 @@ class ContactFormState extends State { labelText: 'Peer ID', hintText: 'string encoded peer ID', obscureText: true), + const SizedBox(height: 12), + TextInput( + controller: _directConnInput, + labelText: 'Direct invitation (optional)', + hintText: 'Paste a tp1: invitation', + ), + const SizedBox(height: 6), + const Text( + 'Paste an invitation to enable direct connection for this contact.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), const SizedBox(height: 26), Center( child: Button( @@ -122,17 +137,28 @@ class ContactFormState extends State { } try { - Contact contact = - profilesController.addContact(nickname, peerId); + final invitation = _directConnInput.text.trim(); + Contact contact = profilesController.addContact( + nickname, + peerId, + directInvitation: invitation.isEmpty ? null : invitation, + ); telepathy.startSession(contact: contact); _nicknameInput.clear(); _peerIdInput.clear(); + _directConnInput.clear(); Navigator.pop(context); - } on DartError catch (_) { + } on DartError { + final invitation = _directConnInput.text.trim(); showErrorDialog( - context, 'Failed to add contact', 'Invalid peer ID'); + context, + 'Failed to add contact', + invitation.isEmpty + ? 'Invalid peer ID' + : 'The direct invitation is invalid or belongs to a different contact. Paste a valid tp1: invitation.', + ); } }, ), diff --git a/lib/widgets/contacts/contact_widget.dart b/lib/widgets/contacts/contact_widget.dart index 1ec5ffc2..2b766bb3 100644 --- a/lib/widgets/contacts/contact_widget.dart +++ b/lib/widgets/contacts/contact_widget.dart @@ -23,11 +23,13 @@ class ContactWidget extends StatefulWidget { class ContactWidgetState extends State { bool isHovered = false; late TextEditingController _nicknameInput; + late TextEditingController _directConnInput; @override void initState() { super.initState(); _nicknameInput = TextEditingController(text: widget.contact.nickname()); + _directConnInput = TextEditingController(); } @override @@ -35,12 +37,14 @@ class ContactWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.contact != oldWidget.contact) { _nicknameInput.text = widget.contact.nickname(); + _directConnInput.clear(); } } @override void dispose() { _nicknameInput.dispose(); + _directConnInput.dispose(); super.dispose(); } @@ -171,6 +175,67 @@ class ContactWidgetState extends State { onChanged: (value) { widget.contact.setNickname(nickname: value); }), + const SizedBox(height: 12), + StatefulBuilder(builder: (context, setLocalState) { + final bool isDirect = widget.contact.isDirect(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isDirect + ? 'Direct connection is enabled' + : 'Use a direct invitation to enable direct connection.', + ), + const SizedBox(height: 12), + if (!isDirect) ...[ + TextInput( + controller: _directConnInput, + labelText: 'Direct invitation', + hintText: 'Paste a tp1: invitation', + ), + const SizedBox(height: 8), + Button( + text: 'Use invitation', + onPressed: () { + final invitation = _directConnInput.text.trim(); + if (invitation.isEmpty) { + showErrorDialog( + context, + 'Direct invitation required', + 'Paste a tp1: invitation to enable direct connection.', + ); + return; + } + try { + widget.contact.setDirectInvitation( + invitation: invitation, + ); + widget.contact.setDirect(isDirect: true); + _directConnInput.clear(); + setLocalState(() {}); + setDialogState(() {}); + } on DartError catch (_) { + showErrorDialog( + context, + 'Invalid direct invitation', + 'This invitation is invalid or belongs to a different contact. Paste a valid tp1: invitation.', + ); + } + }, + ), + ] else + Button( + text: 'Remove direct invitation', + onPressed: () { + widget.contact.setDirectInvitation(); + widget.contact.setDirect(isDirect: false); + setLocalState(() {}); + setDialogState(() {}); + }, + ), + ], + ); + }), const SizedBox(height: 20), const Text('Output Volume', style: TextStyle(fontSize: 15)), Slider( diff --git a/pubspec.lock b/pubspec.lock index 09e7606d..c8f78a50 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -475,26 +475,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: transitive description: @@ -935,10 +935,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" typed_data: dependency: transitive description: @@ -1084,5 +1084,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ce664cbc..626a009b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4381,6 +4381,7 @@ version = "2.8.2" dependencies = [ "async-trait", "atomic_float", + "base64", "bytes", "cfg-if", "chrono", @@ -4399,6 +4400,7 @@ dependencies = [ "ring", "rustls", "serde", + "serde_json", "speedy", "telepathy-audio", "tokio", diff --git a/rust/telepathy-cli/src/runner.rs b/rust/telepathy-cli/src/runner.rs index 8136faf1..8c0db95a 100644 --- a/rust/telepathy-cli/src/runner.rs +++ b/rust/telepathy-cli/src/runner.rs @@ -226,7 +226,7 @@ where id, nickname, peer_id, - } => match Contact::from_parts(id.clone(), nickname, peer_id, 0_f32) { + } => match Contact::from_parts(id.clone(), nickname, peer_id, 0_f32, false, None) { Ok(contact) => { hub.contacts.write().await.insert(id, contact); CommandOutcome::AckOk diff --git a/rust/telepathy-core/Cargo.toml b/rust/telepathy-core/Cargo.toml index 84864e8e..363bb63d 100644 --- a/rust/telepathy-core/Cargo.toml +++ b/rust/telepathy-core/Cargo.toml @@ -18,6 +18,7 @@ default = ["flutter"] [dependencies] atomic_float = "1" async-trait = { version = "0.1", optional = true } +base64 = "0.22" bytes = "1" chrono = "*" cfg-if = "1" @@ -28,6 +29,7 @@ lazy_static = "1" mockall = { version = "0.15", optional = true } regex = "1" # TODO consider removing regex dep serde = { version = "1", features = ["derive"] } +serde_json = "1" speedy = "=0.8.7" telepathy-audio = { path = "../telepathy-audio" } tracing = "0.1" diff --git a/rust/telepathy-core/src/direct_invitation.rs b/rust/telepathy-core/src/direct_invitation.rs new file mode 100644 index 00000000..2f9203c1 --- /dev/null +++ b/rust/telepathy-core/src/direct_invitation.rs @@ -0,0 +1,322 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use iroh::{PublicKey, TransportAddr}; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +const PREFIX: &str = "tp1:"; +const VERSION: u8 = 1; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum DirectInvitationError { + MissingInvitation, + InvalidPrefix, + InvalidBase64, + InvalidPayload, + UnsupportedVersion, + EmptyAddresses, + RelayAddresses, + PeerMismatch, + NonCanonical, +} + +impl Display for DirectInvitationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::MissingInvitation => "A direct invitation is required", + Self::InvalidPrefix => "Direct invitation must start with tp1:", + Self::InvalidBase64 + | Self::InvalidPayload + | Self::EmptyAddresses + | Self::RelayAddresses => "Direct invitation is malformed", + Self::UnsupportedVersion => "Direct invitation version is not supported", + Self::PeerMismatch => "Direct invitation belongs to a different contact", + Self::NonCanonical => "Direct invitation is not canonical", + }) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct DirectInvitationPayload { + version: u8, + peer_id: String, + addresses: Vec, +} + +pub(crate) fn encode(peer_id: PublicKey, addresses: Vec) -> Option { + let addresses: Vec<_> = addresses + .into_iter() + .filter(|address| !address.is_relay()) + .collect(); + if addresses.is_empty() { + return None; + } + + let payload = DirectInvitationPayload { + version: VERSION, + peer_id: peer_id.to_string(), + addresses, + }; + let bytes = serde_json::to_vec(&payload).expect("direct invitation payload is serializable"); + Some(format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))) +} + +pub(crate) fn decode_for_peer( + invitation: &str, + expected_peer: PublicKey, +) -> Result, DirectInvitationError> { + let encoded = invitation + .strip_prefix(PREFIX) + .ok_or(DirectInvitationError::InvalidPrefix)?; + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| DirectInvitationError::InvalidBase64)?; + let payload: DirectInvitationPayload = + serde_json::from_slice(&bytes).map_err(|_| DirectInvitationError::InvalidPayload)?; + + if payload.version != VERSION { + return Err(DirectInvitationError::UnsupportedVersion); + } + if payload.addresses.is_empty() { + return Err(DirectInvitationError::EmptyAddresses); + } + if payload.addresses.iter().any(TransportAddr::is_relay) { + return Err(DirectInvitationError::RelayAddresses); + } + let peer_id = + PublicKey::from_str(&payload.peer_id).map_err(|_| DirectInvitationError::InvalidPayload)?; + if peer_id != expected_peer { + return Err(DirectInvitationError::PeerMismatch); + } + + let canonical = encode(peer_id, payload.addresses.clone()) + .expect("validated direct invitation has addresses"); + if invitation != canonical { + return Err(DirectInvitationError::NonCanonical); + } + + Ok(payload.addresses) +} + +pub(crate) fn canonicalize_for_peer(invitation: &str, expected_peer: PublicKey) -> Option { + if decode_for_peer(invitation, expected_peer).is_ok() { + return Some(invitation.to_string()); + } + + let legacy_addresses: Vec = serde_json::from_str(invitation).ok()?; + if legacy_addresses.iter().any(TransportAddr::is_relay) { + return None; + } + encode(expected_peer, legacy_addresses) +} + +#[cfg(test)] +mod tests { + use super::{ + DirectInvitationError, DirectInvitationPayload, PREFIX, VERSION, canonicalize_for_peer, + decode_for_peer, encode, + }; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use iroh::{SecretKey, TransportAddr}; + use std::net::{Ipv4Addr, SocketAddr}; + use std::str::FromStr; + + fn addresses() -> Vec { + vec![TransportAddr::Ip(SocketAddr::from(( + Ipv4Addr::LOCALHOST, + 40142, + )))] + } + + fn relay_address() -> TransportAddr { + TransportAddr::Relay( + iroh::RelayUrl::from_str("https://relay.example/") + .expect("test relay URL should parse"), + ) + } + + fn token_for(payload: &DirectInvitationPayload) -> String { + let bytes = serde_json::to_vec(payload).expect("test payload should serialize"); + format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) + } + + #[test] + fn valid_invitation_round_trips_canonically() { + let peer = SecretKey::generate().public(); + let invitation = encode(peer, addresses()).expect("nonempty addresses should encode"); + + assert!(invitation.starts_with(PREFIX)); + assert!(!invitation.contains('=')); + assert_eq!(decode_for_peer(&invitation, peer), Ok(addresses())); + assert_eq!( + encode(peer, addresses()).as_deref(), + Some(invitation.as_str()) + ); + } + + #[test] + fn export_removes_relays_but_preserves_direct_addresses() { + let peer = SecretKey::generate().public(); + let mut mixed = addresses(); + mixed.push(relay_address()); + + let invitation = encode(peer, mixed).expect("direct address should remain exportable"); + + assert_eq!(decode_for_peer(&invitation, peer), Ok(addresses())); + } + + #[test] + fn relay_only_export_has_no_invitation() { + let peer = SecretKey::generate().public(); + + assert_eq!(encode(peer, vec![relay_address()]), None); + } + + #[test] + fn malformed_invitations_are_rejected() { + let peer = SecretKey::generate().public(); + + assert_eq!( + decode_for_peer("not-an-invitation", peer), + Err(DirectInvitationError::InvalidPrefix) + ); + assert_eq!( + decode_for_peer("tp1:not+url/base64", peer), + Err(DirectInvitationError::InvalidBase64) + ); + let malformed_payload = format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(b"not json")); + assert_eq!( + decode_for_peer(&malformed_payload, peer), + Err(DirectInvitationError::InvalidPayload) + ); + + let padded = format!( + "{}=", + encode(peer, addresses()).expect("nonempty addresses should encode") + ); + assert!(decode_for_peer(&padded, peer).is_err()); + } + + #[test] + fn semantically_valid_noncanonical_payload_is_rejected() { + let peer = SecretKey::generate().public(); + let canonical = encode(peer, addresses()).expect("nonempty addresses should encode"); + let encoded = canonical.strip_prefix(PREFIX).expect("encoder adds prefix"); + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .expect("encoder emits base64"); + let payload: serde_json::Value = + serde_json::from_slice(&bytes).expect("encoder emits JSON"); + let reordered = serde_json::json!({ + "addresses": payload["addresses"], + "peer_id": payload["peer_id"], + "version": payload["version"], + }); + let invitation = format!( + "{PREFIX}{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&reordered).expect("test JSON serializes")) + ); + + assert_eq!( + decode_for_peer(&invitation, peer), + Err(DirectInvitationError::NonCanonical) + ); + } + + #[test] + fn unsupported_payload_version_is_rejected() { + let peer = SecretKey::generate().public(); + let invitation = token_for(&DirectInvitationPayload { + version: 2, + peer_id: peer.to_string(), + addresses: addresses(), + }); + + assert_eq!( + decode_for_peer(&invitation, peer), + Err(DirectInvitationError::UnsupportedVersion) + ); + } + + #[test] + fn empty_address_list_is_rejected() { + let peer = SecretKey::generate().public(); + let invitation = token_for(&DirectInvitationPayload { + version: 1, + peer_id: peer.to_string(), + addresses: Vec::new(), + }); + + assert_eq!( + decode_for_peer(&invitation, peer), + Err(DirectInvitationError::EmptyAddresses) + ); + assert_eq!(encode(peer, Vec::new()), None); + } + + #[test] + fn relay_bearing_token_is_rejected_before_canonicality() { + let peer = SecretKey::generate().public(); + let invitation = token_for(&DirectInvitationPayload { + version: VERSION, + peer_id: peer.to_string(), + addresses: vec![relay_address()], + }); + + assert_eq!( + decode_for_peer(&invitation, peer), + Err(DirectInvitationError::RelayAddresses) + ); + } + + #[test] + fn contact_peer_mismatch_is_rejected() { + let invited_peer = SecretKey::generate().public(); + let contact_peer = SecretKey::generate().public(); + let invitation = + encode(invited_peer, addresses()).expect("nonempty addresses should encode"); + + assert_eq!( + decode_for_peer(&invitation, contact_peer), + Err(DirectInvitationError::PeerMismatch) + ); + } + + #[test] + fn legacy_address_json_migrates_to_a_canonical_invitation() { + let peer = SecretKey::generate().public(); + let legacy = serde_json::to_string(&addresses()).expect("legacy addresses serialize"); + + let invitation = + canonicalize_for_peer(&legacy, peer).expect("valid legacy addresses should migrate"); + + assert!(invitation.starts_with(PREFIX)); + assert_eq!(decode_for_peer(&invitation, peer), Ok(addresses())); + } + + #[test] + fn legacy_relay_address_json_is_rejected() { + let peer = SecretKey::generate().public(); + let legacy = serde_json::to_string(&vec![relay_address()]).expect("legacy serializes"); + + assert_eq!(canonicalize_for_peer(&legacy, peer), None); + } + + #[test] + fn malformed_and_peer_mismatched_values_do_not_normalize() { + let peer = SecretKey::generate().public(); + let other_peer = SecretKey::generate().public(); + + assert_eq!(canonicalize_for_peer("not-json", peer), None); + assert_eq!( + canonicalize_for_peer( + &encode(other_peer, addresses()).expect("test invitation encodes"), + peer, + ), + None, + ); + } +} diff --git a/rust/telepathy-core/src/flutter.rs b/rust/telepathy-core/src/flutter.rs index 6a8c4903..0c1644cd 100644 --- a/rust/telepathy-core/src/flutter.rs +++ b/rust/telepathy-core/src/flutter.rs @@ -282,6 +282,12 @@ impl Telepathy { pub async fn set_model(&self, model: Option>) -> Result<(), DartError> { self.handle.set_model(model).await.map_err(DartError::from) } + + /// Returns the local endpoint's opaque `tp1:` direct invitation, or + /// `None` if the session manager is not active. + pub async fn node_addr(&self) -> Option { + self.handle.node_addr().await + } } #[frb(opaque)] diff --git a/rust/telepathy-core/src/frb_generated.rs b/rust/telepathy-core/src/frb_generated.rs index 7b1f1bd4..70aaa95c 100644 --- a/rust/telepathy-core/src/frb_generated.rs +++ b/rust/telepathy-core/src/frb_generated.rs @@ -43,7 +43,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 226036739; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1001975318; // Section: executor @@ -828,6 +828,54 @@ fn wire__crate__types__CodecConfig_to_values_impl( }, ) } +fn wire__crate__types__Contact_direct_invitation_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "Contact_direct_invitation", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok(crate::types::Contact::direct_invitation( + &*api_that_guard, + ))?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__types__Contact_from_parts_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -853,6 +901,8 @@ fn wire__crate__types__Contact_from_parts_impl( let api_nickname = ::sse_decode(&mut deserializer); let api_peer_id = ::sse_decode(&mut deserializer); let api_output_volume = ::sse_decode(&mut deserializer); + let api_is_direct = ::sse_decode(&mut deserializer); + let api_direct_invitation = >::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, crate::types::DartError>((move || { let output_ok = crate::types::Contact::from_parts( @@ -860,6 +910,8 @@ fn wire__crate__types__Contact_from_parts_impl( api_nickname, api_peer_id, api_output_volume, + api_is_direct, + api_direct_invitation, )?; Ok(output_ok) })()) @@ -867,16 +919,15 @@ fn wire__crate__types__Contact_from_parts_impl( ) } fn wire__crate__types__Contact_get_peer_id_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( flutter_rust_bridge::for_generated::TaskInfo { debug_name: "Contact_get_peer_id", - port: Some(port_), - mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, }, move || { let message = unsafe { @@ -892,27 +943,25 @@ fn wire__crate__types__Contact_get_peer_id_impl( flutter_rust_bridge::for_generated::RustAutoOpaqueInner, >>::sse_decode(&mut deserializer); deserializer.end(); - move |context| { - transform_result_sse::<_, ()>((move || { - let mut api_that_guard = None; - let decode_indices_ = - flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ - flutter_rust_bridge::for_generated::LockableOrderInfo::new( - &api_that, 0, false, - ), - ]); - for i in decode_indices_ { - match i { - 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), - _ => unreachable!(), - } + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), } - let api_that_guard = api_that_guard.unwrap(); - let output_ok = - Result::<_, ()>::Ok(crate::types::Contact::get_peer_id(&*api_that_guard))?; - Ok(output_ok) - })()) - } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + Result::<_, ()>::Ok(crate::types::Contact::get_peer_id(&*api_that_guard))?; + Ok(output_ok) + })()) }, ) } @@ -1010,6 +1059,53 @@ fn wire__crate__types__Contact_id_eq_impl( }, ) } +fn wire__crate__types__Contact_is_direct_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "Contact_is_direct", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + Result::<_, ()>::Ok(crate::types::Contact::is_direct(&*api_that_guard))?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__types__Contact_new_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -1229,6 +1325,105 @@ fn wire__crate__types__Contact_pub_clone_impl( }, ) } +fn wire__crate__types__Contact_set_direct_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "Contact_set_direct", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_is_direct = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, true, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref_mut()), + _ => unreachable!(), + } + } + let mut api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::types::Contact::set_direct(&mut *api_that_guard, api_is_direct); + })?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__types__Contact_set_direct_invitation_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "Contact_set_direct_invitation", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_invitation = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, crate::types::DartError>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, true, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref_mut()), + _ => unreachable!(), + } + } + let mut api_that_guard = api_that_guard.unwrap(); + let output_ok = crate::types::Contact::set_direct_invitation( + &mut *api_that_guard, + api_invitation, + )?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__types__Contact_set_nickname_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -2196,28 +2391,28 @@ fn wire__crate__overlay__Overlay_new_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); - let api_enabled = ::sse_decode(&mut deserializer); - let api_x = ::sse_decode(&mut deserializer); - let api_y = ::sse_decode(&mut deserializer); - let api_width = ::sse_decode(&mut deserializer); - let api_height = ::sse_decode(&mut deserializer); - let api_font_height = ::sse_decode(&mut deserializer); - let api_background_color = ::sse_decode(&mut deserializer); - let api_font_color = ::sse_decode(&mut deserializer); + let api__enabled = ::sse_decode(&mut deserializer); + let api__x = ::sse_decode(&mut deserializer); + let api__y = ::sse_decode(&mut deserializer); + let api__width = ::sse_decode(&mut deserializer); + let api__height = ::sse_decode(&mut deserializer); + let api__font_height = ::sse_decode(&mut deserializer); + let api__background_color = ::sse_decode(&mut deserializer); + let api__font_color = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, ()>( (move || async move { let output_ok = Result::<_, ()>::Ok( crate::overlay::Overlay::new( - api_enabled, - api_x, - api_y, - api_width, - api_height, - api_font_height, - api_background_color, - api_font_color, + api__enabled, + api__x, + api__y, + api__width, + api__height, + api__font_height, + api__background_color, + api__font_color, ) .await, )?; @@ -3824,6 +4019,63 @@ fn wire__crate__flutter__Telepathy_new_start_operation_impl( }, ) } +fn wire__crate__flutter__Telepathy_node_addr_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "Telepathy_node_addr", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok( + crate::flutter::Telepathy::node_addr(&*api_that_guard).await, + )?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__flutter__Telepathy_pause_statistics_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -6691,103 +6943,105 @@ fn pde_ffi_dispatcher_primary_impl( match func_id { 1 => wire__crate__types__Capabilities_default_impl(port, ptr, rust_vec_len, data_len), 12 => wire__crate__types__CodecConfig_default_impl(port, ptr, rust_vec_len, data_len), - 19 => wire__crate__types__Contact_get_peer_id_impl(port, ptr, rust_vec_len, data_len), - 31 => wire__crate__types__FrontendNotify_notified_impl(port, ptr, rust_vec_len, data_len), - 33 => wire__crate__types__NetworkConfig_default_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__overlay__Overlay_default_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__overlay__Overlay_disable_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__overlay__Overlay_enable_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__overlay__Overlay_hide_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__overlay__Overlay_move_overlay_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__overlay__Overlay_new_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__overlay__Overlay_set_background_color_impl( + 35 => wire__crate__types__FrontendNotify_notified_impl(port, ptr, rust_vec_len, data_len), + 37 => wire__crate__types__NetworkConfig_default_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__overlay__Overlay_default_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__overlay__Overlay_disable_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__overlay__Overlay_enable_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__overlay__Overlay_hide_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__overlay__Overlay_move_overlay_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__overlay__Overlay_new_impl(port, ptr, rust_vec_len, data_len), + 53 => wire__crate__overlay__Overlay_set_background_color_impl( port, ptr, rust_vec_len, data_len, ), - 50 => wire__crate__overlay__Overlay_set_font_color_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__overlay__Overlay_set_font_height_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__overlay__Overlay_show_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__flutter__PreparedIdentitySwitch_commit_impl( + 54 => wire__crate__overlay__Overlay_set_font_color_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__overlay__Overlay_set_font_height_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__overlay__Overlay_show_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__flutter__PreparedIdentitySwitch_commit_impl( port, ptr, rust_vec_len, data_len, ), - 59 => wire__crate__types__ScreenshareConfig_capabilities_impl( + 63 => wire__crate__types__ScreenshareConfig_capabilities_impl( port, ptr, rust_vec_len, data_len, ), - 60 => wire__crate__types__ScreenshareConfig_default_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__types__ScreenshareConfig_new_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__types__ScreenshareConfig_recording_config_impl( + 64 => wire__crate__types__ScreenshareConfig_default_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__types__ScreenshareConfig_new_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__types__ScreenshareConfig_recording_config_impl( port, ptr, rust_vec_len, data_len, ), - 64 => wire__crate__types__ScreenshareConfig_update_recording_config_impl( + 68 => wire__crate__types__ScreenshareConfig_update_recording_config_impl( port, ptr, rust_vec_len, data_len, ), - 67 => wire__crate__player__SoundPlayer_play_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__player__SoundPlayer_update_output_device_impl( + 71 => wire__crate__player__SoundPlayer_play_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__player__SoundPlayer_update_output_device_impl( port, ptr, rust_vec_len, data_len, ), - 71 => wire__crate__flutter__Telepathy_audio_test_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__flutter__Telepathy_end_call_impl(port, ptr, rust_vec_len, data_len), - 74 => wire__crate__flutter__Telepathy_join_room_impl(port, ptr, rust_vec_len, data_len), - 75 => wire__crate__flutter__Telepathy_list_devices_impl(port, ptr, rust_vec_len, data_len), - 79 => wire__crate__flutter__Telepathy_prepare_identity_switch_impl( + 75 => wire__crate__flutter__Telepathy_audio_test_impl(port, ptr, rust_vec_len, data_len), + 77 => wire__crate__flutter__Telepathy_end_call_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__flutter__Telepathy_join_room_impl(port, ptr, rust_vec_len, data_len), + 79 => wire__crate__flutter__Telepathy_list_devices_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__flutter__Telepathy_node_addr_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__flutter__Telepathy_prepare_identity_switch_impl( port, ptr, rust_vec_len, data_len, ), - 80 => { + 85 => { wire__crate__flutter__Telepathy_restart_manager_impl(port, ptr, rust_vec_len, data_len) } - 82 => wire__crate__flutter__Telepathy_send_chat_impl(port, ptr, rust_vec_len, data_len), - 87 => wire__crate__flutter__Telepathy_set_identity_impl(port, ptr, rust_vec_len, data_len), - 88 => { + 87 => wire__crate__flutter__Telepathy_send_chat_impl(port, ptr, rust_vec_len, data_len), + 92 => wire__crate__flutter__Telepathy_set_identity_impl(port, ptr, rust_vec_len, data_len), + 93 => { wire__crate__flutter__Telepathy_set_input_device_impl(port, ptr, rust_vec_len, data_len) } - 90 => wire__crate__flutter__Telepathy_set_model_impl(port, ptr, rust_vec_len, data_len), - 92 => wire__crate__flutter__Telepathy_set_output_device_impl( + 95 => wire__crate__flutter__Telepathy_set_model_impl(port, ptr, rust_vec_len, data_len), + 97 => wire__crate__flutter__Telepathy_set_output_device_impl( port, ptr, rust_vec_len, data_len, ), - 97 => wire__crate__flutter__Telepathy_shutdown_impl(port, ptr, rust_vec_len, data_len), - 98 => wire__crate__flutter__Telepathy_start_call_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__flutter__Telepathy_start_manager_impl(port, ptr, rust_vec_len, data_len), - 100 => wire__crate__flutter__Telepathy_start_screenshare_impl( + 102 => wire__crate__flutter__Telepathy_shutdown_impl(port, ptr, rust_vec_len, data_len), + 103 => wire__crate__flutter__Telepathy_start_call_impl(port, ptr, rust_vec_len, data_len), + 104 => { + wire__crate__flutter__Telepathy_start_manager_impl(port, ptr, rust_vec_len, data_len) + } + 105 => wire__crate__flutter__Telepathy_start_screenshare_impl( port, ptr, rust_vec_len, data_len, ), - 101 => { + 106 => { wire__crate__flutter__Telepathy_start_session_impl(port, ptr, rust_vec_len, data_len) } - 102 => wire__crate__flutter__Telepathy_stop_session_impl(port, ptr, rust_vec_len, data_len), - 105 => wire__crate__player__load_ringtone_impl(port, ptr, rust_vec_len, data_len), - 108 => wire__crate__flutter__utils__screenshare_available_impl( + 107 => wire__crate__flutter__Telepathy_stop_session_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__player__load_ringtone_impl(port, ptr, rust_vec_len, data_len), + 113 => wire__crate__flutter__utils__screenshare_available_impl( port, ptr, rust_vec_len, data_len, ), - 109 => wire__crate__types__statistics_default_impl(port, ptr, rust_vec_len, data_len), + 114 => wire__crate__types__statistics_default_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6827,78 +7081,83 @@ fn pde_ffi_dispatcher_sync_impl( 15 => wire__crate__types__CodecConfig_set_residual_bits_impl(ptr, rust_vec_len, data_len), 16 => wire__crate__types__CodecConfig_set_vbr_impl(ptr, rust_vec_len, data_len), 17 => wire__crate__types__CodecConfig_to_values_impl(ptr, rust_vec_len, data_len), - 18 => wire__crate__types__Contact_from_parts_impl(ptr, rust_vec_len, data_len), - 20 => wire__crate__types__Contact_id_impl(ptr, rust_vec_len, data_len), - 21 => wire__crate__types__Contact_id_eq_impl(ptr, rust_vec_len, data_len), - 22 => wire__crate__types__Contact_new_impl(ptr, rust_vec_len, data_len), - 23 => wire__crate__types__Contact_nickname_impl(ptr, rust_vec_len, data_len), - 24 => wire__crate__types__Contact_output_volume_impl(ptr, rust_vec_len, data_len), - 25 => wire__crate__types__Contact_peer_id_impl(ptr, rust_vec_len, data_len), - 26 => wire__crate__types__Contact_pub_clone_impl(ptr, rust_vec_len, data_len), - 27 => wire__crate__types__Contact_set_nickname_impl(ptr, rust_vec_len, data_len), - 28 => wire__crate__types__Contact_set_output_volume_impl(ptr, rust_vec_len, data_len), - 29 => wire__crate__flutter__FlutterCallbacks_new_impl(ptr, rust_vec_len, data_len), - 30 => wire__crate__player__FlutterSoundHandle_cancel_impl(ptr, rust_vec_len, data_len), - 32 => wire__crate__types__FrontendNotify_notify_impl(ptr, rust_vec_len, data_len), - 34 => { + 18 => wire__crate__types__Contact_direct_invitation_impl(ptr, rust_vec_len, data_len), + 19 => wire__crate__types__Contact_from_parts_impl(ptr, rust_vec_len, data_len), + 20 => wire__crate__types__Contact_get_peer_id_impl(ptr, rust_vec_len, data_len), + 21 => wire__crate__types__Contact_id_impl(ptr, rust_vec_len, data_len), + 22 => wire__crate__types__Contact_id_eq_impl(ptr, rust_vec_len, data_len), + 23 => wire__crate__types__Contact_is_direct_impl(ptr, rust_vec_len, data_len), + 24 => wire__crate__types__Contact_new_impl(ptr, rust_vec_len, data_len), + 25 => wire__crate__types__Contact_nickname_impl(ptr, rust_vec_len, data_len), + 26 => wire__crate__types__Contact_output_volume_impl(ptr, rust_vec_len, data_len), + 27 => wire__crate__types__Contact_peer_id_impl(ptr, rust_vec_len, data_len), + 28 => wire__crate__types__Contact_pub_clone_impl(ptr, rust_vec_len, data_len), + 29 => wire__crate__types__Contact_set_direct_impl(ptr, rust_vec_len, data_len), + 30 => wire__crate__types__Contact_set_direct_invitation_impl(ptr, rust_vec_len, data_len), + 31 => wire__crate__types__Contact_set_nickname_impl(ptr, rust_vec_len, data_len), + 32 => wire__crate__types__Contact_set_output_volume_impl(ptr, rust_vec_len, data_len), + 33 => wire__crate__flutter__FlutterCallbacks_new_impl(ptr, rust_vec_len, data_len), + 34 => wire__crate__player__FlutterSoundHandle_cancel_impl(ptr, rust_vec_len, data_len), + 36 => wire__crate__types__FrontendNotify_notify_impl(ptr, rust_vec_len, data_len), + 38 => { wire__crate__types__NetworkConfig_get_bind_addresses_impl(ptr, rust_vec_len, data_len) } - 35 => wire__crate__types__NetworkConfig_get_dns_endpoint_impl(ptr, rust_vec_len, data_len), - 36 => wire__crate__types__NetworkConfig_get_dns_origin_domain_impl( + 39 => wire__crate__types__NetworkConfig_get_dns_endpoint_impl(ptr, rust_vec_len, data_len), + 40 => wire__crate__types__NetworkConfig_get_dns_origin_domain_impl( ptr, rust_vec_len, data_len, ), - 37 => wire__crate__types__NetworkConfig_get_listen_port_impl(ptr, rust_vec_len, data_len), - 38 => wire__crate__types__NetworkConfig_get_pkarr_relay_impl(ptr, rust_vec_len, data_len), - 39 => wire__crate__types__NetworkConfig_get_relays_impl(ptr, rust_vec_len, data_len), - 40 => wire__crate__types__NetworkConfig_new_impl(ptr, rust_vec_len, data_len), - 41 => wire__crate__types__NetworkConfig_update_impl(ptr, rust_vec_len, data_len), - 48 => wire__crate__overlay__Overlay_screen_resolution_impl(ptr, rust_vec_len, data_len), - 54 => wire__crate__types__RecordingConfig_bitrate_impl(ptr, rust_vec_len, data_len), - 55 => wire__crate__types__RecordingConfig_device_impl(ptr, rust_vec_len, data_len), - 56 => wire__crate__types__RecordingConfig_encoder_impl(ptr, rust_vec_len, data_len), - 57 => wire__crate__types__RecordingConfig_framerate_impl(ptr, rust_vec_len, data_len), - 58 => wire__crate__types__RecordingConfig_height_impl(ptr, rust_vec_len, data_len), - 63 => wire__crate__types__ScreenshareConfig_to_bytes_impl(ptr, rust_vec_len, data_len), - 65 => wire__crate__player__SoundPlayer_host_impl(ptr, rust_vec_len, data_len), - 66 => wire__crate__player__SoundPlayer_new_impl(ptr, rust_vec_len, data_len), - 69 => { + 41 => wire__crate__types__NetworkConfig_get_listen_port_impl(ptr, rust_vec_len, data_len), + 42 => wire__crate__types__NetworkConfig_get_pkarr_relay_impl(ptr, rust_vec_len, data_len), + 43 => wire__crate__types__NetworkConfig_get_relays_impl(ptr, rust_vec_len, data_len), + 44 => wire__crate__types__NetworkConfig_new_impl(ptr, rust_vec_len, data_len), + 45 => wire__crate__types__NetworkConfig_update_impl(ptr, rust_vec_len, data_len), + 52 => wire__crate__overlay__Overlay_screen_resolution_impl(ptr, rust_vec_len, data_len), + 58 => wire__crate__types__RecordingConfig_bitrate_impl(ptr, rust_vec_len, data_len), + 59 => wire__crate__types__RecordingConfig_device_impl(ptr, rust_vec_len, data_len), + 60 => wire__crate__types__RecordingConfig_encoder_impl(ptr, rust_vec_len, data_len), + 61 => wire__crate__types__RecordingConfig_framerate_impl(ptr, rust_vec_len, data_len), + 62 => wire__crate__types__RecordingConfig_height_impl(ptr, rust_vec_len, data_len), + 67 => wire__crate__types__ScreenshareConfig_to_bytes_impl(ptr, rust_vec_len, data_len), + 69 => wire__crate__player__SoundPlayer_host_impl(ptr, rust_vec_len, data_len), + 70 => wire__crate__player__SoundPlayer_new_impl(ptr, rust_vec_len, data_len), + 73 => { wire__crate__player__SoundPlayer_update_output_volume_impl(ptr, rust_vec_len, data_len) } - 70 => wire__crate__flutter__StartOperation_cancel_impl(ptr, rust_vec_len, data_len), - 72 => wire__crate__flutter__Telepathy_build_chat_impl(ptr, rust_vec_len, data_len), - 76 => wire__crate__flutter__Telepathy_new_impl(ptr, rust_vec_len, data_len), - 77 => wire__crate__flutter__Telepathy_new_start_operation_impl(ptr, rust_vec_len, data_len), - 78 => wire__crate__flutter__Telepathy_pause_statistics_impl(ptr, rust_vec_len, data_len), - 81 => wire__crate__flutter__Telepathy_resume_statistics_impl(ptr, rust_vec_len, data_len), - 83 => wire__crate__flutter__Telepathy_set_contact_output_volume_impl( + 74 => wire__crate__flutter__StartOperation_cancel_impl(ptr, rust_vec_len, data_len), + 76 => wire__crate__flutter__Telepathy_build_chat_impl(ptr, rust_vec_len, data_len), + 80 => wire__crate__flutter__Telepathy_new_impl(ptr, rust_vec_len, data_len), + 81 => wire__crate__flutter__Telepathy_new_start_operation_impl(ptr, rust_vec_len, data_len), + 83 => wire__crate__flutter__Telepathy_pause_statistics_impl(ptr, rust_vec_len, data_len), + 86 => wire__crate__flutter__Telepathy_resume_statistics_impl(ptr, rust_vec_len, data_len), + 88 => wire__crate__flutter__Telepathy_set_contact_output_volume_impl( ptr, rust_vec_len, data_len, ), - 84 => wire__crate__flutter__Telepathy_set_deafened_impl(ptr, rust_vec_len, data_len), - 85 => wire__crate__flutter__Telepathy_set_denoise_impl(ptr, rust_vec_len, data_len), - 86 => wire__crate__flutter__Telepathy_set_efficiency_mode_impl(ptr, rust_vec_len, data_len), - 89 => wire__crate__flutter__Telepathy_set_input_volume_impl(ptr, rust_vec_len, data_len), - 91 => wire__crate__flutter__Telepathy_set_muted_impl(ptr, rust_vec_len, data_len), - 93 => wire__crate__flutter__Telepathy_set_output_volume_impl(ptr, rust_vec_len, data_len), - 94 => wire__crate__flutter__Telepathy_set_play_custom_ringtones_impl( + 89 => wire__crate__flutter__Telepathy_set_deafened_impl(ptr, rust_vec_len, data_len), + 90 => wire__crate__flutter__Telepathy_set_denoise_impl(ptr, rust_vec_len, data_len), + 91 => wire__crate__flutter__Telepathy_set_efficiency_mode_impl(ptr, rust_vec_len, data_len), + 94 => wire__crate__flutter__Telepathy_set_input_volume_impl(ptr, rust_vec_len, data_len), + 96 => wire__crate__flutter__Telepathy_set_muted_impl(ptr, rust_vec_len, data_len), + 98 => wire__crate__flutter__Telepathy_set_output_volume_impl(ptr, rust_vec_len, data_len), + 99 => wire__crate__flutter__Telepathy_set_play_custom_ringtones_impl( ptr, rust_vec_len, data_len, ), - 95 => wire__crate__flutter__Telepathy_set_rms_threshold_impl(ptr, rust_vec_len, data_len), - 96 => wire__crate__flutter__Telepathy_set_send_custom_ringtone_impl( + 100 => wire__crate__flutter__Telepathy_set_rms_threshold_impl(ptr, rust_vec_len, data_len), + 101 => wire__crate__flutter__Telepathy_set_send_custom_ringtone_impl( ptr, rust_vec_len, data_len, ), - 103 => wire__crate__flutter__logging__create_log_stream_impl(ptr, rust_vec_len, data_len), - 104 => wire__crate__flutter__utils__generate_keys_impl(ptr, rust_vec_len, data_len), - 106 => wire__crate__flutter__utils__room_hash_impl(ptr, rust_vec_len, data_len), - 107 => wire__crate__flutter__logging__rust_set_up_impl(ptr, rust_vec_len, data_len), - 110 => wire__crate__flutter__utils__validate_peer_id_impl(ptr, rust_vec_len, data_len), + 108 => wire__crate__flutter__logging__create_log_stream_impl(ptr, rust_vec_len, data_len), + 109 => wire__crate__flutter__utils__generate_keys_impl(ptr, rust_vec_len, data_len), + 111 => wire__crate__flutter__utils__room_hash_impl(ptr, rust_vec_len, data_len), + 112 => wire__crate__flutter__logging__rust_set_up_impl(ptr, rust_vec_len, data_len), + 115 => wire__crate__flutter__utils__validate_peer_id_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/rust/telepathy-core/src/internal.rs b/rust/telepathy-core/src/internal.rs index 882a12f1..5393270d 100644 --- a/rust/telepathy-core/src/internal.rs +++ b/rust/telepathy-core/src/internal.rs @@ -13,6 +13,7 @@ pub mod state; mod utils; use crate::AudioDevice; +use crate::direct_invitation; use crate::internal::callbacks::CoreCallbacks; use crate::internal::core::{RoomControllerStart, TelepathyCore}; use crate::internal::error::{Error, ErrorKind}; @@ -1033,6 +1034,15 @@ where *self.inner.core_state.denoise_model.write().await = model; Ok(()) } + + /// Returns the local endpoint's opaque direct invitation. Returns `None` + /// if the session manager is not active or has no bound addresses. + pub async fn node_addr(&self) -> Option { + direct_invitation::encode( + self.inner.peer_id().await, + self.inner.core_state.get_endpoint_addrs(), + ) + } } fn validate_identity_switch_contacts(contacts: &[Contact]) -> Result<()> { diff --git a/rust/telepathy-core/src/internal/core.rs b/rust/telepathy-core/src/internal/core.rs index bc5aad4f..b42a7f60 100644 --- a/rust/telepathy-core/src/internal/core.rs +++ b/rust/telepathy-core/src/internal/core.rs @@ -2,6 +2,7 @@ //! negotiates incoming or outgoing calls, then transitions into direct [`call_handshake`] //! or room [`room_handshake`] handling. +use crate::direct_invitation::{decode_for_peer, DirectInvitationError}; use crate::internal::callbacks::CoreCallbacks; use crate::internal::connections::{ ConstConnection, DynamicConnection, SharedConnections, audio_input, audio_output, @@ -36,6 +37,7 @@ use crate::types::{ SessionStatus, }; use chrono::Local; +use iroh::EndpointAddr; use iroh::endpoint::{ ConnectError, ConnectingError, Connection, ConnectionError, RecvStream, SendStream, VarInt, }; @@ -95,7 +97,7 @@ where pending_room_admission: PendingRoomAdmissionRegistry, - pending_session_candidates: PendingSessionCandidateRegistry, + pub(crate) pending_session_candidates: PendingSessionCandidateRegistry, /// Keeps track of and controls the sessions pub session_states: Arc>>>, @@ -103,12 +105,10 @@ where /// Signals the session manager to start a new session pub start_session: Option>, - pub(crate) cancel_outbound_connections: Arc, - /// Monotonic outbound dial generation per peer; stale attempts must not emit UI status. pub(crate) outbound_attempts: Arc>>, - session_availability: Arc, + pub(crate) session_availability: Arc, /// A reference to the object that controls the call overlay pub(crate) overlay: Overlay, @@ -238,7 +238,6 @@ where pending_session_candidates: Default::default(), session_states: Default::default(), start_session: None, - cancel_outbound_connections: Default::default(), outbound_attempts: Default::default(), session_availability: Default::default(), overlay: overlay.clone(), @@ -364,6 +363,10 @@ where elapsed_ms = setup_started.elapsed().as_millis() as u64 ); + // Store the bound endpoint's addresses so the frontend can display + // connection information (direct addresses + relay URL) in settings. + self.core_state + .set_endpoint_addrs(endpoint.addr().addrs.iter().cloned().collect()); *self.core_state.identity.write().await = runtime.identity.clone(); self.reset_sessions().await; self.core_state.reset_peer_output_volumes()?; @@ -373,6 +376,7 @@ where let (dial_events, mut dial_event_receiver) = unbounded_channel(); let mut direct_dials = HashSet::new(); let mut room_dials = RoomDialScheduler::default(); + let outbound_cancellation = CancellationToken::new(); let mut room_reconcile_timer = interval(ROOM_DIAL_RECONCILE_INTERVAL); room_reconcile_timer.tick().await; // preload public identity @@ -395,8 +399,11 @@ where let self_clone = self.clone(); let endpoint_clone = endpoint.clone(); let events = dial_events.clone(); + let cancellation = outbound_cancellation.clone(); handles.push(spawn_task(async move { - self_clone.open_session(peer_id, endpoint_clone).await; + self_clone + .open_session(peer_id, endpoint_clone, cancellation) + .await; let _ = events.send(ManagerDialEvent::DirectCompleted(peer_id, attempt_id)); })); } @@ -462,7 +469,13 @@ where let self_clone = self.clone(); handles.push(spawn_task(async move { if let Err(error) = self_clone - .initialize_session(peer_id, connection, None, candidate_attempt) + .initialize_session( + peer_id, + connection, + None, + candidate_attempt, + None, + ) .await { error!(event = "session_init_failed", error = %error); @@ -541,9 +554,10 @@ where let self_clone = self.clone(); let endpoint_clone = endpoint.clone(); let events = dial_events.clone(); + let cancellation = outbound_cancellation.clone(); handles.push(spawn_task(async move { self_clone - .open_session(peer_id, endpoint_clone) + .open_session(peer_id, endpoint_clone, cancellation) .await; let _ = events.send(ManagerDialEvent::DirectCompleted(peer_id, attempt_id)); })); @@ -557,11 +571,13 @@ where }; debug!(event = "manager_teardown_start"); + outbound_cancellation.cancel(); room_dials.cancel_all(); self.pending_room_admission.cancel_current(); self.callbacks.manager_state(ManagerState::Stopped).await; - self.cancel_outbound_connections.notify_waiters(); self.outbound_attempts.write().await.clear(); + // Clear endpoint addresses so stale connection info is not served + self.core_state.set_endpoint_addrs(Vec::new()); self.reset_sessions().await; // reset room state if let Some(state) = self.room_state.write().await.take() { @@ -661,12 +677,10 @@ where .await; select! { + biased; _ = launch.cancel.cancelled() => { debug!(event = "room_outbound_connection_canceled", peer.id = %peer); } - _ = self.cancel_outbound_connections.notified() => { - debug!(event = "room_outbound_connection_manager_canceled", peer.id = %peer); - } result = endpoint.connect(peer, ALPN) => { match result { Ok(connection) => { @@ -676,7 +690,13 @@ where { connection.close(VarInt::from_u32(0), b"room dial canceled"); } else if let Err(error) = self - .initialize_session(peer, connection, Some(outbound_generation), None) + .initialize_session( + peer, + connection, + Some(outbound_generation), + None, + Some(&launch.cancel), + ) .await { error!(event = "room_session_init_failed", error = %error); @@ -704,15 +724,36 @@ where skip_all, fields(peer.id = %peer) )] - async fn open_session(&self, peer: PublicKey, endpoint: Endpoint) { + async fn open_session( + &self, + peer: PublicKey, + endpoint: Endpoint, + cancellation: CancellationToken, + ) { let generation = self.begin_outbound_attempt(peer).await; self.emit_outbound_status(peer, generation, SessionStatus::Connecting) .await; + let contact = self.callbacks.get_contact(peer.to_vec()).await; + let direct_addr = match resolve_direct_addr(contact.as_ref(), peer) { + Ok(addr) => addr, + Err(error) => { + warn!(event = "direct_invitation_rejected", peer.id = %peer, ?error); + self.emit_outbound_status(peer, generation, SessionStatus::Inactive) + .await; + return; + } + }; + let connect_future = async { let mut retries = 0; loop { - match endpoint.connect(peer, ALPN).await { + let result = if let Some(ref addr) = direct_addr { + endpoint.connect(addr.clone(), ALPN).await + } else { + endpoint.connect(peer, ALPN).await + }; + match result { Ok(connection) => { break Some(connection); } @@ -741,14 +782,25 @@ where }; select! { - _ = self.cancel_outbound_connections.notified() => { + biased; + _ = cancellation.cancelled() => { warn!(event = "outbound_connection_canceled", peer = %peer); } result = connect_future => { if let Some(connection) = result { info!(event = "connect_succeeded", peer.id = %peer); + if cancellation.is_cancelled() { + connection.close(VarInt::from_u32(0), b"outbound dial canceled"); + return; + } if let Err(error) = self - .initialize_session(peer, connection, Some(generation), None) + .initialize_session( + peer, + connection, + Some(generation), + None, + Some(&cancellation), + ) .await { error!(event = "session_init_failed", error = %error); @@ -774,6 +826,7 @@ where connection: Connection, outbound_generation: Option, incoming_candidate: Option, + outbound_cancellation: Option<&CancellationToken>, ) -> Result<()> { let session_generation = match outbound_generation { Some(generation) => generation, @@ -781,16 +834,14 @@ where }; let contact_option = self.callbacks.get_contact(peer.to_vec()).await; + if outbound_cancellation.is_some_and(CancellationToken::is_cancelled) { + connection.close(VarInt::from_u32(0), b"outbound dial canceled"); + return Ok(()); + } let contact = contact_option.unwrap_or_else(|| { // there may be no contact for members of a group debug!(event = "group_contact_created", peer.id = %peer); - Contact { - id: Uuid::new_v4().to_string(), - nickname: String::from("GroupContact"), - peer_id: peer, - output_volume: 0_f32, - is_room_only: true, - } + Contact::group_contact(peer) }); self.core_state.set_peer_output_volume(&contact)?; @@ -805,6 +856,11 @@ where let keep_new_session = should_keep_new_session(&local_peer, &peer, connection.side().is_client()); let mut states = self.session_states.write().await; + if outbound_cancellation.is_some_and(CancellationToken::is_cancelled) { + drop(states); + connection.close(VarInt::from_u32(0), b"outbound dial canceled"); + return Ok(()); + } let mut deferred_candidate = None; let old_state_option = if let Some(old_state) = states.get(&peer).cloned() { if keep_new_session { @@ -3111,7 +3167,6 @@ where pending_session_candidates: self.pending_session_candidates.clone(), session_states: Arc::clone(&self.session_states), start_session: self.start_session.clone(), - cancel_outbound_connections: Arc::clone(&self.cancel_outbound_connections), outbound_attempts: Arc::clone(&self.outbound_attempts), session_availability: Arc::clone(&self.session_availability), overlay: self.overlay.clone(), @@ -3567,13 +3622,13 @@ enum ManagerDialEvent { } #[derive(Default)] -struct SessionAvailability { - peers: StdMutex>, - changed: Notify, +pub(crate) struct SessionAvailability { + pub(crate) peers: StdMutex>, + pub(crate) changed: Notify, } impl SessionAvailability { - fn terminalize_all(&self) { + pub(crate) fn terminalize_all(&self) { let mut peers = self.peers.lock().unwrap(); for state in peers.values_mut() { if state.active_attempt.take().is_some() { @@ -3584,7 +3639,7 @@ impl SessionAvailability { self.changed.notify_waiters(); } - async fn wait_for_change(&self, peer: PublicKey, generation: u64) { + pub(crate) async fn wait_for_change(&self, peer: PublicKey, generation: u64) { loop { let changed = self.changed.notified(); tokio::pin!(changed); @@ -3603,7 +3658,7 @@ impl SessionAvailability { } } - fn complete_direct_attempt(&self, peer: PublicKey, attempt_id: u64) { + pub(crate) fn complete_direct_attempt(&self, peer: PublicKey, attempt_id: u64) { let mut peers = self.peers.lock().unwrap(); let Some(state) = peers.get_mut(&peer) else { return; @@ -3625,26 +3680,26 @@ impl SessionAvailability { } #[derive(Default)] -struct PeerSessionAvailability { - next_attempt_id: u64, - active_attempt: Option, - generation: u64, +pub(crate) struct PeerSessionAvailability { + pub(crate) next_attempt_id: u64, + pub(crate) active_attempt: Option, + pub(crate) generation: u64, } -struct DirectAttempt { - id: u64, - direct_completed: bool, - candidates: usize, +pub(crate) struct DirectAttempt { + pub(crate) id: u64, + pub(crate) direct_completed: bool, + pub(crate) candidates: usize, } -struct IncomingCandidateLease { - availability: Arc, - peer: PublicKey, - attempt_id: u64, +pub(crate) struct IncomingCandidateLease { + pub(crate) availability: Arc, + pub(crate) peer: PublicKey, + pub(crate) attempt_id: u64, } #[derive(Clone, Default)] -struct PendingSessionCandidateRegistry { +pub(crate) struct PendingSessionCandidateRegistry { inner: Arc>>, } @@ -3692,7 +3747,7 @@ impl PendingSessionCandidateRegistry { }) } - fn cancel_all(&self) { + pub(crate) fn cancel_all(&self) { let candidates = std::mem::take(&mut *self.inner.lock().unwrap()); for candidate in candidates.into_values() { candidate.cancellation.cancel(); @@ -3759,100 +3814,6 @@ pub(crate) struct SessionAvailabilitySnapshot { pub(crate) waiting_for_session: bool, } -impl TelepathyCore -where - C: CoreCallbacks + Send + Sync + 'static, - H: AudioHost + Send + Sync + Clone + 'static, -{ - pub(crate) fn begin_direct_attempt(&self, peer: PublicKey) -> u64 { - let mut peers = self.session_availability.peers.lock().unwrap(); - let state = peers.entry(peer).or_default(); - if let Some(attempt) = &state.active_attempt { - return attempt.id; - } - state.next_attempt_id = state.next_attempt_id.wrapping_add(1); - let attempt_id = state.next_attempt_id; - state.active_attempt = Some(DirectAttempt { - id: attempt_id, - direct_completed: false, - candidates: 0, - }); - state.generation = state.generation.wrapping_add(1); - drop(peers); - self.session_availability.changed.notify_waiters(); - attempt_id - } - - pub(crate) fn clear_session_availability(&self) { - self.session_availability.terminalize_all(); - } - - pub(crate) fn cancel_pending_session_candidates(&self) { - self.pending_session_candidates.cancel_all(); - } - - pub(crate) fn complete_direct_attempt(&self, peer: PublicKey, attempt_id: u64) { - self.session_availability - .complete_direct_attempt(peer, attempt_id); - } - - fn is_current_direct_attempt(&self, peer: PublicKey, attempt_id: u64) -> bool { - self.session_availability - .peers - .lock() - .unwrap() - .get(&peer) - .and_then(|state| state.active_attempt.as_ref()) - .is_some_and(|attempt| attempt.id == attempt_id) - } - - fn handoff_incoming_candidate(&self, peer: PublicKey) -> Option { - let mut peers = self.session_availability.peers.lock().unwrap(); - let state = peers.get_mut(&peer)?; - let attempt = state.active_attempt.as_mut()?; - let attempt_id = attempt.id; - attempt.candidates += 1; - state.generation = state.generation.wrapping_add(1); - drop(peers); - self.session_availability.changed.notify_waiters(); - Some(IncomingCandidateLease { - availability: Arc::clone(&self.session_availability), - peer, - attempt_id, - }) - } - - fn publish_session_locked(&self, peer: PublicKey) { - let mut peers = self.session_availability.peers.lock().unwrap(); - let state = peers.entry(peer).or_default(); - state.generation = state.generation.wrapping_add(1); - drop(peers); - self.session_availability.changed.notify_waiters(); - } - - pub(crate) fn session_availability_snapshot( - &self, - peer: PublicKey, - ) -> SessionAvailabilitySnapshot { - let peers = self.session_availability.peers.lock().unwrap(); - let state = peers.get(&peer); - SessionAvailabilitySnapshot { - generation: state.map_or(0, |state| state.generation), - waiting_for_session: state.is_some_and(|state| state.active_attempt.is_some()), - } - } - - pub(crate) async fn wait_for_session_availability_change( - &self, - peer: PublicKey, - generation: u64, - ) { - self.session_availability - .wait_for_change(peer, generation) - .await; - } -} - impl RoomDialScheduler { fn reconcile( &mut self, @@ -4056,6 +4017,21 @@ async fn is_session_still_current( .unwrap_or(false) } +fn resolve_direct_addr( + contact: Option<&Contact>, + peer: PublicKey, +) -> std::result::Result, DirectInvitationError> { + let Some(contact) = contact.filter(|contact| contact.is_direct) else { + return Ok(None); + }; + let invitation = contact + .direct_connection_string + .as_deref() + .ok_or(DirectInvitationError::MissingInvitation)?; + let addresses = decode_for_peer(invitation, peer)?; + Ok(Some(EndpointAddr::from_parts(peer, addresses))) +} + /// Releases a pending direct-call slot only if the owning session is still the current /// map entry for `peer` in `session_states`. /// @@ -4113,9 +4089,56 @@ fn ringtone_is_within_limit(ringtone: &Option>) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::direct_invitation::{DirectInvitationError, encode}; use crate::internal::messages::AudioHeader; - use iroh::SecretKey; + use iroh::{SecretKey, TransportAddr}; use speedy::{Readable, Writable}; + use std::net::{Ipv4Addr, SocketAddr}; + + fn direct_test_invitation(peer: PublicKey) -> String { + encode( + peer, + vec![TransportAddr::Ip(SocketAddr::from(( + Ipv4Addr::LOCALHOST, + 40142, + )))], + ) + .expect("test invitation has an address") + } + + #[test] + fn dial_resolution_uses_discovery_for_normal_contacts() { + let peer = SecretKey::generate().public(); + let contact = Contact::new("Eve".to_string(), peer.to_string()) + .expect("valid peer should create contact"); + + assert!(resolve_direct_addr(Some(&contact), peer).unwrap().is_none()); + assert!(resolve_direct_addr(None, peer).unwrap().is_none()); + } + + #[test] + fn dial_resolution_accepts_only_matching_direct_invitations() { + let peer = SecretKey::generate().public(); + let mut contact = Contact::new("Frank".to_string(), peer.to_string()) + .expect("valid peer should create contact"); + contact.direct_connection_string = Some(direct_test_invitation(peer)); + contact.is_direct = true; + + assert!(resolve_direct_addr(Some(&contact), peer).unwrap().is_some()); + + contact.direct_connection_string = None; + assert_eq!( + resolve_direct_addr(Some(&contact), peer), + Err(DirectInvitationError::MissingInvitation) + ); + + let other_peer = SecretKey::generate().public(); + contact.direct_connection_string = Some(direct_test_invitation(other_peer)); + assert_eq!( + resolve_direct_addr(Some(&contact), peer), + Err(DirectInvitationError::PeerMismatch) + ); + } #[test] fn oversized_hello_ringtone_is_rejected_before_prompting() { diff --git a/rust/telepathy-core/src/internal/helpers.rs b/rust/telepathy-core/src/internal/helpers.rs index 6c4eb8d1..b59c0484 100644 --- a/rust/telepathy-core/src/internal/helpers.rs +++ b/rust/telepathy-core/src/internal/helpers.rs @@ -1,7 +1,7 @@ use crate::internal::callbacks::CoreCallbacks; use crate::internal::core::{ - OutgoingSlotDecision, PendingDirectCallSlot, RoomControllerCleanup, RoomControllerOutcome, - TelepathyCore, + DirectAttempt, IncomingCandidateLease, OutgoingSlotDecision, PendingDirectCallSlot, + RoomControllerCleanup, RoomControllerOutcome, SessionAvailabilitySnapshot, TelepathyCore, }; use crate::internal::error::{AudioStreamError, Error, ErrorKind}; use crate::internal::messages::{AudioHeader, RoomMessage}; @@ -876,6 +876,97 @@ where None => Ok(OutgoingSlotDecision::Busy), } } + + pub(crate) fn begin_direct_attempt(&self, peer: PublicKey) -> u64 { + let mut peers = self.session_availability.peers.lock().unwrap(); + let state = peers.entry(peer).or_default(); + if let Some(attempt) = &state.active_attempt { + return attempt.id; + } + state.next_attempt_id = state.next_attempt_id.wrapping_add(1); + let attempt_id = state.next_attempt_id; + state.active_attempt = Some(DirectAttempt { + id: attempt_id, + direct_completed: false, + candidates: 0, + }); + state.generation = state.generation.wrapping_add(1); + drop(peers); + self.session_availability.changed.notify_waiters(); + attempt_id + } + + pub(crate) fn clear_session_availability(&self) { + self.session_availability.terminalize_all(); + } + + pub(crate) fn cancel_pending_session_candidates(&self) { + self.pending_session_candidates.cancel_all(); + } + + pub(crate) fn complete_direct_attempt(&self, peer: PublicKey, attempt_id: u64) { + self.session_availability + .complete_direct_attempt(peer, attempt_id); + } + + pub(crate) fn is_current_direct_attempt(&self, peer: PublicKey, attempt_id: u64) -> bool { + self.session_availability + .peers + .lock() + .unwrap() + .get(&peer) + .and_then(|state| state.active_attempt.as_ref()) + .is_some_and(|attempt| attempt.id == attempt_id) + } + + pub(crate) fn handoff_incoming_candidate( + &self, + peer: PublicKey, + ) -> Option { + let mut peers = self.session_availability.peers.lock().unwrap(); + let state = peers.get_mut(&peer)?; + let attempt = state.active_attempt.as_mut()?; + let attempt_id = attempt.id; + attempt.candidates += 1; + state.generation = state.generation.wrapping_add(1); + drop(peers); + self.session_availability.changed.notify_waiters(); + Some(IncomingCandidateLease { + availability: Arc::clone(&self.session_availability), + peer, + attempt_id, + }) + } + + pub(crate) fn publish_session_locked(&self, peer: PublicKey) { + let mut peers = self.session_availability.peers.lock().unwrap(); + let state = peers.entry(peer).or_default(); + state.generation = state.generation.wrapping_add(1); + drop(peers); + self.session_availability.changed.notify_waiters(); + } + + pub(crate) fn session_availability_snapshot( + &self, + peer: PublicKey, + ) -> SessionAvailabilitySnapshot { + let peers = self.session_availability.peers.lock().unwrap(); + let state = peers.get(&peer); + SessionAvailabilitySnapshot { + generation: state.map_or(0, |state| state.generation), + waiting_for_session: state.is_some_and(|state| state.active_attempt.is_some()), + } + } + + pub(crate) async fn wait_for_session_availability_change( + &self, + peer: PublicKey, + generation: u64, + ) { + self.session_availability + .wait_for_change(peer, generation) + .await; + } } /// Bounded join classification for room tasks. diff --git a/rust/telepathy-core/src/internal/state.rs b/rust/telepathy-core/src/internal/state.rs index c5c488d1..95aafa5d 100644 --- a/rust/telepathy-core/src/internal/state.rs +++ b/rust/telepathy-core/src/internal/state.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::atomic::Ordering::{Acquire, Relaxed, Release}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize}; -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::{Arc, Mutex as StdMutex, RwLock as StdRwLock}; use std::time::Duration; use telepathy_audio::RnnModel; use telepathy_audio::internal::utils::db_to_multiplier; @@ -531,6 +531,12 @@ pub struct CoreState { /// global audio sequence number pub(crate) audio_sequence: Arc, + + /// The local endpoint's current transport addrs, populated when the + /// session manager binds the endpoint and cleared on teardown. Used + /// to expose connection information to the frontend (networking settings) + /// so users can share their direct addresses with contacts. + pub(crate) endpoint_addrs: Arc>>, } /// Immutable runtime target captured by one manager generation. @@ -912,6 +918,24 @@ impl CoreState { .or_insert_with(|| PeerVolume::new(self.output_volume.load(Relaxed))) .clone()) } + + /// Returns the local endpoint's current [`EndpointAddr`] if the session + /// manager is active and the endpoint has been bound. + pub(crate) fn get_endpoint_addrs(&self) -> Vec { + self.endpoint_addrs + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Stores the local endpoint's [`EndpointAddr`] after the session manager + /// binds the endpoint. Cleared on manager teardown. + pub(crate) fn set_endpoint_addrs(&self, addrs: Vec) { + *self + .endpoint_addrs + .write() + .expect("endpoint_addrs lock poisoned") = addrs; + } } pub(crate) fn room_hash_for_peers(peers: &[PublicKey]) -> u64 { diff --git a/rust/telepathy-core/src/lib.rs b/rust/telepathy-core/src/lib.rs index 9dcea1e5..9325ab0a 100644 --- a/rust/telepathy-core/src/lib.rs +++ b/rust/telepathy-core/src/lib.rs @@ -1,5 +1,7 @@ use telepathy_audio::devices::AudioDeviceInfo; +mod direct_invitation; + #[cfg(feature = "flutter")] pub mod flutter; #[cfg(feature = "flutter")] diff --git a/rust/telepathy-core/src/types.rs b/rust/telepathy-core/src/types.rs index 173d3059..9655e724 100644 --- a/rust/telepathy-core/src/types.rs +++ b/rust/telepathy-core/src/types.rs @@ -1,3 +1,4 @@ +use crate::direct_invitation::{canonicalize_for_peer, decode_for_peer}; use crate::internal::error::{Error, ErrorKind}; use crate::internal::messages::Attachment; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] @@ -66,6 +67,12 @@ pub struct Contact { /// In rooms, some contacts are dummy representing unknown peers pub(crate) is_room_only: bool, + + /// When true, sessions for this contact use the direct invitation only. + pub(crate) is_direct: bool, + + /// An opaque `tp1:` direct invitation conveying the peer's direct addresses. + pub(crate) direct_connection_string: Option, } impl Contact { @@ -77,22 +84,37 @@ impl Contact { peer_id: PublicKey::from_str(&peer_id).map_err(|_| ErrorKind::InvalidContactFormat)?, output_volume: 0.0, is_room_only: false, + is_direct: false, + direct_connection_string: None, }) } #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] + #[allow(clippy::too_many_arguments)] + /// Reconstructs persisted contact data, discarding invalid invitation state + /// without discarding the contact's valid identity and preferences. pub fn from_parts( id: String, nickname: String, peer_id: String, output_volume: f32, + is_direct: bool, + direct_invitation: Option, ) -> Result { + let peer_id = PublicKey::from_str(&peer_id).map_err(|_| ErrorKind::InvalidContactFormat)?; + let direct_connection_string = direct_invitation + .as_deref() + .and_then(|invitation| canonicalize_for_peer(invitation, peer_id)); + let is_direct = is_direct && direct_connection_string.is_some(); + Ok(Self { id, nickname, - peer_id: PublicKey::from_str(&peer_id).map_err(|_| ErrorKind::InvalidContactFormat)?, + peer_id, output_volume: contact_output_volume_from_parts(output_volume), is_room_only: false, + is_direct, + direct_connection_string, }) } @@ -136,9 +158,57 @@ impl Contact { self.peer_id.to_vec() == id } + #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] pub fn get_peer_id(&self) -> PublicKey { self.peer_id } + + #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] + pub fn is_direct(&self) -> bool { + self.is_direct + } + + #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] + pub fn set_direct(&mut self, is_direct: bool) { + self.is_direct = is_direct + && self + .direct_connection_string + .as_deref() + .is_some_and(|invitation| decode_for_peer(invitation, self.peer_id).is_ok()); + } + + #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] + pub fn direct_invitation(&self) -> Option { + self.direct_connection_string.clone() + } + + #[cfg_attr(feature = "flutter", flutter_rust_bridge::frb(sync))] + /// Strictly validates interactive invitation input before changing state. + /// Passing `None` removes the current invitation and disables direct mode. + pub fn set_direct_invitation(&mut self, invitation: Option) -> Result<(), DartError> { + let Some(invitation) = invitation else { + self.is_direct = false; + self.direct_connection_string = None; + return Ok(()); + }; + + decode_for_peer(&invitation, self.peer_id) + .map_err(|error| DartError::from(error.to_string()))?; + self.direct_connection_string = Some(invitation); + Ok(()) + } + + pub(crate) fn group_contact(peer_id: PublicKey) -> Self { + Self { + id: Uuid::new_v4().to_string(), + nickname: String::from("GroupContact"), + peer_id, + output_volume: 0_f32, + is_room_only: true, + is_direct: false, + direct_connection_string: None, + } + } } #[derive(Debug, Serialize, Clone)] @@ -893,7 +963,13 @@ fn poison_field_error( #[cfg(test)] mod tests { - use super::{NetworkConfig, NetworkConfigField}; + use super::{Contact, NetworkConfig, NetworkConfigField}; + use crate::direct_invitation::encode; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use iroh::{RelayUrl, SecretKey, TransportAddr}; + use std::net::{Ipv4Addr, SocketAddr}; + use std::str::FromStr; const VALID_RELAY_A: &str = "https://relay-us.iroh.example/"; const VALID_RELAY_B: &str = "https://relay-eu.iroh.example/"; @@ -905,6 +981,166 @@ mod tests { items.iter().map(|s| s.to_string()).collect() } + fn direct_invitation(peer: iroh::PublicKey) -> String { + encode( + peer, + vec![TransportAddr::Ip(SocketAddr::from(( + Ipv4Addr::LOCALHOST, + 40142, + )))], + ) + .expect("test invitation has an address") + } + + fn unsupported_direct_invitation(peer: iroh::PublicKey) -> String { + let invitation = direct_invitation(peer); + let encoded = invitation + .strip_prefix("tp1:") + .expect("test invitation has canonical prefix"); + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .expect("test invitation has valid base64"); + let mut payload: serde_json::Value = + serde_json::from_slice(&bytes).expect("test invitation has valid JSON"); + payload["version"] = serde_json::json!(2); + format!( + "tp1:{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("test JSON serializes")) + ) + } + + fn relay_direct_invitation(peer: iroh::PublicKey) -> String { + let invitation = direct_invitation(peer); + let encoded = invitation + .strip_prefix("tp1:") + .expect("test invitation has canonical prefix"); + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .expect("test invitation has valid base64"); + let mut payload: serde_json::Value = + serde_json::from_slice(&bytes).expect("test invitation has valid JSON"); + payload["addresses"] = serde_json::json!([serde_json::to_value(TransportAddr::Relay( + RelayUrl::from_str("https://relay.example/").expect("test relay URL should parse") + )) + .expect("relay address should serialize")]); + format!( + "tp1:{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("test JSON serializes")) + ) + } + + fn legacy_relay_invitation() -> String { + serde_json::to_string(&vec![TransportAddr::Relay( + RelayUrl::from_str("https://relay.example/").expect("test relay URL should parse"), + )]) + .expect("legacy invitation should serialize") + } + + #[test] + fn direct_contact_requires_a_valid_peer_bound_invitation() { + let peer = SecretKey::generate().public(); + let invitation = direct_invitation(peer); + + let contact = Contact::from_parts( + "contact-alice".to_string(), + "Alice".to_string(), + peer.to_string(), + 0.0, + true, + Some(invitation.clone()), + ) + .expect("valid direct contact should load"); + + assert!(contact.is_direct()); + assert_eq!(contact.direct_invitation(), Some(invitation)); + } + + #[test] + fn invalid_direct_state_is_disabled_without_losing_contact_data() { + let peer = SecretKey::generate().public(); + let other_peer = SecretKey::generate().public(); + + for invitation in [ + None, + Some("tp1:not-valid-base64".to_string()), + Some(direct_invitation(other_peer)), + Some(relay_direct_invitation(peer)), + Some(legacy_relay_invitation()), + ] { + let contact = Contact::from_parts( + "contact-bob".to_string(), + "Bob Rivera".to_string(), + peer.to_string(), + -6.5, + true, + invitation, + ) + .expect("invalid invitation state must not discard a valid contact"); + + assert_eq!(contact.id(), "contact-bob"); + assert_eq!(contact.nickname(), "Bob Rivera"); + assert_eq!(contact.output_volume(), -6.5); + assert!(!contact.is_direct()); + assert_eq!(contact.direct_invitation(), None); + } + } + + #[test] + fn interactive_invitation_import_is_strict_and_atomic() { + let peer = SecretKey::generate().public(); + let other_peer = SecretKey::generate().public(); + let mut contact = Contact::new("Carol".to_string(), peer.to_string()) + .expect("valid peer should create contact"); + + contact.set_direct(true); + assert!(!contact.is_direct()); + + let valid_invitation = direct_invitation(peer); + contact + .set_direct_invitation(Some(valid_invitation.clone())) + .expect("matching canonical invitation should be accepted"); + contact.set_direct(true); + assert!(contact.is_direct()); + + for invalid_invitation in [ + "tp1:broken".to_string(), + direct_invitation(other_peer), + unsupported_direct_invitation(peer), + relay_direct_invitation(peer), + ] { + assert!( + contact + .set_direct_invitation(Some(invalid_invitation)) + .is_err() + ); + assert_eq!(contact.direct_invitation(), Some(valid_invitation.clone())); + assert!(contact.is_direct()); + } + + contact + .set_direct_invitation(None) + .expect("removing an invitation should succeed"); + assert_eq!(contact.direct_invitation(), None); + assert!(!contact.is_direct()); + } + + #[test] + fn normal_contact_does_not_require_an_invitation() { + let peer = SecretKey::generate().public(); + let contact = Contact::from_parts( + "contact-dana".to_string(), + "Dana".to_string(), + peer.to_string(), + 0.0, + false, + None, + ) + .expect("ordinary contact should not require an invitation"); + + assert!(!contact.is_direct()); + assert_eq!(contact.direct_invitation(), None); + } + #[test] fn default_constructor_leaves_optionals_unset_and_listens_on_unspecified_v4() { let config = NetworkConfig::default(); diff --git a/rust/telepathy-core/tests/core_integration_test/common.rs b/rust/telepathy-core/tests/core_integration_test/common.rs index 816153ee..786ab8cf 100644 --- a/rust/telepathy-core/tests/core_integration_test/common.rs +++ b/rust/telepathy-core/tests/core_integration_test/common.rs @@ -21,6 +21,7 @@ use telepathy_core::types::{ }; use tokio::sync::{Notify, watch}; use tokio::time::{interval, sleep}; +use tokio_util::sync::CancellationToken; use tracing::info; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -119,6 +120,54 @@ impl ContactLookupProbe { } } +#[derive(Clone)] +pub(super) struct ContactLookupGate { + peer_id: Vec, + block_on: usize, + count: Arc, + blocked: Arc, + released: CancellationToken, +} + +impl ContactLookupGate { + pub(super) fn new(peer_id: Vec, block_on: usize) -> Self { + Self { + peer_id, + block_on, + count: Arc::new(AtomicUsize::new(0)), + blocked: Arc::new(Notify::new()), + released: CancellationToken::new(), + } + } + + async fn wait_if_target(&self, peer_id: &[u8]) { + if peer_id != self.peer_id { + return; + } + let count = self.count.fetch_add(1, Relaxed) + 1; + if count == self.block_on { + self.blocked.notify_waiters(); + self.released.cancelled().await; + } + } + + pub(super) async fn wait_blocked(&self) { + let blocked = self.blocked.notified(); + tokio::pin!(blocked); + blocked.as_mut().enable(); + if self.count.load(Relaxed) >= self.block_on { + return; + } + tokio::time::timeout(Duration::from_secs(10), blocked) + .await + .expect("timed out waiting for contact lookup gate"); + } + + pub(super) fn release(&self) { + self.released.cancel(); + } +} + #[derive(Clone)] pub(super) struct SessionStatusProbe { statuses: Arc, SessionStatus>>>, @@ -1256,6 +1305,7 @@ where None, None, Some(contact_lookup_probe.clone()), + None, Some(session_status_probe.clone()), ); @@ -1279,6 +1329,69 @@ where } } +#[allow(clippy::too_many_arguments)] +pub(super) async fn build_client_with_contact_lookup_gate( + relay_map: &RelayMap, + identity: SecretKey, + contacts: Vec, + initial_contacts: Vec, + codec_config: &CodecConfig, + host: H, + call_states: Arc>>, + lifecycle: ManagerLifecycle, + contact_lookup_gate: ContactLookupGate, +) -> ClientHarness +where + H: AudioHost + Send + Sync + Clone + 'static, + I: Send + Sync + 'static, + O: Send + Sync + 'static, +{ + let network_config = NetworkConfig::mock( + 0, + relay_map, + None, + None, + None, + Some(shared_address_lookup().clone()), + ); + let is_active = Arc::new(AtomicBool::new(false)); + let contact_lookup_probe = ContactLookupProbe::default(); + let session_status_probe = SessionStatusProbe::default(); + let mock = construct_mock_callbacks_with_contact_lookup( + contacts, + initial_contacts, + is_active.clone(), + Arc::new(AtomicBool::new(false)), + call_states, + None, + lifecycle, + None, + None, + None, + Some(contact_lookup_probe.clone()), + Some(contact_lookup_gate), + Some(session_status_probe.clone()), + ); + let mut telepathy = TelepathyHandle::new( + host, + &network_config, + &ScreenshareConfig::default(), + &Overlay::default(), + codec_config, + mock, + ); + *telepathy.inner.core_state.identity.write().await = Some(identity); + telepathy.start_manager().await; + telepathy.inner.core_state.manager_active.notified().await; + + ClientHarness { + telepathy, + is_active, + contact_lookup_probe, + session_status_probe, + } +} + /// Returns mock callbacks that establish a telepathy instance with the provided /// contacts. `is_active` flips to true on the first session-connected event. /// `lifecycle` controls how many `manager_state` activations the mock accepts @@ -1312,6 +1425,7 @@ pub(super) fn construct_mock_callbacks( connected_gate, call_ended_park, None, + None, session_status_probe, ) } @@ -1329,6 +1443,7 @@ fn construct_mock_callbacks_with_contact_lookup( connected_gate: Option, call_ended_park: Option, contact_lookup_probe: Option, + contact_lookup_gate: Option, session_status_probe: Option, ) -> MockCoreCallbacks { let mut mock = MockCoreCallbacks::new(); @@ -1454,10 +1569,14 @@ fn construct_mock_callbacks_with_contact_lookup( mock.expect_get_contact().returning(move |peer_id| { let contacts_clone = contacts.clone(); let contact_lookup_probe = contact_lookup_probe.clone(); + let contact_lookup_gate = contact_lookup_gate.clone(); Box::pin(async move { if let Some(probe) = contact_lookup_probe { probe.record(&peer_id); } + if let Some(gate) = contact_lookup_gate { + gate.wait_if_target(&peer_id).await; + } for contact in contacts_clone.iter() { if contact.get_peer_id().to_vec() == peer_id { return Some(contact.clone()); diff --git a/rust/telepathy-core/tests/core_integration_test/session_lifecycle.rs b/rust/telepathy-core/tests/core_integration_test/session_lifecycle.rs index 73928d77..edeccdd3 100644 --- a/rust/telepathy-core/tests/core_integration_test/session_lifecycle.rs +++ b/rust/telepathy-core/tests/core_integration_test/session_lifecycle.rs @@ -1,6 +1,7 @@ use super::common::{ - DEFAULT_SAMPLE_RATE, PendingAcceptProbe, TwoClientShutdownGuard, assert_no_busy_end, - assert_no_call_ended_before_connected, build_client, build_client_with_accept_probe, + ContactLookupGate, DEFAULT_SAMPLE_RATE, ManagerLifecycle, PendingAcceptProbe, + TwoClientShutdownGuard, assert_no_busy_end, assert_no_call_ended_before_connected, + build_client, build_client_with_accept_probe, build_client_with_contact_lookup_gate, build_client_with_lookup_contacts, call_state_snapshot, init_test_tracing, shared_address_lookup, shared_relay_map, wait_for_active_transport, wait_for_connected, wait_for_sessions, @@ -14,7 +15,7 @@ use telepathy_audio::devices::{MockAudioHost, MockAudioInput, MockAudioOutput}; use telepathy_core::internal::state::SessionState; use telepathy_core::types::Contact; use telepathy_core::types::{CallState, CodecConfig, SessionStatus}; -use tokio::time::sleep; +use tokio::time::{sleep, timeout}; use tracing::info; #[tokio::test(flavor = "multi_thread")] @@ -105,6 +106,109 @@ async fn session_collision_doesnt_fail() { client_b.telepathy.shutdown().await; } +#[tokio::test(flavor = "multi_thread")] +async fn profile_switch_cancels_dial_blocked_after_connect_and_refreshes_invitation() { + init_test_tracing(); + let relay_map = shared_relay_map(); + let codec_config = CodecConfig::new(true, true, 5.0); + let key_a = SecretKey::generate(); + let key_b = SecretKey::generate(); + let contact_a = Contact::new("switch-client-a".to_string(), key_a.public().to_string()) + .expect("contact a invalid"); + let contact_b = Contact::new("switch-client-b".to_string(), key_b.public().to_string()) + .expect("contact b invalid"); + let gate = ContactLookupGate::new(contact_b.get_peer_id().to_vec(), 2); + + let client_a = build_client_with_contact_lookup_gate( + relay_map, + key_a, + vec![contact_b.clone()], + Vec::new(), + &codec_config, + MockAudioHost::new( + MockAudioInput::default(), + DEFAULT_SAMPLE_RATE, + MockAudioOutput, + DEFAULT_SAMPLE_RATE, + ), + Arc::new(Mutex::new(Vec::new())), + ManagerLifecycle::Restartable, + gate.clone(), + ) + .await; + let client_b = build_client( + relay_map, + key_b, + vec![contact_a], + &codec_config, + MockAudioHost::new( + MockAudioInput::default(), + DEFAULT_SAMPLE_RATE, + MockAudioOutput, + DEFAULT_SAMPLE_RATE, + ), + Arc::new(Mutex::new(Vec::new())), + ) + .await; + let initial_invitation = client_a + .telepathy + .node_addr() + .await + .expect("active manager should expose an invitation"); + + client_a.telepathy.start_session(&contact_b).await; + gate.wait_blocked().await; + + let prepared = client_a + .telepathy + .prepare_identity_switch(SecretKey::generate().to_bytes(), Vec::new()) + .await + .expect("identity switch should prepare"); + timeout(Duration::from_secs(5), prepared.commit()) + .await + .expect("profile switch must cancel the delayed old dial") + .expect("profile switch should succeed"); + + assert!( + client_a + .telepathy + .inner + .session_states + .read() + .await + .is_empty(), + "the canceled old connection must not initialize a stale session" + ); + let restarted_invitation = client_a + .telepathy + .node_addr() + .await + .expect("restarted manager should expose its current invitation"); + assert_ne!( + restarted_invitation, initial_invitation, + "the restarted invitation must use the switched identity" + ); + timeout(Duration::from_secs(5), client_a.telepathy.restart_manager()) + .await + .expect("explicit manager restart should be bounded") + .expect("explicit manager restart should succeed"); + assert!( + client_a.telepathy.node_addr().await.is_some(), + "active manager should expose its current invitation after restart" + ); + + gate.release(); + client_b.telepathy.shutdown().await; + timeout(Duration::from_secs(5), client_a.telepathy.shutdown()) + .await + .expect("shutdown must not wait for the canceled dial"); + assert_eq!( + client_a.telepathy.node_addr().await, + None, + "inactive manager must not expose a direct invitation" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn stale_predecessor_promotes_same_identity_replacement_and_allows_call() { init_test_tracing(); diff --git a/test/controllers/profiles_controller_test.dart b/test/controllers/profiles_controller_test.dart index bd7acc10..b7e63c08 100644 --- a/test/controllers/profiles_controller_test.dart +++ b/test/controllers/profiles_controller_test.dart @@ -10,6 +10,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:telepathy/controllers/profiles_controller.dart'; import 'package:telepathy/core/rust/frb_generated.dart'; import 'package:telepathy/core/rust/flutter.dart'; +import 'package:telepathy/core/rust/lib.dart'; import 'package:telepathy/core/rust/types.dart'; void main() { @@ -67,6 +68,121 @@ void main() { expect(await fixture.storage.read(key: 'beta-keypair'), isNotNull); }); + Future verifyDirectInvitationMigration() async { + final fixture = await _fixture(); + final String aliceInvitation = _canonicalInvitation('alice-peer'); + final String otherPeerInvitation = _canonicalInvitation('other-peer'); + await fixture.storage.write( + key: 'alpha-contacts', + value: jsonEncode({ + 'alice-contact': { + 'nickname': 'Alice Nguyen', + 'peerId': 'alice-peer', + 'outputVolume': -4.5, + 'isDirect': true, + 'directInvitation': aliceInvitation, + }, + 'ben-contact': { + 'nickname': 'Ben Ortiz', + 'peerId': 'ben-peer', + 'outputVolume': 2.25, + 'isDirect': true, + 'directConnectionString': _legacyAddresses, + }, + 'carol-contact': { + 'nickname': 'Carol Smith', + 'peerId': 'carol-peer', + 'outputVolume': -7.0, + 'isDirect': true, + 'directInvitation': 'tp1:not-valid-base64', + }, + 'diego-contact': { + 'nickname': 'Diego Morales', + 'peerId': 'diego-peer', + 'outputVolume': 1.5, + 'isDirect': true, + 'directInvitation': {'unexpected': 'object'}, + }, + 'erin-contact': { + 'nickname': 'Erin Chen', + 'peerId': 'erin-peer', + 'outputVolume': 3.0, + 'isDirect': true, + 'directInvitation': otherPeerInvitation, + }, + 'fatima-contact': { + 'nickname': 'Fatima Zahra', + 'peerId': 'fatima-peer', + 'outputVolume': 6.75, + 'isDirect': false, + }, + }), + ); + final restarted = ProfilesController( + storage: fixture.storage, + options: fixture.options, + roomHasher: _roomHash, + ); + + await restarted.init(const []); + + final Map contacts = restarted.profiles['alpha']!.contacts; + expect(contacts, hasLength(6)); + expect(contacts['alice-contact']!.directInvitation(), aliceInvitation); + expect(contacts['alice-contact']!.isDirect(), isTrue); + expect( + contacts['ben-contact']!.directInvitation(), + _canonicalInvitation('ben-peer'), + ); + expect(contacts['ben-contact']!.isDirect(), isTrue); + for (final String id in [ + 'carol-contact', + 'diego-contact', + 'erin-contact', + ]) { + expect(contacts[id]!.directInvitation(), isNull); + expect(contacts[id]!.isDirect(), isFalse); + } + expect(contacts['fatima-contact']!.nickname(), 'Fatima Zahra'); + expect(contacts['fatima-contact']!.outputVolume(), 6.75); + + final Map repaired = jsonDecode( + (await fixture.storage.read(key: 'alpha-contacts'))!, + ) as Map; + expect(jsonEncode(repaired), isNot(contains('directConnectionString'))); + expect(repaired['ben-contact']['directInvitation'], + _canonicalInvitation('ben-peer')); + expect(repaired['carol-contact']['isDirect'], isFalse); + expect(repaired['diego-contact']['isDirect'], isFalse); + expect(repaired['erin-contact']['isDirect'], isFalse); + expect(repaired['fatima-contact']['nickname'], 'Fatima Zahra'); + expect(repaired['fatima-contact']['outputVolume'], 6.75); + } + + Future verifyInteractiveInvitationAddIsAtomic() async { + final fixture = await _fixture(); + + expect( + () => fixture.controller.addContact( + 'Grace Kim', + 'grace-peer', + directInvitation: _canonicalInvitation('different-peer'), + ), + throwsA(isA()), + ); + expect(fixture.controller.contacts, isEmpty); + + final Contact contact = fixture.controller.addContact( + 'Grace Kim', + 'grace-peer', + directInvitation: _canonicalInvitation('grace-peer'), + ); + expect(fixture.controller.contacts, hasLength(1)); + expect(fixture.controller.contacts.values.single, same(contact)); + expect(contact.directInvitation(), _canonicalInvitation('grace-peer')); + expect(contact.isDirect(), isTrue); + } + test('persistence failure disposes prepared token and keeps active profile', () async { final fixture = await _fixture(options: _ThrowingPreferences()); @@ -309,6 +425,16 @@ void main() { expect(fixture.controller.activeProfile, replacementId); expect(fixture.controller.profiles.keys, [replacementId]); }); + + test( + 'interactive direct invitation add validates before profile mutation', + verifyInteractiveInvitationAddIsAtomic, + ); + + test( + 'direct invitation migration preserves contacts and repairs storage', + verifyDirectInvitationMigration, + ); } String _roomHash({required List peers}) => peers.join('|'); @@ -370,6 +496,46 @@ class _Prepared implements PreparedIdentitySwitch { } class _RustApi implements RustLibApi { + @override + Contact crateTypesContactNew({ + required String nickname, + required String peerId, + }) => + _PersistedContact( + id: 'contact-$peerId', + nickname: nickname, + peerId: peerId, + outputVolume: 0.0, + isDirect: false, + directInvitation: null, + ); + + @override + Contact crateTypesContactFromParts({ + required String id, + required String nickname, + required String peerId, + required double outputVolume, + required bool isDirect, + String? directInvitation, + }) { + // The native bridge is the external boundary for this controller test; + // Rust unit tests cover its real validation and canonicalization behavior. + final String? canonicalInvitation = switch (directInvitation) { + _legacyAddresses => _canonicalInvitation(peerId), + final String value when value == _canonicalInvitation(peerId) => value, + _ => null, + }; + return _PersistedContact( + id: id, + nickname: nickname, + peerId: peerId, + outputVolume: outputVolume, + isDirect: isDirect && canonicalInvitation != null, + directInvitation: canonicalInvitation, + ); + } + @override (String, Uint8List) crateFlutterUtilsGenerateKeys() => ('replacement-peer', Uint8List.fromList(List.filled(32, 4))); @@ -378,6 +544,111 @@ class _RustApi implements RustLibApi { Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +const String _legacyAddresses = '[{"Ip":"203.0.113.42:40142"}]'; + +String _canonicalInvitation(String peerId) { + final String payload = jsonEncode({ + 'version': 1, + 'peer_id': peerId, + 'addresses': >[ + {'Ip': '203.0.113.42:40142'}, + ], + }); + return 'tp1:${base64Url.encode(utf8.encode(payload)).replaceAll('=', '')}'; +} + +class _PersistedContact implements Contact { + _PersistedContact({ + required String id, + required String nickname, + required String peerId, + required double outputVolume, + required bool isDirect, + required String? directInvitation, + }) : _id = id, + _nickname = nickname, + _peerId = peerId, + _outputVolume = outputVolume, + _isDirect = isDirect, + _directInvitation = directInvitation; + + final String _id; + String _nickname; + final String _peerId; + double _outputVolume; + bool _isDirect; + String? _directInvitation; + + @override + String? directInvitation() => _directInvitation; + + @override + void dispose() {} + + @override + PublicKey getPeerId() => _TestPublicKey(); + + @override + String id() => _id; + + @override + bool idEq({required List id}) => false; + + @override + bool get isDisposed => false; + + @override + bool isDirect() => _isDirect; + + @override + String nickname() => _nickname; + + @override + double outputVolume() => _outputVolume; + + @override + String peerId() => _peerId; + + @override + Contact pubClone() => this; + + @override + void setDirect({required bool isDirect}) { + _isDirect = isDirect && _directInvitation != null; + } + + @override + void setDirectInvitation({String? invitation}) { + if (invitation == null) { + _isDirect = false; + _directInvitation = null; + return; + } + if (invitation != _canonicalInvitation(_peerId)) { + throw const DartError(message: 'invalid direct invitation'); + } + _directInvitation = invitation; + } + + @override + void setNickname({required String nickname}) { + _nickname = nickname; + } + + @override + void setOutputVolume({required double decibel}) { + _outputVolume = decibel; + } +} + +class _TestPublicKey implements PublicKey { + @override + void dispose() {} + + @override + bool get isDisposed => false; +} + class _Telepathy implements Telepathy { int commits = 0; int disposals = 0; diff --git a/test/screens/settings/sections/audio_settings_test.dart b/test/screens/settings/sections/audio_settings_test.dart index a91727e4..97c98390 100644 --- a/test/screens/settings/sections/audio_settings_test.dart +++ b/test/screens/settings/sections/audio_settings_test.dart @@ -340,6 +340,9 @@ class _FakeTelepathy implements Telepathy { @override Future audioTest() => _audioTestCompleter.future; + @override + Future nodeAddr() async => null; + void completeAudioTestError() { _audioTestCompleter.completeError( const DartError(message: 'microphone unavailable'), diff --git a/test/screens/settings/sections/networking_test.dart b/test/screens/settings/sections/networking_test.dart index 01bc7f47..1bd6e83a 100644 --- a/test/screens/settings/sections/networking_test.dart +++ b/test/screens/settings/sections/networking_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -14,6 +15,9 @@ import 'package:telepathy/screens/settings/sections/networking.dart'; import 'package:telepathy/widgets/common/index.dart'; void main() { + const directInvitationNote = + 'Copy this invitation and ask recipients to paste it into Add direct contact.'; + setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); @@ -23,6 +27,94 @@ void main() { SharedPreferencesAsyncPlatform.instance = null; }); + testWidgets( + 'direct invitation stays copy-only while loading, available, and inactive', + (WidgetTester tester) async { + final loadingNodeAddr = Completer(); + final loadingRecorder = _NetworkConfigRecorder( + listenPort: 40142, + bindAddresses: const ['0.0.0.0', '::'], + ); + + await tester.pumpNetworkSettings( + controller: _FakeNetworkSettingsController(loadingRecorder), + stateController: StateController(), + telepathy: _FakeTelepathy(nodeAddrFuture: loadingNodeAddr.future), + ); + + expect(find.text(directInvitationNote), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + loadingNodeAddr.complete('node-address-with-fixed-port'); + await tester.pumpAndSettle(); + + expect(find.text(directInvitationNote), findsOneWidget); + expect(find.text('Copy invitation'), findsOneWidget); + expect(find.text('node-address-with-fixed-port'), findsNothing); + expect( + tester + .widget