diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 8a2593c777..736fdd2bce 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -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 diff --git a/mobile/README.md b/mobile/README.md index bfc796c74f..2bdd7ad7a9 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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 diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 439a9dc6e7..8660e4e3ed 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -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" diff --git a/mobile/ios/Flutter/PushEnabled.xcconfig b/mobile/ios/Flutter/PushEnabled.xcconfig deleted file mode 100644 index c088a384a5..0000000000 --- a/mobile/ios/Flutter/PushEnabled.xcconfig +++ /dev/null @@ -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== diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 29eee3fc77..cdacb9e89b 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -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" diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 09ec7a94df..e8a284b0d6 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -80,8 +80,6 @@ BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; 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 = ""; }; - BZZ00000000000000000028 /* RunnerPush.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = RunnerPush.entitlements; sourceTree = ""; }; - BZZ0000000000000000002D /* RunnerPush-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "RunnerPush-Info.plist"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 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 = ""; }; @@ -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", diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9779b032c4..f33cad45b4 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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? - 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? + 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 +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index baf248cfa5..998cc20a3d 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,6 +55,10 @@ Buzz needs photo library access so you can attach images to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + PHPhotoLibraryPreventAutomaticLimitedAccessAlert UIApplicationSceneManifest diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index ca4c6ace78..9b9c555456 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -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 + } +} diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index 1b21685566..6253f656ce 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -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 diff --git a/mobile/ios/Runner/PushPresentationCacheBridge.swift b/mobile/ios/Runner/PushPresentationCacheBridge.swift index b749349132..51894fcc79 100644 --- a/mobile/ios/Runner/PushPresentationCacheBridge.swift +++ b/mobile/ios/Runner/PushPresentationCacheBridge.swift @@ -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 diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 0c67376eba..7fca08a0f3 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -1,5 +1,20 @@ - + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.devicecheck.appattest-environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.usernotifications.communication + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + diff --git a/mobile/ios/Runner/RunnerPush-Info.plist b/mobile/ios/Runner/RunnerPush-Info.plist deleted file mode 100644 index 998cc20a3d..0000000000 --- a/mobile/ios/Runner/RunnerPush-Info.plist +++ /dev/null @@ -1,98 +0,0 @@ - - - - - BuzzAppGroupIdentifier - $(BUZZ_APP_GROUP_IDENTIFIER) - BuzzKeychainAccessGroup - $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - $(APP_DISPLAY_NAME) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Buzz - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLName - com.buzz.deeplink - CFBundleURLSchemes - - buzz - - - - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - FlutterDeepLinkingEnabled - - ITSAppUsesNonExemptEncryption - - LSRequiresIPhoneOS - - NSFaceIDUsageDescription - Buzz uses Face ID to confirm sensitive identity transfers. - NSCameraUsageDescription - Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. - NSPhotoLibraryUsageDescription - Buzz needs photo library access so you can attach images to messages. - NSPhotoLibraryAddUsageDescription - Buzz needs permission to save images to your photo library. - NSUserActivityTypes - - INSendMessageIntent - - PHPhotoLibraryPreventAutomaticLimitedAccessAlert - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/mobile/ios/Runner/RunnerPush.entitlements b/mobile/ios/Runner/RunnerPush.entitlements deleted file mode 100644 index 7fca08a0f3..0000000000 --- a/mobile/ios/Runner/RunnerPush.entitlements +++ /dev/null @@ -1,20 +0,0 @@ - - - - - aps-environment - $(BUZZ_IOS_PUSH_ENVIRONMENT) - com.apple.developer.devicecheck.appattest-environment - $(BUZZ_APP_ATTEST_ENVIRONMENT) - com.apple.developer.usernotifications.communication - - com.apple.security.application-groups - - $(BUZZ_APP_GROUP_IDENTIFIER) - - keychain-access-groups - - $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) - - - diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index a968302b3e..fef4f317fb 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -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; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index fb8ce50aeb..a793a5aa6a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 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 runBuzzApp(Widget app) async { runApp( ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: buzzPushCapabilityEnabled ? BuzzPushBootstrap(child: app) : app, + child: BuzzPushBootstrap(child: app), ), ); } diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index ceb2545cba..d37ff45a63 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -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 _deactivateCommunityPushLease(Community community) async { - if (!buzzPushCapabilityEnabled) return; final state = community.pushSubscriptionState; final acceptedGeneration = state.acceptedGeneration; final installationId = state.acceptedInstallationId; diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index bf22c55139..9b1d0a5fdd 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -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 _authorize(String relayBaseUrl) async { - await fetchBuzzPushLeaseDescriptor(relayBaseUrl); - await requestBuzzPushAuthorization(); - } - static Future _publish( WidgetRef ref, RelayConfig config, diff --git a/mobile/lib/shared/push/push_capability.dart b/mobile/lib/shared/push/push_capability.dart deleted file mode 100644 index 55aafb8516..0000000000 --- a/mobile/lib/shared/push/push_capability.dart +++ /dev/null @@ -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, -); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart index e42d94b829..5465c633a3 100644 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -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 cacheBuzzPushProfileEvents( String communityID, Iterable events, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || - communityID.isEmpty) { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } final verified = _boundedNewestEvents( @@ -66,9 +63,7 @@ Future cacheBuzzPushChannelEvents( Iterable metadataEvents, Iterable membershipEvents, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || - communityID.isEmpty) { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { return; } final batch = selectBoundedPushChannelEvents( @@ -172,8 +167,7 @@ Future cacheBuzzPushAvatarFromLoadedBytes( String sourceURL, Uint8List sourceBytes, ) async { - if (!buzzPushCapabilityEnabled || - defaultTargetPlatform != TargetPlatform.iOS || + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty || sourceBytes.isEmpty || sourceBytes.length > _maximumAvatarSourceBytes || diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart new file mode 100644 index 0000000000..8e5e42d2eb --- /dev/null +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -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 Function(String relayBaseUrl); + +final buzzPushDescriptorFetcherProvider = Provider( + (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((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 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 requestBuzzPushAuthorizationIfCapable( + BuzzPushLeaseDescriptor? descriptor, { + required Future Function() requestAuthorization, +}) async { + if (descriptor == null) return false; + return requestAuthorization(); +} diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart new file mode 100644 index 0000000000..696911fcdb --- /dev/null +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -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 [ + 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, +); diff --git a/scripts/test-ios-pbxproj-semantics.py b/scripts/test-ios-pbxproj-semantics.py index 5bb07bcc33..e2b2015904 100755 --- a/scripts/test-ios-pbxproj-semantics.py +++ b/scripts/test-ios-pbxproj-semantics.py @@ -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 diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index d9f0625a8a..56b09f41fe 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -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 'aps-environment' "$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 'com.apple.developer.devicecheck.appattest-environment' "$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 'com.apple.developer.devicecheck.appattest-environment' "$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