Files
buzz/mobile/ios/Runner/PushSnapshotBridge.swift
Tom Brow f78a32cf03 feat(mobile): hard-block under-18 users on the store age signal (#4665)
### 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>
2026-09-15 05:21:27 -07:00

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)
}
}
}