refactor(mobile): activate push from relay capability

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
Tom Brow
2026-08-24 11:35:20 -07:00
parent bf76a5300b
commit 2f5f8d89c6
24 changed files with 691 additions and 730 deletions

View File

@@ -118,17 +118,18 @@ capability—not a raw APNs token—into the encrypted relay lease.
## Internal dogfood evaluation and rollback
The MVP is ready to enable only when all of these are true: the internal iOS
artifact was built with `mobile/ios/Flutter/PushEnabled.xcconfig`; the canonical
gateway has the dogfood profile enabled with its server-owned App Attest app ID,
APNs topic, production certificate identity, and production APNs environment;
and only the selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`.
The App Store profile remains configured but dormant, and ordinary/App Store
iOS builds continue to omit the push capability and notification extension.
The MVP is ready to enable only when the canonical gateway has the dogfood
profile enabled with its server-owned App Attest app ID, APNs topic, production
certificate identity, and production APNs environment, and only the selected
internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS artifact
contains the native push bridge and Notification Service Extension, but the
client remains inactive until its current authenticated relay advertises a
fully valid NIP-11 `nip-pl` descriptor. The App Store gateway profile remains
configured but dormant.
Local physical-device development may instead use the normal
`xyz.block.buzz.mobile` development identity with the push overlay and sandbox
entitlements. Its local gateway must enable only the closed App Store profile,
`xyz.block.buzz.mobile` development identity with sandbox entitlements. Its
local gateway must enable only the closed App Store profile,
configured with that profile's server-owned App Attest application ID, APNs
topic, sandbox certificate, and sandbox environment. This is a development
integration proof, not dogfood release validation, and does not authorize
@@ -143,13 +144,12 @@ APNs delivery, fetched and signature-verified notification content, and
exact-message tap routing against the canonical gateway and a push-enabled
internal relay before widening the internal evaluation.
Before that first push-enabled candidate, the private dogfood builder must
include `PushEnabled.xcconfig` from its generated `AppOverrides.xcconfig`. Its
manual signing and export configuration must also map separate distribution
Before that first candidate, the private dogfood builder's manual signing and
export configuration must map separate distribution
profiles for both `xyz.block.buzz.dogfood.mobile` and
`xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does
not provision the extension. The App Store builder must continue omitting the
overlay and extension profile until that rollout is separately approved.
not provision the extension. App Store rollout remains off through relay and
gateway deployment configuration until separately approved.
Before enabling rich message presentation, enable Apple's Communication
Notifications capability on the parent dogfood App ID and regenerate its app
provisioning profile. The extension profile does not need that capability.
@@ -171,10 +171,9 @@ is designed.
Rollback does not require deleting credentials or mutating existing leases.
Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease
acceptance, matching, workers, and new gateway traffic; disable the dogfood
gateway profile if the gateway itself is unhealthy; and ship the next internal
build without the push overlay if client behavior must be removed. Existing
leases and gateway authorities then expire naturally. Do not enable the App
Store build capability or profile as part of this internal evaluation.
gateway profile if the gateway itself is unhealthy. Existing leases and gateway
authorities then expire naturally. Do not enable the App Store gateway profile
as part of this internal evaluation.
## Helm production inputs

View File

@@ -68,36 +68,24 @@ connected Android emulators, run `just mobile-clean` (add `--dry-run` via
`./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production
installs are never touched.
### Internal iOS push capability
### iOS push capability
iOS push is a compile/build capability and defaults off. A normal Debug,
Profile, Release, or App Store build excludes the native push bridge sources,
uses push-free Runner entitlements, and neither builds nor embeds the
Notification Service Extension. Dart also compiles out permission requests,
APNs registration, gateway enrollment/delegation, and relay lease behavior.
Every iOS artifact builds and embeds the Notification Service Extension and
native push bridge. Runtime activation is fail-closed and scoped to the current
relay: Buzz requests notification permission, registers with APNs, enrolls with
the gateway, and publishes a lease only after authenticated connectivity and a
fully valid NIP-11 `nip-pl` push descriptor. An absent, malformed, or
unreachable descriptor leaves push inactive without partial enrollment.
For an authorized internal dogfood build only, create the gitignored
`mobile/ios/Flutter/AppOverrides.xcconfig` with this single include:
```xcconfig
#include "PushEnabled.xcconfig"
```
The tracked overlay selects `xyz.block.buzz.dogfood.mobile`, production App
Attest/APNs entitlements, the internal development team, the push-capable
Runner entitlements, the native bridge, and the extension. CI may equivalently
inject that same include into its ephemeral `AppOverrides.xcconfig`; it must not
edit a tracked base configuration. Relay rollout is independent and remains off
unless its deployment sets `BUZZ_PUSH_ENABLED=true`. See
Relay rollout remains an explicit deployment opt-in. Only deployments with
`BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See
`docs/push-gateway-deployment.md` for the canonical gateway profile contract,
manual physical-device proof, measurements, and rollback procedure.
For local physical-device development, enable the same capability while
overriding the dogfood identity back to the normal mobile development identity
and sandbox environments:
For local physical-device development, override the identity and sandbox
environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`:
```xcconfig
#include "PushEnabled.xcconfig"
BUNDLE_IDENTIFIER = xyz.block.buzz.mobile
BUZZ_DEVELOPMENT_TEAM = EYF346PHUG
BUZZ_IOS_PUSH_ENVIRONMENT = development
@@ -112,14 +100,14 @@ artifact or enable the App Store profile in production. Validate dogfood APNs
end to end by cutting an internal release, waiting for it to reach Mobile
Releases/Comp Portal, and installing that signed artifact on a physical device.
Push-enabled parent app identifiers also require Apple's Communication
Parent app identifiers require Apple's Communication
Notifications capability and a regenerated app provisioning profile. The
Notification Service Extension profile does not require that capability.
Enable it on the personal development App ID for local rich-presentation
validation. Enabling it on the Block dogfood and eventual App Store App IDs is
a release follow-up and is not performed by this repository change. Without a
matching parent profile, source and unit validation still work, but the
push-enabled app cannot be signed for a physical device.
matching parent profile, source and unit validation still work, but the app
cannot be signed for a physical device.
APNs and the gateway continue to carry only the constant opaque wake-up. The
extension fetches the message from the scoped relay, verifies message, sender

View File

@@ -10,14 +10,12 @@ BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile
APP_DISPLAY_NAME = Buzz
BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3
// Push is a build capability, not a runtime rollout switch. Normal Debug
// builds compile out the bridge and exclude the extension product. Internal
// dogfood builds opt in by including PushEnabled.xcconfig from the ignored
// AppOverrides.xcconfig file.
BUZZ_PUSH_ENABLED = NO
BUZZ_INFO_PLIST = Runner/Info.plist
BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements
EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift
// Push support is always present in the iOS artifact. The current relay's
// fully validated NIP-11 descriptor is the runtime activation authority.
BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
BUZZ_IOS_PUSH_ENVIRONMENT = development
BUZZ_APP_ATTEST_ENVIRONMENT = development
// Worktree-aware debug identity (gitignored, written by
// scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier
@@ -26,7 +24,6 @@ EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift Pus
#include? "WorktreeOverrides.xcconfig"
// Developer app-specific overrides are included last: xcconfig
// later-include-wins is per variable, so a personal BUNDLE_IDENTIFIER for
// device signing beats the worktree default while unset variables still
// fall through to the worktree values.
// later-include-wins is per variable, so a personal signing identity beats the
// worktree default while unset variables still fall through to tracked values.
#include? "AppOverrides.xcconfig"

View File

@@ -1,16 +0,0 @@
// Complete internal iOS push capability overlay. This file is inert unless an
// ignored AppOverrides.xcconfig explicitly includes it.
BUZZ_PUSH_ENABLED = YES
BUZZ_INFO_PLIST = Runner/RunnerPush-Info.plist
BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile
BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3
BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
BUZZ_IOS_PUSH_ENVIRONMENT = production
BUZZ_APP_ATTEST_ENVIRONMENT = production
BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush.entitlements
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) BUZZ_PUSH_ENABLED
EXCLUDED_SOURCE_FILE_NAMES =
// BUZZ_PUSH_ENABLED=true, base64-encoded for Flutter's DART_DEFINES setting.
DART_DEFINES = $(inherited),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==

