Files
buzz/mobile/ios/Runner/PushSnapshotBridge.swift
Tom Brow 779af8886c fix(mobile): fail open when age checks are unavailable (#7714)
Age-check errors previously blocked Buzz startup. Normal app startup now
proceeds immediately; only a valid under-18 response from the current
launch request can restrict access.

Age check enforcement remains disabled at compile time by default.
Builds can opt in with `--dart-define=BUZZ_AGE_GATING_ENABLED=true`.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-17 16:44:08 -07:00

387 lines
13 KiB
Swift

import BuzzPushKit
import Flutter
import Foundation
import Intents
import UserNotifications
final class BuzzPushSnapshotBridge {
private let containerURL: () -> URL?
private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore
private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline
private let keychainAccessGroup: String?
private let queue = DispatchQueue(
label: "xyz.block.buzz.push-snapshot",
qos: .utility
)
private lazy var store: BuzzPushPresentationCacheStore? = {
guard let container = containerURL() else { return nil }
return BuzzPushPresentationCacheStore(containerURL: container)
}()
private let ageRestrictionSession = BuzzAgeRestrictionSession()
init(
appGroupIdentifier: String?,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore,
keychainAccessGroup: String?,
containerURL: (() -> URL?)? = nil,
interactionDeletionDeadline: BuzzInteractionDeletionDeadline =
BuzzInteractionDeletionDeadline(
timeout: 5,
deleteAllInteractions: { completion in
INInteraction.deleteAll(completion: completion)
},
scheduleTimeout: { delay, action in
DispatchQueue.global(qos: .utility).asyncAfter(
deadline: .now() + delay,
execute: action
)
}
)
) {
self.containerURL = containerURL ?? {
guard let appGroupIdentifier else { return nil }
return FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
self.endpointGrantStore = endpointGrantStore
self.keychainAccessGroup = keychainAccessGroup
self.interactionDeletionDeadline = interactionDeletionDeadline
}
@discardableResult
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool {
if call.method == "restoreAgeRestrictedNotifications" {
queue.async { [self] in
ageRestrictionSession.release()
BuzzInteractionCleanupRetry(
containerURL: containerURL(), deletion: interactionDeletionDeadline
).retryPending { error in
Self.complete(result, value: error.map {
FlutterError(code: "interaction_cleanup_retry_failed",
message: "Unable to finish pending interaction cleanup.",
details: $0.localizedDescription)
})
}
}
return true
}
if call.method == "purgeAgeRestrictedNotifications" {
purgeAgeRestrictedNotifications(result: result)
return true
}
let strictAgeGateWrite = call.method == "syncAgeGatePushSnapshot"
guard strictAgeGateWrite || call.method == "syncPushSnapshot",
let arguments = call.arguments as? [String: Any],
let section = arguments["section"] as? String
else {
return false
}
switch section {
case "communities":
syncCommunities(
arguments,
requiresStore: strictAgeGateWrite,
result: result
)
case "profiles": cacheProfiles(arguments, result: result)
case "channels": cacheChannels(arguments, result: result)
case "avatar": cacheAvatar(arguments, result: result)
default: return false
}
return true
}
private func purgeAgeRestrictedNotifications(result: @escaping FlutterResult) {
queue.async { [weak self] in
do {
guard let self, let container = containerURL() else {
throw NSError(
domain: "BuzzPushSnapshotBridge",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "The age-restriction session container is unavailable."
]
)
}
try ageRestrictionSession.restrict(containerURL: container)
// Preserve snapshots and credentials. Restriction authority expires
// with this process even when cleanup or a later storage read fails.
let center = UNUserNotificationCenter.current()
center.removeAllDeliveredNotifications()
center.removeAllPendingNotificationRequests()
BuzzInteractionCleanupRetry(
containerURL: container, deletion: interactionDeletionDeadline
).request { error in
Self.complete(
result,
value: error.map {
FlutterError(
code: "age_restriction_purge_failed",
message: "Unable to suppress and purge restricted notifications.",
details: $0.localizedDescription
)
}
)
}
} catch {
Self.complete(
result,
value: FlutterError(
code: "age_restriction_purge_failed",
message: "Unable to suppress and purge restricted notifications.",
details: error.localizedDescription
)
)
}
}
}
private func syncCommunities(
_ arguments: [String: Any],
requiresStore: Bool,
result: @escaping FlutterResult
) {
guard let communities = arguments["communities"] as? [[String: Any]],
let signingKeys = arguments["signingKeys"] as? [String: String],
communities.count <= BuzzPushPresentationCacheStore.maximumCommunities,
!requiresStore || arguments["settleFence"] is Bool
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected bounded communities and signing keys.",
details: nil
)
)
return
}
queue.async { [weak self] in
do {
guard let self else {
Self.complete(
result,
value: requiresStore
? FlutterError(
code: "snapshot_sync_unavailable",
message: "The push snapshot bridge is unavailable.",
details: nil
)
: nil
)
return
}
guard let store else {
Self.complete(
result,
value: requiresStore
? FlutterError(
code: "snapshot_sync_unavailable",
message: "The push snapshot store is unavailable.",
details: nil
)
: nil
)
return
}
// Relay-metadata enrichment is optional presentation state. A damaged
// grant cache must not block the core NSE snapshot and 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.relayMetadataPubkey(
relayURL: relayURL,
grants: grants
)
else { return community }
community["relayMetadataPubkey"] = relayMetadataPubkey
return community
}
let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys])
let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data)
// Ordinary snapshot maintenance never changes age access.
try store.replaceCommunities(decoded)
try BuzzPushKeychain.replace(
signingKeys: signingKeys,
accessGroup: self.keychainAccessGroup
)
Self.complete(result, value: nil)
} catch {
Self.complete(
result,
value: FlutterError(
code: "snapshot_sync_failed",
message: "Unable to sync push community state.",
details: error.localizedDescription
)
)
}
}
}
static func relayMetadataPubkey(
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 cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) {
guard let arguments = rawArguments as? [String: Any],
let communityID = arguments["communityId"] as? String,
let rawEvents = arguments["events"] as? [[String: Any]],
rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected communityId and profile events.",
details: nil
)
)
return
}
queue.async { [weak self] in
do {
guard let self, let community = community(id: communityID) else {
Self.complete(result, value: nil)
return
}
let events = try decodeEvents(rawEvents)
try store?.updateProfiles(
communityID: communityID,
relayOrigin: community.relayUrl,
updates: events.map { BuzzPushProfileCacheUpdate(event: $0) }
)
Self.complete(result, value: nil)
} catch {
Self.complete(
result,
value: FlutterError(
code: "profile_cache_failed",
message: "Unable to cache push sender profiles.",
details: error.localizedDescription
)
)
}
}
}
private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) {
guard let arguments = rawArguments as? [String: Any],
let communityID = arguments["communityId"] as? String,
let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]],
let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]],
rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels,
rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected communityId, channel metadata, and membership events.",
details: nil
)
)
return
}
queue.async { [weak self] in
do {
guard let self, let community = community(id: communityID),
let relayMetadataPubkey = community.relayMetadataPubkey
else {
Self.complete(result, value: nil)
return
}
try store?.updateChannels(
communityID: communityID,
relayOrigin: community.relayUrl,
relayMetadataPubkey: relayMetadataPubkey,
metadataEvents: try decodeEvents(rawMetadataEvents),
membershipEvents: try decodeEvents(rawMembershipEvents)
)
Self.complete(result, value: nil)
} catch {
Self.complete(
result,
value: FlutterError(
code: "channel_cache_failed",
message: "Unable to cache push channel metadata.",
details: error.localizedDescription
)
)
}
}
}
private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) {
guard let arguments = rawArguments as? [String: Any],
let communityID = arguments["communityId"] as? String,
let sourceURL = arguments["sourceUrl"] as? String,
let avatar = arguments["png"] as? FlutterStandardTypedData
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected an avatar source and PNG thumbnail.",
details: nil
)
)
return
}
let avatarData = avatar.data
queue.async { [weak self] in
do {
guard let self, let community = community(id: communityID) else {
Self.complete(result, value: false)
return
}
let updated =
try store?.updateAvatar(
communityID: communityID,
relayOrigin: community.relayUrl,
sourceURL: sourceURL,
avatarPNG: avatarData
) ?? false
Self.complete(result, value: updated)
} catch {
Self.complete(
result,
value: FlutterError(
code: "avatar_cache_failed",
message: "Unable to cache a push sender avatar.",
details: error.localizedDescription
)
)
}
}
}
private func community(id: String) -> PushLeaseCommunity? {
guard let container = containerURL(),
let data = try? Data(
contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)),
let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data)
else { return nil }
return snapshot.communities.first { $0.id == id }
}
private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] {
let data = try JSONSerialization.data(withJSONObject: rawEvents)
return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
}
private static func complete(_ result: @escaping FlutterResult, value: Any?) {
DispatchQueue.main.async {
result(value)
}
}
}