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 <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-09 10:44:01 -07:00
committed by GitHub
parent 16eb0b6685
commit 394d4a434b
2 changed files with 59 additions and 1 deletions

View File

@@ -130,7 +130,9 @@ Future<void> _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) =>

View File

@@ -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 = <http.Request>[];
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<StateError>().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');
},
);
}
}