mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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==
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 ||
|
||||
|
||||
61
mobile/lib/shared/push/push_relay_capability_provider.dart
Normal file
61
mobile/lib/shared/push/push_relay_capability_provider.dart
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user