From 394d4a434b70f6c14587174bc9942b3ba1d0eb59 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 9 Oct 2026 10:44:01 -0700 Subject: [PATCH] fix(mobile): normalize relay URL before disabling push (#8201) Communities joined with a `ws://` or `wss://` relay URL cannot disable push: the opt-out path passes that URL directly to the HTTP-only NIP-11 fetcher, fails before publishing the inactive lease, and leaves the durable retry pending. This was reproduced in USB logs from TestFlight 0.20.1 (49). Normalize the URL with the existing push relay HTTP helper before fetching metadata, matching the revocation outbox's handling. No settings UI changes. Related: #8199 removes the status text but does not fix this synchronization failure; no duplicate fix found. ### Validation Four regression cases exercise the real community deactivator through its HTTP boundary. The WebSocket cases fail with the device's exact exception before the fix; all four pass afterward. All 37 focused community/revocation tests and targeted Flutter analysis pass. An initial full mobile run had three failures (recount timing and native export timeout/order); all three passed in isolated reruns. The full mobile checks and test suite passed on retry. The local repository-wide gate stopped during Rust compilation without a final result; a full local pass is not claimed. [GitHub CI](https://github.com/block/buzz/actions/runs/37865048873) passed, including mobile tests, Android build, Swift tests, iOS simulator build, and unsigned iOS release build. [Codex review](https://github.com/block/buzz/pull/8201#issuecomment-6071795750) reported no major issues. The installed TestFlight build is unchanged. After this fix lands, validate that upgrading the same installation clears its existing pending opt-out and leaves the relay lease inactive, without toggling notifications back on. Signed-off-by: Tom Brow --- .../shared/community/community_provider.dart | 4 +- .../community/push_opt_out_url_test.dart | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 mobile/test/shared/community/push_opt_out_url_test.dart diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 9cd24db0e8..f1e0cdef2b 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -130,7 +130,9 @@ Future _deactivateCommunityPushLease( } final decoded = nostr.Nip19.decode(payload: nsec); final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; - final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final descriptor = await fetchBuzzPushLeaseDescriptor( + canonicalBuzzPushRelayHttpUrl(community.relayUrl), + ); final matchingGrant = (await readBuzzPushEndpointGrants()) .where( (grant) => diff --git a/mobile/test/shared/community/push_opt_out_url_test.dart b/mobile/test/shared/community/push_opt_out_url_test.dart new file mode 100644 index 0000000000..12d9b637a4 --- /dev/null +++ b/mobile/test/shared/community/push_opt_out_url_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + for (final scheme in ['ws', 'wss', 'http', 'https']) { + test( + 'opt-out fetches relay metadata over HTTP for $scheme community', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final community = Community.create( + name: 'Test', + relayUrl: '$scheme://relay.example:8443', + nsec: nostr.Keys.generate().nsec, + ); + final requests = []; + final client = MockClient((request) async { + requests.add(request); + // Stop at the HTTP boundary, before native grants or publication. + return http.Response('test unavailable', 503); + }); + addTearDown(client.close); + + await http.runWithClient(() async { + await expectLater( + container.read(communityPushLeaseDeactivatorProvider)( + community, + generation: 2, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('NIP-11 request failed with HTTP 503'), + ), + ), + ); + }, () => client); + + final httpScheme = scheme == 'ws' || scheme == 'http' + ? 'http' + : 'https'; + expect( + requests.single.url, + Uri.parse('$httpScheme://relay.example:8443/'), + ); + expect(requests.single.headers['Accept'], 'application/nostr+json'); + }, + ); + } +}