mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
### What changed? Buzz mobile checks the app-store age signal at launch and hard-blocks the app behind a full-screen 18+ notice when the store says the signed-in person is under 18. No age signal (e.g. declined permission, indeterminate value, unsupported OS versions) results in no block. The age value is never persisted. It is read, used to compute one boolean, and discarded. Nothing is written to disk, and neither the age value nor the range is logged or sent to analytics. Platform notes: - **New dependency `com.google.android.play:age-signals:0.0.4`.** This is the only way to read the Play signal. Its AAR declares `minSdkVersion 23`, below the app's 24, so it forces **no minSdk bump**, and it self-declares its own consent activity, so **no AndroidManifest change** is needed. - **iOS entitlement and linking.** `DeclaredAgeRange` autolinks from the import. `Runner.entitlements` now declares `com.apple.developer.declared-age-range`, and the call remains behind `#available(iOS 26.0, *)`. ### Why? App stores now expose a declared-age range, and Buzz's Terms require users to be 18 or older. This wires the store signal to that rule with the smallest change that is defensible. Absent signals, declined or undeterminable results, and unsupported OS versions allow the app through. A transient native request failure does not become evidence that access is allowed: the app remains gated until a retry produces a result. Only an affirmative under-18 signal produces the permanent restriction screen. ### How is it tested? Added tests: - [`age_signal_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) covers the 17/18 boundary, open-ended ranges, no-signal, transient recovery, bounded retry exhaustion, malformed payloads, and request-once behavior - [`age_gate_app_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) proves authenticated content remains unmounted while the signal is pending or retryable, and verifies the retry path - [`age_restriction_page_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate) asserts the block screen offers no bypass affordance - [`community_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/shared/community) verifies age restriction clears push state, persists inactive leases, and retries a failed tombstone - [`AgeSignalPayloadTest`](https://github.com/block/buzz/tree/main/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile) pins the Android success payload to exactly `{status, ageUpper}` and verifies failures return the distinct retryable platform error ### Deployment note Enable the Declared Age Range capability and regenerate signing profiles carrying the entitlement. This relops/App Store Connect task is not performed by this PR. *🤖 This PR was authored [with an agent](codex://threads/019e5128-536b-73b3-8348-88651123f6da).* | Age restriction screen | | --- | | <img src="https://github.com/user-attachments/assets/5b0f48b1-df9b-4a60-9800-80b33f128535" alt="Age restriction screen" width="320"> | --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com>
442 lines
15 KiB
Swift
442 lines
15 KiB
Swift
import BuzzPushKit
|
|
import Flutter
|
|
import Foundation
|
|
import Intents
|
|
import UserNotifications
|
|
|
|
final class BuzzPushSnapshotBridge {
|
|
private let appGroupIdentifier: String?
|
|
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 appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return nil }
|
|
return BuzzPushPresentationCacheStore(containerURL: container)
|
|
}()
|
|
private lazy var ageRestrictionFenceStore: BuzzAgeRestrictionFenceStore? = {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return nil }
|
|
return BuzzAgeRestrictionFenceStore(containerURL: container)
|
|
}()
|
|
|
|
init(
|
|
appGroupIdentifier: String?,
|
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore,
|
|
keychainAccessGroup: String?,
|
|
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.appGroupIdentifier = appGroupIdentifier
|
|
self.endpointGrantStore = endpointGrantStore
|
|
self.keychainAccessGroup = keychainAccessGroup
|
|
self.interactionDeletionDeadline = interactionDeletionDeadline
|
|
}
|
|
|
|
@discardableResult
|
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool {
|
|
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 store, let ageRestrictionFenceStore
|
|
else {
|
|
throw NSError(
|
|
domain: "BuzzPushSnapshotBridge",
|
|
code: 1,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey: "The age-restriction fence store is unavailable."
|
|
]
|
|
)
|
|
}
|
|
try ageRestrictionFenceStore.begin()
|
|
try store.replaceCommunities([])
|
|
try BuzzPushKeychain.replace(
|
|
signingKeys: [:],
|
|
accessGroup: self.keychainAccessGroup
|
|
)
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
self.interactionDeletionDeadline.deleteAll { error in
|
|
Self.complete(
|
|
result,
|
|
value: error.map {
|
|
FlutterError(
|
|
code: "age_restriction_purge_failed",
|
|
message: "Unable to fence and purge restricted notifications.",
|
|
details: $0.localizedDescription
|
|
)
|
|
}
|
|
)
|
|
}
|
|
} catch {
|
|
Self.complete(
|
|
result,
|
|
value: FlutterError(
|
|
code: "age_restriction_purge_failed",
|
|
message: "Unable to fence 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)
|
|
let settleFence = arguments["settleFence"] as? Bool ?? false
|
|
let replaceSnapshot = {
|
|
try store.replaceCommunities(decoded)
|
|
try BuzzPushKeychain.replace(
|
|
signingKeys: signingKeys,
|
|
accessGroup: self.keychainAccessGroup
|
|
)
|
|
}
|
|
if requiresStore && decoded.isEmpty {
|
|
guard let ageRestrictionFenceStore else {
|
|
throw NSError(
|
|
domain: "BuzzPushSnapshotBridge",
|
|
code: 2,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey: "The age-restriction fence store is unavailable."
|
|
]
|
|
)
|
|
}
|
|
let cleanup = { (completion: @escaping (Error?) -> Void) throws in
|
|
try replaceSnapshot()
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
self.interactionDeletionDeadline.deleteAll(completion: completion)
|
|
}
|
|
let completion = { (error: Error?) in
|
|
Self.complete(
|
|
result,
|
|
value: error.map {
|
|
FlutterError(
|
|
code: "snapshot_sync_failed",
|
|
message: "Unable to sync push community state.",
|
|
details: $0.localizedDescription
|
|
)
|
|
}
|
|
)
|
|
}
|
|
if settleFence {
|
|
try ageRestrictionFenceStore.performFencedAsyncCleanup(
|
|
cleanup,
|
|
completion: completion
|
|
)
|
|
} else {
|
|
try ageRestrictionFenceStore.begin()
|
|
try cleanup(completion)
|
|
}
|
|
return
|
|
} else {
|
|
try replaceSnapshot()
|
|
if requiresStore {
|
|
guard settleFence else {
|
|
throw NSError(
|
|
domain: "BuzzPushSnapshotBridge",
|
|
code: 3,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey:
|
|
"Only an allowed age-gate transition may restore push state."
|
|
]
|
|
)
|
|
}
|
|
// Only the acknowledged age-gate path may end a failed purge fence.
|
|
// An older best-effort export must never reopen notification access.
|
|
try ageRestrictionFenceStore?.settleIfFencing()
|
|
}
|
|
}
|
|
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 appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
),
|
|
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)
|
|
}
|
|
}
|
|
}
|