diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6a6c57a673..6a9d38c203 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -65,6 +65,27 @@ typedef PairingSocketFactory = required void Function(Object? error) onDisconnected, }); + +String pairingEndpointNotFoundMessage(String relayUrl) { + final sanitized = relayUrl.trim(); + if (sanitized.isEmpty) { + return 'Pairing endpoint not found (404). The relay advertises NIP-43 without ' + 'a working /pair route. Ask the operator to set BUZZ_PAIRING_RELAY_URL or ' + 'route /pair to buzz-pair-relay, then create a new QR code on desktop.'; + } + return 'Pairing endpoint $sanitized not found (404). The relay advertises NIP-43 ' + 'without a working /pair route. Ask the operator to set BUZZ_PAIRING_RELAY_URL ' + 'or route /pair to buzz-pair-relay, then create a new QR code on desktop.'; +} + +bool pairingLooksLikeMissingLegacyEndpoint(Object error, {String? relayUrl}) { + final message = error.toString().toLowerCase(); + final mentions404 = message.contains('404') || message.contains('not found'); + final legacyPath = relayUrl?.contains('/pair') == true || message.contains('/pair'); + return mentions404 && legacyPath; +} + + class PairingNotifier extends Notifier { final PairingSocketFactory _socketFactory; PairingSocket? _socket; @@ -168,6 +189,7 @@ class PairingNotifier extends Notifier { Future _pairNipAb(String uri) async { state = const PairingState(status: PairingStatus.connecting); + String? pairingRelayUrl; try { // 1. Parse the nostrpair:// URI. @@ -175,7 +197,8 @@ class PairingNotifier extends Notifier { _sourcePubkey = qr.sourcePubkey; _sessionSecret = qr.sessionSecret; - final relayWsUrl = qr.relays.first; + pairingRelayUrl = qr.relays.first; + final relayWsUrl = pairingRelayUrl!; // 2. Generate ephemeral keypair. final keychain = nostr.Keys.generate(); @@ -258,13 +281,16 @@ class PairingNotifier extends Notifier { _cleanup(); state = PairingState( status: PairingStatus.error, - errorMessage: _friendlyErrorMessage(e), + errorMessage: _friendlyErrorMessage(e, relayUrl: pairingRelayUrl), ); } } - static String _friendlyErrorMessage(Object error) { + static String _friendlyErrorMessage(Object error, {String? relayUrl}) { final message = error.toString(); + if (pairingLooksLikeMissingLegacyEndpoint(error, relayUrl: relayUrl)) { + return pairingEndpointNotFoundMessage(relayUrl ?? ''); + } if (message.contains('SocketException') || message.contains('Connection refused') || message.contains('Network is unreachable') || diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6f49f71921..97a0fd2b9a 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -24,6 +24,32 @@ import 'package:buzz/shared/auth/auth.dart'; /// guards (private IPs, non-http schemes). /// - `reset()` returning to idle from an error state. void main() { + group('pairing endpoint diagnostics', () { + test('pairingEndpointNotFoundMessage mentions operator levers', () { + expect( + pairingEndpointNotFoundMessage('wss://relay.example/pair'), + contains('BUZZ_PAIRING_RELAY_URL'), + ); + }); + + test('pairingLooksLikeMissingLegacyEndpoint detects /pair 404s', () { + expect( + pairingLooksLikeMissingLegacyEndpoint( + Exception('WebSocket connection failed: HTTP error: 404 Not Found'), + relayUrl: 'wss://relay.example/pair', + ), + isTrue, + ); + expect( + pairingLooksLikeMissingLegacyEndpoint( + Exception('Connection refused'), + relayUrl: 'wss://relay.example/pair', + ), + isFalse, + ); + }); + }); + group('PairingNotifier', () { late ProviderContainer container; late FakeAuthNotifier fakeAuth;