Files
buzz/mobile/lib/shared/push/push_bridge.dart
Tom Brow f6f6d9a673 chore(mobile): delete dead code with no remaining UI path (#8880)
## Summary

Deletes mobile code that no longer has a user-reachable path and has no
counterpart in buzz-app, so it is unlikely to come back for parity. Net:
104 files, about 5,500 lines removed. No intended behavior change.

- **iOS native message menu and action surface:**
`NativeMessageMenu.swift` and `NativeMessageActionSurface.swift`, the
`message`/`supportsMessage` coordinator branches, and the Dart selection
hook. Dart stopped creating this view in
[#8280](https://github.com/block/buzz/pull/8280). The native reactions
sheet in the same coordinator is kept.
- **Old home quick-actions launcher and `ComposeMorphIcon`:** only
instantiated in tests since the nav-bar creation actions replaced them.
- **Retired local theme stack:** `ThemeNotifier`, `AccentNotifier`,
`SchemeNotifier`, `adaptive_theme.dart`, `AccentPickerPage`, the
appearance-mode sheet and their helpers. These predate per-community
theme sync. The legacy prefs migration in
`community_theme_preference.dart` is kept.
- **Unused widgets:** `ProfileAvatar` and `MaskedAvatarBadge`.
- **Push permission-status plumbing:** the iOS
`notificationAuthorizationStatus`/`openNotificationSettings` handlers,
plus the uncalled strict age-gate snapshot registration and its Swift
`syncAgeGatePushSnapshot` path.
- **Orphaned files** with no importer: `profile_text_edit_sheet`,
`editor_option_button`, `retained_page_transition`,
`tappable_flapping_bee`, `agent_model_info`, `community_bot_profiles`,
`sheet_divider`, `success_haptic`.
- **Uncalled helpers:** the `pickAndUpload*`/camera helpers in
`media_upload.dart`, unused `NostrFilters` builders,
`mediaGetHeadersFor*`, and about 20 other uncalled functions, providers
and styles.
- **Removed-UI leftovers:** the never-wired invite directory providers,
`AuthNotifier.signOut` and `removeActiveCommunityForSignOut` (the Sign
out row is gone), `renameCommunity`,
`HomePage.profilePageBuilder`/`SettingsPage.embedded`, and the
create-forum branches of the create-channel sheet. Forum viewing is
unchanged.

Tests that only covered deleted code are removed. Tests of surviving
code that used deleted helpers as drivers were repointed to the
surviving API.

An audit of `mobile/android` found no dead Kotlin, resources, manifest
entries or Gradle dependencies, so Android code is unchanged.

## Validation

- `flutter analyze`: no issues.
- After rebasing onto main
([93b8b8f](https://github.com/block/buzz/commit/93b8b8febc)), `just
mobile-check` is clean (format and analyze), and `just mobile-test`
passes (4,070 passed, 35 skipped, 0 failed, plus the push-unconfigured
and secp256k1 suites).
- `dart format`: clean.
- Android: `just mobile-build-android` builds the debug APK, and
`./gradlew :app:testDebugUnitTest` passes.
- iOS: `build-for-testing` succeeds and BuzzPushKit `swift test` passes
(65 tests). In RunnerTests, 97 of 98 pass.
`testNativeNavigationTitleExpandsAgainAtTop` fails, but it fails the
same way on unmodified `main` (acf56fff6f) on the same machine (Xcode
27, iPhone 17 Pro simulator), so it is not caused by this change.
- Agent review of the diff ran. Its one blocker (stale `project.pbxproj`
entries for the deleted Swift files) is fixed.
- In-app smoke run at
[66141c1](66141c10b9):
a debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to
the internal community. The following flows all worked with no errors:
- Home: the New menu (channel, DM, workspace, chat) and the
create-channel sheet, including its channel-type options.
- A chat, with the message action sheet (reactions row, Reply, Copy
link, Remind me, Mark unread, Copy text, Edit and Delete).
  - Appearance: the previews, and cycling the System and Light modes.
- Settings: the profile header, the display name editor, and Edit photo
(Image and Emoji tabs).
  - The Agents directory.
- Search, including the Channels filter, plus opening a team channel
from the results.
- Tapping a reaction summary, which opens the native iOS Reactions
sheet.
- The recording isn't attached here because it shows internal channels
and messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:09:50 -08:00

272 lines
9.4 KiB
Dart

import 'dart:async';
import 'package:nostr/nostr.dart' as nostr;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../community/community.dart';
import '../deeplink/deep_link.dart';
import '../relay/relay_provider.dart';
import 'push_snapshot.dart';
const _channel = MethodChannel('buzz/push');
/// Latest APNs registration state, including callbacks replayed by iOS after
/// the Flutter method channel attaches.
final apnsDeviceToken = ValueNotifier<String?>(null);
final apnsRegistrationError = ValueNotifier<String?>(null);
final pushEndpointGrants = ValueNotifier<List<BuzzPushEndpointGrant>>([]);
final pushEndpointGrantError = ValueNotifier<String?>(null);
/// The most recent notification response waiting for app navigation.
///
/// Native iOS buffers cold-start responses until Dart asks for them. This
/// notifier also carries warm responses into the existing deep-link pipeline.
final pendingPushNotificationLink = ValueNotifier<MessageDeepLink?>(null);
MessageDeepLink? _pushNotificationLink(Object? arguments) {
if (arguments is! Map) return null;
final eventId = arguments['eventId'];
final communityId = arguments['communityId'];
final channelId = arguments['channelId'];
final threadRootId = arguments['threadRootId'];
if (eventId is! String ||
eventId.isEmpty ||
communityId is! String ||
communityId.isEmpty ||
channelId is! String ||
channelId.isEmpty ||
(threadRootId != null &&
(threadRootId is! String || threadRootId.isEmpty))) {
return null;
}
return MessageDeepLink(
communityId: communityId,
channelId: channelId,
messageId: eventId,
threadRootId: threadRootId as String?,
);
}
/// Pulls a notification response that arrived before the Flutter method
/// handler was installed.
Future<void> syncPendingBuzzPushNotificationResponse() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
final arguments = await _channel.invokeMapMethod<dynamic, dynamic>(
'takePendingNotificationResponse',
);
final link = _pushNotificationLink(arguments);
if (link != null) pendingPushNotificationLink.value = link;
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
/// Starts the independent iOS notification-authorization and APNs-registration
/// requests. Display authorization is intentionally not returned or persisted:
/// APNs registration and enrollment remain valid while display is denied.
Future<void> startBuzzPushRegistration() async {
if (!Env.pushGatewayConfigured ||
defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
try {
await _channel.invokeMethod<void>('startRegistration');
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
class BuzzPushEndpointGrant {
final String relayOrigin;
final String relayPubkey;
final String installationId;
final String endpointGrant;
final String endpointHash;
final int endpointEpoch;
final int generation;
final int expiresAt;
const BuzzPushEndpointGrant({
required this.relayOrigin,
required this.relayPubkey,
required this.installationId,
required this.endpointGrant,
required this.endpointHash,
required this.endpointEpoch,
required this.generation,
required this.expiresAt,
});
factory BuzzPushEndpointGrant.fromMap(Map<dynamic, dynamic> map) {
final generation = map['generation'] as int;
return BuzzPushEndpointGrant(
relayOrigin: map['relayOrigin'] as String,
relayPubkey: map['relayPubkey'] as String,
installationId: map['installationId'] as String,
endpointGrant: map['endpointGrant'] as String,
endpointHash: map['endpointHash'] as String,
endpointEpoch: map['endpointEpoch'] as int,
generation: generation,
expiresAt: map['expiresAt'] as int,
);
}
}
Future<List<BuzzPushEndpointGrant>> readBuzzPushEndpointGrants() async {
if (!Env.pushGatewayConfigured ||
defaultTargetPlatform != TargetPlatform.iOS) {
return const [];
}
try {
final raw = await _channel.invokeListMethod<dynamic>('endpointGrants', {
'gatewayUrl': Env.pushGatewayUrl,
});
final grants = [
for (final value in raw ?? const [])
BuzzPushEndpointGrant.fromMap(value as Map<dynamic, dynamic>),
];
pushEndpointGrants.value = grants;
pushEndpointGrantError.value = null;
return grants;
} catch (error) {
pushEndpointGrantError.value = error.toString();
rethrow;
}
}
/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward.
///
/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant
/// and accepted relay lease remain reusable and their generations do not move.
Future<BuzzPushEndpointGrant> enrollBuzzPush(
String relayUrl,
String gatewayUrl, {
List<Community>? communitiesForSnapshotRefresh,
}) async {
final raw = await _channel.invokeMapMethod<dynamic, dynamic>('enrollPush', {
'relayUrl': relayUrl,
'gatewayUrl': gatewayUrl,
});
if (raw == null) {
throw StateError('Native push enrollment returned no grant.');
}
final grant = BuzzPushEndpointGrant.fromMap(raw);
await readBuzzPushEndpointGrants();
if (communitiesForSnapshotRefresh != null) {
try {
await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh);
pushCommunitySnapshotError.value = null;
} catch (error, stackTrace) {
reportPushCommunitySnapshotError(error, stackTrace);
}
}
return grant;
}
/// Latest failure to export the community snapshot used by the iOS
/// notification service extension. Snapshot export is push enrichment and must
/// never gate authentication or community persistence.
final pushCommunitySnapshotError = ValueNotifier<String?>(null);
final pushLeaseCleanupError = ValueNotifier<String?>(null);
void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) {
pushCommunitySnapshotError.value = error.toString();
debugPrint('Push community snapshot export failed: $error');
debugPrintStack(stackTrace: stackTrace);
}
void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) {
pushLeaseCleanupError.value = error.toString();
debugPrint('Push lease cleanup failed: $error');
debugPrintStack(stackTrace: stackTrace);
}
/// Restores notification presentation without reading community storage.
Future<void> restoreAgeRestrictedBuzzNotifications() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
await _channel.invokeMethod<void>('restoreAgeRestrictedNotifications');
}
/// Removes notifications rendered before a confirmed age restriction.
Future<void> purgeAgeRestrictedBuzzNotifications() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
await _channel.invokeMethod<void>('purgeAgeRestrictedNotifications');
}
Future<void> registerBuzzPushCommunitySnapshot(
List<Community> communities,
) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
final snapshots = [
for (final community in communities)
if (community.pushNotificationsEnabled)
BuzzPushCommunitySnapshot(
id: community.id,
name: community.name,
relayUrl: community.relayUrl,
pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec),
subscriptions: community.pushSubscriptionState.authoritative,
),
];
final signingKeys = <String, String>{};
for (final community in communities) {
if (!community.pushNotificationsEnabled) continue;
final nsec = community.nsec;
if (nsec == null || nsec.isEmpty) continue;
try {
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec ||
decoded.data.length != 64) {
continue;
}
signingKeys[community.id] = decoded.data;
} catch (_) {
// Native storage is fail-closed; malformed keys are never exported.
}
}
await _channel.invokeMethod<void>('syncPushSnapshot', {
'section': 'communities',
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
'signingKeys': signingKeys,
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
void installBuzzPushMethodHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'apnsTokenChanged':
final args = call.arguments;
if (args is Map) {
final token = args['token'];
if (token is String && token.isNotEmpty) {
apnsDeviceToken.value = token;
apnsRegistrationError.value = null;
}
}
return null;
case 'apnsRegistrationFailed':
final args = call.arguments;
final message = args is Map ? args['message'] : null;
apnsRegistrationError.value = message is String && message.isNotEmpty
? message
: 'APNs registration failed';
debugPrint('APNs registration failed: ${apnsRegistrationError.value}');
return null;
case 'notificationOpened':
final link = _pushNotificationLink(call.arguments);
if (link == null) return 'ignored';
pendingPushNotificationLink.value = link;
return 'handled';
default:
throw MissingPluginException('Unknown buzz/push method ${call.method}');
}
});
}