View File

@@ -10,10 +10,10 @@ CODE_SIGN_STYLE = Automatic
CODE_SIGN_IDENTITY = iPhone Developer
BUZZ_DEVELOPMENT_TEAM = EYF346PHUG
// App Store builds remain dormant until a later rollout. The internal build
// pipeline explicitly includes PushEnabled.xcconfig from AppOverrides.xcconfig.
BUZZ_PUSH_ENABLED = NO
BUZZ_INFO_PLIST = Runner/Info.plist
BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements
EXCLUDED_SOURCE_FILE_NAMES = NotificationService.appex PushNativeState.swift PushEndpointGrantStore.swift PushPresentationCacheBridge.swift
// Push support is always present in the iOS artifact. Relay advertisement is
// the rollout authority, so a relay without a valid descriptor remains inert.
BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER)
BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER)
BUZZ_IOS_PUSH_ENVIRONMENT = production
BUZZ_APP_ATTEST_ENVIRONMENT = production
#include? "AppOverrides.xcconfig"

View File

@@ -80,8 +80,6 @@
BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = "<group>"; };
BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = "<group>"; };
BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = "<group>"; };
BZZ0000000000000000002D /* RunnerPush-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "RunnerPush-Info.plist"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -229,8 +227,6 @@
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
BZZ0000000000000000000A /* Runner.entitlements */,
BZZ00000000000000000028 /* RunnerPush.entitlements */,
BZZ0000000000000000002D /* RunnerPush-Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
@@ -660,8 +656,8 @@
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)";
ENABLE_BITCODE = NO;
CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)";
INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -849,8 +845,8 @@
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)";
ENABLE_BITCODE = NO;
CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)";
INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -873,8 +869,8 @@
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)";
ENABLE_BITCODE = NO;
CODE_SIGN_ENTITLEMENTS = "$(BUZZ_CODE_SIGN_ENTITLEMENTS)";
INFOPLIST_FILE = "$(BUZZ_INFO_PLIST)";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -1,31 +1,26 @@
import AVFoundation
import BuzzPushKit
import Flutter
import UIKit
#if BUZZ_PUSH_ENABLED
import BuzzPushKit
import UserNotifications
#endif
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
#if BUZZ_PUSH_ENABLED
private var pushChannel: FlutterMethodChannel?
private let apnsRegistrationBuffer = APNsRegistrationBuffer()
private let pushNavigationBuffer = BuzzPushNavigationBuffer()
private var apnsDeviceToken: Data?
private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
)
private var enrollmentTask: Task<Void, Never>?
private var appGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
}
private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge(
appGroupIdentifier: appGroupIdentifier
)
#endif
private var pushChannel: FlutterMethodChannel?
private let apnsRegistrationBuffer = APNsRegistrationBuffer()
private let pushNavigationBuffer = BuzzPushNavigationBuffer()
private var apnsDeviceToken: Data?
private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
)
private var enrollmentTask: Task<Void, Never>?
private var appGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
}
private lazy var pushPresentationCacheBridge = BuzzPushPresentationCacheBridge(
appGroupIdentifier: appGroupIdentifier
)
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
@@ -37,9 +32,7 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
#if BUZZ_PUSH_ENABLED
UNUserNotificationCenter.current().delegate = self
#endif
UNUserNotificationCenter.current().delegate = self
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
@@ -53,18 +46,16 @@ import UIKit
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
#if BUZZ_PUSH_ENABLED
pushChannel = FlutterMethodChannel(
name: "buzz/push",
binaryMessenger: messenger
)
pushChannel?.setMethodCallHandler { [weak self] call, result in
self?.handlePushMethodCall(call, result: result)
}
apnsRegistrationBuffer.attach { [weak self] update in
self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments)
}
#endif
pushChannel = FlutterMethodChannel(
name: "buzz/push",
binaryMessenger: messenger
)
pushChannel?.setMethodCallHandler { [weak self] call, result in
self?.handlePushMethodCall(call, result: result)
}
apnsRegistrationBuffer.attach { [weak self] update in
self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments)
}
qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner",
binaryMessenger: messenger
@@ -169,7 +160,8 @@ import UIKit
if #available(iOS 16.0, *),
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMessageActionSurface"
) {
)
{
nativeMessageActionsRegistrar.register(
NativeMessageActionSurfaceFactory(messenger: messenger),
withId: "buzz/native_message_action_surface"
@@ -238,297 +230,295 @@ import UIKit
.safeAreaInsets.top ?? 0
}
#if BUZZ_PUSH_ENABLED
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
apnsDeviceToken = deviceToken
apnsRegistrationBuffer.recordToken(deviceToken)
}
override func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
apnsDeviceToken = deviceToken
apnsRegistrationBuffer.recordToken(deviceToken)
}
override func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
apnsRegistrationBuffer.recordError(error.localizedDescription)
}
override func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
apnsRegistrationBuffer.recordError(error.localizedDescription)
}
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo,
onTarget: { target in
pushNavigationBuffer.record(target)
deliverPushNavigationTarget(target)
},
forwardToFlutter: { pluginCompletion in
self.forwardPushNotificationResponseToFlutter(
center,
response: response,
completion: pluginCompletion
)
},
completion: completionHandler
)
}
private func forwardPushNotificationResponseToFlutter(
_ center: UNUserNotificationCenter,
response: UNNotificationResponse,
completion: @escaping () -> Void
) {
super.userNotificationCenter(
center,
didReceive: response,
withCompletionHandler: completion
)
}
private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) {
pushChannel?.invokeMethod(
"notificationOpened",
arguments: target.flutterArguments
) { [weak self] result in
guard result as? String == "handled" else { return }
self?.pushNavigationBuffer.remove(ifMatching: target)
}
}
private func handlePushMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
if pushPresentationCacheBridge.handle(call, result: result) {
return
}
switch call.method {
case "requestAuthorization":
requestPushAuthorization(result: result)
case "takePendingNotificationResponse":
result(pushNavigationBuffer.take()?.flutterArguments)
case "saveCommunitySnapshot":
guard let arguments = call.arguments as? [String: Any],
let communities = arguments["communities"] as? [[String: Any]],
let signingKeys = arguments["signingKeys"] as? [String: String]
else {
result(
FlutterError(
code: "invalid_arguments", message: "Expected communities array.", details: nil))
return
}
do {
try savePushCommunitySnapshot(communities)
try BuzzPushKeychain.replace(
signingKeys: signingKeys,
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup")
as? String
)
result(nil)
} catch {
result(
FlutterError(
code: "save_failed", message: "Unable to save push community credentials.",
details: error.localizedDescription))
}
case "endpointGrants":
do {
result(try endpointGrantStore.records().map(\.flutterArguments))
} catch {
result(
FlutterError(
code: "endpoint_grant_read_failed",
message: "Unable to read persisted push endpoint grants.",
details: error.localizedDescription
)
)
}
case "enrollPush":
handleDevPushEnrollment(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func requestPushAuthorization(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {
granted, error in
DispatchQueue.main.async {
if let error {
result(
FlutterError(
code: "notification_authorization_failed",
message: "Unable to request notification authorization.",
details: error.localizedDescription
)
)
return
}
guard granted else {
result(false)
return
}
UIApplication.shared.registerForRemoteNotifications()
result(true)
}
}
}
private func handleDevPushEnrollment(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard enrollmentTask == nil else {
result(
FlutterError(
code: "enrollment_in_progress",
message: "Development push enrollment is already running.",
details: nil
)
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo,
onTarget: { target in
pushNavigationBuffer.record(target)
deliverPushNavigationTarget(target)
},
forwardToFlutter: { pluginCompletion in
self.forwardPushNotificationResponseToFlutter(
center,
response: response,
completion: pluginCompletion
)
return
}
guard let deviceToken = apnsDeviceToken else {
result(
FlutterError(
code: "missing_apns_token",
message: "APNs has not supplied a device token.",
details: nil
)
)
return
}
guard !deviceToken.isEmpty else {
result(
FlutterError(
code: "invalid_apns_token",
message: "APNs supplied an empty device token.",
details: nil
)
)
return
}
},
completion: completionHandler
)
}
private func forwardPushNotificationResponseToFlutter(
_ center: UNUserNotificationCenter,
response: UNNotificationResponse,
completion: @escaping () -> Void
) {
super.userNotificationCenter(
center,
didReceive: response,
withCompletionHandler: completion
)
}
private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) {
pushChannel?.invokeMethod(
"notificationOpened",
arguments: target.flutterArguments
) { [weak self] result in
guard result as? String == "handled" else { return }
self?.pushNavigationBuffer.remove(ifMatching: target)
}
}
private func handlePushMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
if pushPresentationCacheBridge.handle(call, result: result) {
return
}
switch call.method {
case "requestAuthorization":
requestPushAuthorization(result: result)
case "takePendingNotificationResponse":
result(pushNavigationBuffer.take()?.flutterArguments)
case "saveCommunitySnapshot":
guard let arguments = call.arguments as? [String: Any],
let relayText = arguments["relayUrl"] as? String,
let relayURL = URL(string: relayText),
let gatewayText = arguments["gatewayUrl"] as? String,
let gatewayURL = URL(string: gatewayText)
let communities = arguments["communities"] as? [[String: Any]],
let signingKeys = arguments["signingKeys"] as? [String: String]
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Development push enrollment requires relayUrl and gatewayUrl.",
details: nil
)
)
code: "invalid_arguments", message: "Expected communities array.", details: nil))
return
}
do {
let driver: BuzzDevPushEnrollmentDriver
#if DEBUG
driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore
)
#else
driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore,
appAttestKeychainAccessGroup: Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String
)
#endif
enrollmentTask = Task { [weak self] in
defer { self?.enrollmentTask = nil }
do {
let record = try await driver.enroll(
deviceToken: deviceToken,
relayURL: relayURL
)
await MainActor.run { result(record.flutterArguments) }
} catch {
await MainActor.run {
result(
FlutterError(
code: "dev_enrollment_failed",
message: "Development push enrollment failed.",
details: error.localizedDescription
)
)
}
}
}
try savePushCommunitySnapshot(communities)
try BuzzPushKeychain.replace(
signingKeys: signingKeys,
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup")
as? String
)
result(nil)
} catch {
result(
FlutterError(
code: "dev_enrollment_configuration_failed",
message: "Development push enrollment is not configured.",
code: "save_failed", message: "Unable to save push community credentials.",
details: error.localizedDescription))
}
case "endpointGrants":
do {
result(try endpointGrantStore.records().map(\.flutterArguments))
} catch {
result(
FlutterError(
code: "endpoint_grant_read_failed",
message: "Unable to read persisted push endpoint grants.",
details: error.localizedDescription
)
)
}
case "enrollPush":
handleDevPushEnrollment(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func requestPushAuthorization(result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) {
granted, error in
DispatchQueue.main.async {
if let error {
result(
FlutterError(
code: "notification_authorization_failed",
message: "Unable to request notification authorization.",
details: error.localizedDescription
)
)
return
}
guard granted else {
result(false)
return
}
UIApplication.shared.registerForRemoteNotifications()
result(true)
}
}
}
private func handleDevPushEnrollment(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard enrollmentTask == nil else {
result(
FlutterError(
code: "enrollment_in_progress",
message: "Development push enrollment is already running.",
details: nil
)
)
return
}
guard let deviceToken = apnsDeviceToken else {
result(
FlutterError(
code: "missing_apns_token",
message: "APNs has not supplied a device token.",
details: nil
)
)
return
}
guard !deviceToken.isEmpty else {
result(
FlutterError(
code: "invalid_apns_token",
message: "APNs supplied an empty device token.",
details: nil
)
)
return
}
guard let arguments = call.arguments as? [String: Any],
let relayText = arguments["relayUrl"] as? String,
let relayURL = URL(string: relayText),
let gatewayText = arguments["gatewayUrl"] as? String,
let gatewayURL = URL(string: gatewayText)
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Development push enrollment requires relayUrl and gatewayUrl.",
details: nil
)
)
return
}
private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws {
guard let appGroupIdentifier else {
throw NSError(
domain: "BuzzPush", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"])
}
guard
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
else {
throw NSError(
domain: "BuzzPush", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"])
}
// Channel-name enrichment is optional presentation state. A damaged or
// unavailable grant cache must not block the core NSE snapshot/key update.
let grants = (try? endpointGrantStore.records()) ?? []
let enriched = communities.map { community -> [String: Any] in
var community = community
guard let relayURL = community["relayUrl"] as? String,
let relayMetadataPubkey = Self.pushRelayMetadataPubkey(
relayURL: relayURL,
grants: grants
do {
let driver: BuzzDevPushEnrollmentDriver
#if DEBUG
driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore
)
#else
driver = try BuzzDevPushEnrollmentDriver(
gatewayBaseURL: gatewayURL,
store: endpointGrantStore,
appAttestKeychainAccessGroup: Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String
)
#endif
enrollmentTask = Task { [weak self] in
defer { self?.enrollmentTask = nil }
do {
let record = try await driver.enroll(
deviceToken: deviceToken,
relayURL: relayURL
)
else { return community }
community["relayMetadataPubkey"] = relayMetadataPubkey
return community
await MainActor.run { result(record.flutterArguments) }
} catch {
await MainActor.run {
result(
FlutterError(
code: "dev_enrollment_failed",
message: "Development push enrollment failed.",
details: error.localizedDescription
)
)
}
}
}
let data = try JSONSerialization.data(
withJSONObject: ["communities": enriched], options: [.sortedKeys])
let destination = container.appendingPathComponent("push-communities.json")
try data.write(to: destination, options: [.atomic])
pushPresentationCacheBridge.retainCommunities(
Set(enriched.compactMap { $0["id"] as? String })
} catch {
result(
FlutterError(
code: "dev_enrollment_configuration_failed",
message: "Development push enrollment is not configured.",
details: error.localizedDescription
)
)
}
}
static func pushRelayMetadataPubkey(
relayURL: String,
grants: [BuzzPushEndpointGrantRecord]
) -> String? {
guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else {
return nil
}
return grants.filter {
$0.appProfile == BuzzDevPushEnrollmentDriver.appProfile
&& BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin
}.max {
$0.generation < $1.generation
}?.relayMetadataPubkey
private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws {
guard let appGroupIdentifier else {
throw NSError(
domain: "BuzzPush", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"])
}
#endif
guard
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
else {
throw NSError(
domain: "BuzzPush", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"])
}
// Channel-name enrichment is optional presentation state. A damaged or
// unavailable grant cache must not block the core NSE snapshot/key update.
let grants = (try? endpointGrantStore.records()) ?? []
let enriched = communities.map { community -> [String: Any] in
var community = community
guard let relayURL = community["relayUrl"] as? String,
let relayMetadataPubkey = Self.pushRelayMetadataPubkey(
relayURL: relayURL,
grants: grants
)
else { return community }
community["relayMetadataPubkey"] = relayMetadataPubkey
return community
}
let data = try JSONSerialization.data(
withJSONObject: ["communities": enriched], options: [.sortedKeys])
let destination = container.appendingPathComponent("push-communities.json")
try data.write(to: destination, options: [.atomic])
pushPresentationCacheBridge.retainCommunities(
Set(enriched.compactMap { $0["id"] as? String })
)
}
static func pushRelayMetadataPubkey(
relayURL: String,
grants: [BuzzPushEndpointGrantRecord]
) -> String? {
guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else {
return nil
}
return grants.filter {
$0.appProfile == BuzzDevPushEnrollmentDriver.appProfile
&& BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin
}.max {
$0.generation < $1.generation
}?.relayMetadataPubkey
}
private func handleMediaUploadMethodCall(
_ call: FlutterMethodCall,
@@ -969,14 +959,12 @@ import UIKit
}
}
#if BUZZ_PUSH_ENABLED
extension BuzzPushNavigationTarget {
fileprivate var flutterArguments: [String: String] {
[
"eventId": eventID,
"communityId": communityID,
"channelId": channelID,
]
}
extension BuzzPushNavigationTarget {
fileprivate var flutterArguments: [String: String] {
[
"eventId": eventID,
"communityId": communityID,
"channelId": channelID,
]
}
#endif
}

View File

@@ -55,6 +55,10 @@
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIApplicationSceneManifest</key>

View File

@@ -1,108 +1,106 @@
#if BUZZ_PUSH_ENABLED
import BuzzPushKit
import Foundation
import Security
import BuzzPushKit
import Foundation
import Security
/// Keychain-backed endpoint grant storage. The opaque grant is never written to
/// UserDefaults or logs. Dart can read the closed record through the push bridge.
final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore {
private static let service = "buzz.push.endpoint-grants"
private static let account = "v1"
/// Keychain-backed endpoint grant storage. The opaque grant is never written to
/// UserDefaults or logs. Dart can read the closed record through the push bridge.
final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore {
private static let service = "buzz.push.endpoint-grants"
private static let account = "v1"
private let accessGroup: String?
private let accessGroup: String?
init(accessGroup: String?) {
self.accessGroup = accessGroup
init(accessGroup: String?) {
self.accessGroup = accessGroup
}
func records() throws -> [BuzzPushEndpointGrantRecord] {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return [] }
guard status == errSecSuccess, let data = result as? Data else {
throw keychainError(status, operation: "read")
}
func records() throws -> [BuzzPushEndpointGrantRecord] {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return [] }
guard status == errSecSuccess, let data = result as? Data else {
throw keychainError(status, operation: "read")
}
do {
return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data)
} catch {
throw NSError(
domain: "BuzzPushEndpointGrantStore",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"]
)
}
}
func save(_ record: BuzzPushEndpointGrantRecord) throws {
var all = try records()
all.removeAll {
$0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile
}
all.append(record)
try replace(all)
}
private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws {
let data = try JSONEncoder().encode(records)
let updateStatus = SecItemUpdate(
baseQuery() as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw keychainError(updateStatus, operation: "update")
}
var add = baseQuery()
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw keychainError(addStatus, operation: "add")
}
}
private func baseQuery() -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func keychainError(_ status: OSStatus, operation: String) -> Error {
NSError(
domain: NSOSStatusErrorDomain,
code: Int(status),
userInfo: [
NSLocalizedDescriptionKey:
"Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)"
]
do {
return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data)
} catch {
throw NSError(
domain: "BuzzPushEndpointGrantStore",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"]
)
}
}
extension BuzzPushEndpointGrantRecord {
var flutterArguments: [String: Any] {
let arguments: [String: Any] = [
"relayOrigin": relayOrigin,
"relayPubkey": relayPubkey,
"installationId": installationId,
"endpointGrant": endpointGrant,
"endpointHash": endpointHash,
"appProfile": appProfile,
"endpointEpoch": endpointEpoch,
"generation": generation,
"expiresAt": expiresAt,
]
return arguments
func save(_ record: BuzzPushEndpointGrantRecord) throws {
var all = try records()
all.removeAll {
$0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile
}
all.append(record)
try replace(all)
}
private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws {
let data = try JSONEncoder().encode(records)
let updateStatus = SecItemUpdate(
baseQuery() as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw keychainError(updateStatus, operation: "update")
}
var add = baseQuery()
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw keychainError(addStatus, operation: "add")
}
}
#endif
private func baseQuery() -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func keychainError(_ status: OSStatus, operation: String) -> Error {
NSError(
domain: NSOSStatusErrorDomain,
code: Int(status),
userInfo: [
NSLocalizedDescriptionKey:
"Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)"
]
)
}
}
extension BuzzPushEndpointGrantRecord {
var flutterArguments: [String: Any] {
let arguments: [String: Any] = [
"relayOrigin": relayOrigin,
"relayPubkey": relayPubkey,
"installationId": installationId,
"endpointGrant": endpointGrant,
"endpointHash": endpointHash,
"appProfile": appProfile,
"endpointEpoch": endpointEpoch,
"generation": generation,
"expiresAt": expiresAt,
]
return arguments
}
}

View File

@@ -1,8 +1,7 @@
#if BUZZ_PUSH_ENABLED
import BuzzPushKit
import Foundation
import Security
import UserNotifications
import BuzzPushKit
import Foundation
import Security
import UserNotifications
final class BuzzOneShotCompletion {
private let lock = NSLock()
@@ -56,7 +55,10 @@ enum BuzzPushKeychain {
SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary)
throw NSError(
domain: NSOSStatusErrorDomain, code: Int(status),
userInfo: [NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? "Keychain write failed" as CFString]
userInfo: [
NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil)
?? "Keychain write failed" as CFString
]
)
}
query.removeValue(forKey: kSecValueData as String)
@@ -76,4 +78,3 @@ enum BuzzPushKeychain {
return query
}
}
#endif

View File

@@ -1,7 +1,6 @@
#if BUZZ_PUSH_ENABLED
import BuzzPushKit
import Flutter
import Foundation
import BuzzPushKit
import Flutter
import Foundation
final class BuzzPushPresentationCacheBridge {
private let appGroupIdentifier: String?
@@ -152,12 +151,13 @@ final class BuzzPushPresentationCacheBridge {
Self.complete(result, value: false)
return
}
let updated = try store?.updateAvatar(
communityID: communityID,
relayOrigin: community.relayUrl,
sourceURL: sourceURL,
avatarPNG: avatarData
) ?? false
let updated =
try store?.updateAvatar(
communityID: communityID,
relayOrigin: community.relayUrl,
sourceURL: sourceURL,
avatarPNG: avatarData
) ?? false
Self.complete(result, value: updated)
} catch {
Self.complete(
@@ -196,4 +196,3 @@ final class BuzzPushPresentationCacheBridge {
}
}
}
#endif

View File

@@ -1,5 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>aps-environment</key>
<string>$(BUZZ_IOS_PUSH_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(BUZZ_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
</array>
</dict>
</plist>

View File

@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuzzAppGroupIdentifier</key>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
<key>BuzzKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>$(APP_DISPLAY_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Buzz</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.buzz.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>buzz</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Buzz uses Face ID to confirm sensitive identity transfers.</string>
<key>NSCameraUsageDescription</key>
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(BUZZ_IOS_PUSH_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(BUZZ_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>$(BUZZ_APP_GROUP_IDENTIFIER)</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP)</string>
</array>
</dict>
</plist>

View File

@@ -20,7 +20,7 @@ import 'shared/auth/auth.dart';
import 'shared/deeplink/pending_deep_link_provider.dart';
import 'shared/emoji/emoji_burst.dart';
import 'shared/push/push_subscription_provider.dart';
import 'shared/push/push_capability.dart';
import 'shared/push/push_relay_capability_provider.dart';
import 'shared/relay/relay.dart';
import 'shared/read_state/read_state_provider.dart';
import 'shared/theme/theme.dart';
@@ -89,7 +89,7 @@ class App extends HookConsumerWidget {
ref.watch(observerRelayProvider);
ref.watch(appLifecycleProvider);
ref.watch(userStatusCacheProvider);
if (buzzPushCapabilityEnabled) {
if (ref.watch(currentRelayPushDescriptorProvider).value != null) {
ref.watch(pushSubscriptionSyncProvider);
}
hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0;

View File

@@ -4,7 +4,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'shared/push/push_bootstrap.dart';
import 'shared/push/push_capability.dart';
import 'shared/push/push_bridge.dart';
import 'shared/theme/theme_provider.dart';
@@ -12,10 +11,8 @@ void main() => runBuzzApp(const App());
Future<void> runBuzzApp(Widget app) async {
WidgetsFlutterBinding.ensureInitialized();
if (buzzPushCapabilityEnabled) {
installBuzzPushMethodHandler();
await syncPendingBuzzPushNotificationResponse();
}
installBuzzPushMethodHandler();
await syncPendingBuzzPushNotificationResponse();
// Pre-load preferences so the first frame uses the saved theme/accent.
final prefs = await SharedPreferences.getInstance();
@@ -23,7 +20,7 @@ Future<void> runBuzzApp(Widget app) async {
runApp(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: buzzPushCapabilityEnabled ? BuzzPushBootstrap(child: app) : app,
child: BuzzPushBootstrap(child: app),
),
);
}

View File

@@ -4,7 +4,6 @@ import 'package:nostr/nostr.dart' as nostr;
import '../auth/auth_provider.dart';
import '../push/dev_push_lease.dart';
import '../push/push_bridge.dart';
import '../push/push_capability.dart';
import '../push/push_subscription.dart';
import '../relay/signed_event_relay.dart';
import 'community.dart';
@@ -39,7 +38,6 @@ final communityPushLeaseDeactivatorProvider =
});
Future<void> _deactivateCommunityPushLease(Community community) async {
if (!buzzPushCapabilityEnabled) return;
final state = community.pushSubscriptionState;
final acceptedGeneration = state.acceptedGeneration;
final installationId = state.acceptedInstallationId;

View File

@@ -11,6 +11,7 @@ import '../relay/relay_session.dart';
import '../relay/signed_event_relay.dart';
import 'dev_push_lease.dart';
import 'push_bridge.dart';
import 'push_relay_capability_provider.dart';
import 'push_subscription.dart';
/// Starts the push lifecycle only after authenticated relay connectivity and a
@@ -30,27 +31,40 @@ class BuzzPushBootstrap extends HookConsumerWidget {
final config = ref.watch(relayConfigProvider);
final community = ref.watch(activeCommunityProvider).value;
final memberPubkey = ref.watch(myPubkeyProvider);
final descriptor = ref.watch(currentRelayPushDescriptorProvider).value;
useEffect(() {
if (!_ready(session, config, community, memberPubkey)) return null;
final attempt = '${community!.id}|${config.baseUrl}';
if (authorizationAttempt.value == attempt) return null;
authorizationAttempt.value = attempt;
unawaited(
_authorize(config.baseUrl).catchError((Object error, StackTrace stack) {
authorizationAttempt.value = null;
debugPrint('Push authorization bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}),
);
return null;
}, [session.status, config.baseUrl, community?.id, memberPubkey]);
useEffect(
() {
if (!_ready(session, config, community, memberPubkey) ||
descriptor == null) {
return null;
}
final attempt = '${community!.id}|${config.baseUrl}';
if (authorizationAttempt.value == attempt) return null;
authorizationAttempt.value = attempt;
unawaited(() async {
try {
await requestBuzzPushAuthorizationIfCapable(
descriptor,
requestAuthorization: requestBuzzPushAuthorization,
);
} catch (error, stack) {
authorizationAttempt.value = null;
debugPrint('Push authorization bootstrap failed: $error');
debugPrintStack(stackTrace: stack);
}
}());
return null;
},
[session.status, config.baseUrl, community?.id, memberPubkey, descriptor],
);
final token = apnsDeviceToken.value;
final authorized = pushAuthorizationGranted.value;
useEffect(
() {
if (!_ready(session, config, community, memberPubkey) ||
descriptor == null ||
authorized != true ||
token == null) {
return null;
@@ -97,6 +111,7 @@ class BuzzPushBootstrap extends HookConsumerWidget {
community?.id,
community?.pushSubscriptionState,
memberPubkey,
descriptor,
authorized,
token,
],
@@ -118,11 +133,6 @@ class BuzzPushBootstrap extends HookConsumerWidget {
memberPubkey != null &&
memberPubkey.isNotEmpty;
static Future<void> _authorize(String relayBaseUrl) async {
await fetchBuzzPushLeaseDescriptor(relayBaseUrl);
await requestBuzzPushAuthorization();
}
static Future<void> _publish(
WidgetRef ref,
RelayConfig config,

View File

@@ -1,6 +0,0 @@
/// Compile-time iOS push capability. It is false for every normal build and is
/// injected as a Dart define only by the tracked PushEnabled.xcconfig overlay.
const buzzPushCapabilityEnabled = bool.fromEnvironment(
'BUZZ_PUSH_ENABLED',
defaultValue: false,
);

View File

@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../relay/nostr_models.dart';
import 'push_capability.dart';
const _pushPresentationChannel = MethodChannel('buzz/push');
// Keep these bridge payload bounds aligned with BuzzPushPresentationCacheStore.
@@ -42,9 +41,7 @@ Future<void> cacheBuzzPushProfileEvents(
String communityID,
Iterable<NostrEvent> events,
) async {
if (!buzzPushCapabilityEnabled ||
defaultTargetPlatform != TargetPlatform.iOS ||
communityID.isEmpty) {
if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) {
return;
}
final verified = _boundedNewestEvents(
@@ -66,9 +63,7 @@ Future<void> cacheBuzzPushChannelEvents(
Iterable<NostrEvent> metadataEvents,
Iterable<NostrEvent> membershipEvents,
) async {
if (!buzzPushCapabilityEnabled ||
defaultTargetPlatform != TargetPlatform.iOS ||
communityID.isEmpty) {
if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) {
return;
}
final batch = selectBoundedPushChannelEvents(
@@ -172,8 +167,7 @@ Future<void> cacheBuzzPushAvatarFromLoadedBytes(
String sourceURL,
Uint8List sourceBytes,
) async {
if (!buzzPushCapabilityEnabled ||
defaultTargetPlatform != TargetPlatform.iOS ||
if (defaultTargetPlatform != TargetPlatform.iOS ||
communityID.isEmpty ||
sourceBytes.isEmpty ||
sourceBytes.length > _maximumAvatarSourceBytes ||

View File

@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../community/community_provider.dart';
import '../relay/relay_provider.dart';
import '../relay/relay_session.dart';
import 'dev_push_lease.dart';
typedef BuzzPushDescriptorFetcher =
Future<BuzzPushLeaseDescriptor> Function(String relayBaseUrl);
final buzzPushDescriptorFetcherProvider = Provider<BuzzPushDescriptorFetcher>(
(ref) => fetchBuzzPushLeaseDescriptor,
);
/// The fully validated push capability advertised by the current relay.
///
/// Discovery fails closed. An absent, malformed, or unreachable NIP-11 push
/// descriptor is represented as no capability, so no notification permission,
/// APNs registration, gateway enrollment, or relay lease can begin.
final currentRelayPushDescriptorProvider =
FutureProvider.autoDispose<BuzzPushLeaseDescriptor?>((ref) async {
final session = ref.watch(relaySessionProvider);
final config = ref.watch(relayConfigProvider);
final community = ref.watch(activeCommunityProvider).value;
final memberPubkey = ref.watch(myPubkeyProvider);
if (session.status != SessionStatus.connected ||
community == null ||
config.nsec == null ||
config.nsec!.isEmpty ||
memberPubkey == null ||
memberPubkey.isEmpty) {
return null;
}
return discoverBuzzPushRelayCapability(
config.baseUrl,
fetchDescriptor: ref.read(buzzPushDescriptorFetcherProvider),
);
});
Future<BuzzPushLeaseDescriptor?> discoverBuzzPushRelayCapability(
String relayBaseUrl, {
required BuzzPushDescriptorFetcher fetchDescriptor,
}) async {
try {
return await fetchDescriptor(relayBaseUrl);
} catch (error, stackTrace) {
debugPrint('Current relay does not advertise valid push: $error');
debugPrintStack(stackTrace: stackTrace);
return null;
}
}
Future<bool> requestBuzzPushAuthorizationIfCapable(
BuzzPushLeaseDescriptor? descriptor, {
required Future<bool> Function() requestAuthorization,
}) async {
if (descriptor == null) return false;
return requestAuthorization();
}

View File

@@ -0,0 +1,73 @@
import 'package:buzz/shared/push/dev_push_lease.dart';
import 'package:buzz/shared/push/push_relay_capability_provider.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('valid capability permits notification authorization', () async {
var requests = 0;
final granted = await requestBuzzPushAuthorizationIfCapable(
_descriptor,
requestAuthorization: () async {
requests += 1;
return true;
},
);
expect(granted, isTrue);
expect(requests, 1);
});
test('missing or invalid capability cannot start authorization', () async {
var requests = 0;
final granted = await requestBuzzPushAuthorizationIfCapable(
null,
requestAuthorization: () async {
requests += 1;
return true;
},
);
expect(granted, isFalse);
expect(requests, 0);
});
for (final failure in <Object>[
const FormatException('malformed descriptor'),
StateError('relay unreachable'),
]) {
test('$failure keeps capability inactive without authorization', () async {
final descriptor = await discoverBuzzPushRelayCapability(
'https://relay.example',
fetchDescriptor: (_) async => throw failure,
);
var requests = 0;
final granted = await requestBuzzPushAuthorizationIfCapable(
descriptor,
requestAuthorization: () async {
requests += 1;
return true;
},
);
expect(descriptor, isNull);
expect(granted, isFalse);
expect(requests, 0);
});
}
}
const _descriptor = BuzzPushLeaseDescriptor(
origin: 'wss://relay.example',
executorKeyId: 'relay-v1',
executorPubkey:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
transport: 'apns',
maxLeaseTtlSeconds: 3600,
maxContentLength: 4096,
maxPlaintextLength: 4096,
maxEndpointLength: 2048,
maxStringLength: 512,
);

View File

@@ -13,9 +13,9 @@ EXPECTED_ROWS = """\
NotificationService Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService
NotificationService Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService
NotificationService Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService
Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER)
Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER)
Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) $(BUZZ_CODE_SIGN_ENTITLEMENTS) $(BUNDLE_IDENTIFIER)
Runner Debug Flutter/Debug.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER)
Runner Profile Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER)
Runner Release Flutter/Release.xcconfig $(BUZZ_DEVELOPMENT_TEAM) Runner/Runner.entitlements $(BUNDLE_IDENTIFIER)
RunnerTests Debug Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests
RunnerTests Profile Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests
RunnerTests Release Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests

View File

@@ -123,10 +123,8 @@ grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properti
# ── Tracked build files: overrides are debug-only, release stays production ──
debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig"
release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig"
push_xcconfig="$repo_root/mobile/ios/Flutter/PushEnabled.xcconfig"
pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj"
runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements"
runner_push_entitlements="$repo_root/mobile/ios/Runner/RunnerPush.entitlements"
gradle="$repo_root/mobile/android/app/build.gradle.kts"
manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml"
plist="$repo_root/mobile/ios/Runner/Info.plist"
@@ -201,44 +199,25 @@ assert_single_xcconfig_declaration() {
}
for config in "$debug_xcconfig" "$release_xcconfig"; do
assert_xcconfig_value "$config" '^BUZZ_PUSH_ENABLED = NO$' \
"$(basename "$config") defaults push capability off"
assert_xcconfig_value "$config" \
'^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \
"$(basename "$config") uses the push-free Runner entitlements"
'^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \
"$(basename "$config") derives the push App Group from the bundle"
assert_xcconfig_value "$config" \
'^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift PushPresentationCacheBridge\.swift$' \
"$(basename "$config") excludes native push sources and extension product"
'^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \
"$(basename "$config") derives the push Keychain group from the bundle"
done
assert_xcconfig_value "$push_xcconfig" '^BUZZ_PUSH_ENABLED = YES$' \
"PushEnabled explicitly enables the capability"
assert_xcconfig_value "$push_xcconfig" \
'^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' \
"PushEnabled selects the internal dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
assert_xcconfig_value "$debug_xcconfig" \
'^BUZZ_IOS_PUSH_ENVIRONMENT = development$' \
"Debug uses sandbox APNs"
assert_xcconfig_value "$debug_xcconfig" \
'^BUZZ_APP_ATTEST_ENVIRONMENT = development$' \
"Debug uses development App Attest"
assert_xcconfig_value "$release_xcconfig" \
'^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \
"PushEnabled uses production APNs transport for distribution"
assert_xcconfig_value "$push_xcconfig" \
"Release uses production APNs"
assert_xcconfig_value "$release_xcconfig" \
'^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \
"PushEnabled uses production App Attest"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \
"PushEnabled derives the App Group from the dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \
"PushEnabled derives the Keychain access group from the dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush\.entitlements$' \
"PushEnabled selects push-capable Runner entitlements"
assert_xcconfig_value "$push_xcconfig" \
'^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) BUZZ_PUSH_ENABLED$' \
"PushEnabled compiles the native push bridge"
assert_xcconfig_value "$push_xcconfig" '^EXCLUDED_SOURCE_FILE_NAMES =$' \
"PushEnabled restores the extension product and native push sources"
assert_xcconfig_value "$push_xcconfig" \
'^DART_DEFINES = \$\(inherited\),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==$' \
"PushEnabled compiles the Dart push bootstrap"
"Release uses production App Attest"
assert_xcconfig_value "$release_xcconfig" \
'^CODE_SIGN_STYLE = Automatic$' \
"Release code signing style is declared as automatic"
@@ -246,15 +225,19 @@ assert_xcconfig_value "$release_xcconfig" \
'^CODE_SIGN_IDENTITY = iPhone Developer$' \
"Release code signing identity is declared as iPhone Developer"
for key in BUNDLE_IDENTIFIER BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES; do
for key in BUNDLE_IDENTIFIER; do
assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug"
assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release"
done
for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do
assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug"
assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release"
done
for key in BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES DART_DEFINES; do
assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug"
assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release"
assert_single_xcconfig_declaration "$push_xcconfig" "$key" "PushEnabled"
done
for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do
@@ -295,8 +278,8 @@ else
fi
grep -q '<key>aps-environment</key>' "$runner_entitlements" \
&& fail "push-free Runner entitlements must not contain aps-environment" \
|| pass "push-free Runner entitlements omit aps-environment"
&& pass "Runner entitlements always include APNs support" \
|| fail "Runner entitlements must include aps-environment"
# Split the retired identifiers so the regression test does not match itself.
retired_bundle_id='com.buzz.buzz'"Mobile"
@@ -305,14 +288,14 @@ if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then
else
pass "tracked files do not retain the retired iOS bundle identifier"
fi
grep -q '<key>com.apple.developer.devicecheck.appattest-environment</key>' "$runner_push_entitlements" \
&& pass "push-enabled Runner uses the App Attest entitlement key accepted by Apple" \
|| fail "push-enabled Runner must use com.apple.developer.devicecheck.appattest-environment"
grep -q '<key>com.apple.developer.devicecheck.appattest-environment</key>' "$runner_entitlements" \
&& pass "Runner uses the App Attest entitlement key accepted by Apple" \
|| fail "Runner must use com.apple.developer.devicecheck.appattest-environment"
retired_entitlement_key='com.apple.developer.app-attest.'"environment"
if grep -q "$retired_entitlement_key" "$runner_push_entitlements"; then
fail "push-enabled Runner must not retain the invalid App Attest entitlement key"
if grep -q "$retired_entitlement_key" "$runner_entitlements"; then
fail "Runner must not retain the invalid App Attest entitlement key"
else
pass "push-enabled Runner omits the invalid App Attest entitlement key"
pass "Runner omits the invalid App Attest entitlement key"
fi
duplicate_pbx_object_ids=$(awk '
@@ -507,9 +490,9 @@ expected_signing_map=$(printf '%s\n' \
'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \
'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \
'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"')
'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \
'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements' \
'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" Runner/Runner.entitlements')
if [[ "$signing_map" == "$expected_signing_map" ]]; then
pass "Runner and NotificationService signing settings match each build configuration"
else