Files
buzz/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.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

182 lines
6.6 KiB
Swift

import Foundation
/// Verified local values used to specialize an ordinary notification as communication.
public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
public let senderDisplayName: String
public let senderIdentifier: String
public let senderAvatarPNG: Data?
public let messageBody: String
public let conversationIdentifier: String
public let conversationDisplayName: String?
/// Verified recipients represented by the incoming message, excluding its sender.
public let recipientCount: Int
public init(
senderDisplayName: String,
senderIdentifier: String,
senderAvatarPNG: Data?,
messageBody: String,
conversationIdentifier: String,
conversationDisplayName: String?,
recipientCount: Int
) {
self.senderDisplayName = senderDisplayName
self.senderIdentifier = senderIdentifier
self.senderAvatarPNG = senderAvatarPNG
self.messageBody = messageBody
self.conversationIdentifier = conversationIdentifier
self.conversationDisplayName = conversationDisplayName
self.recipientCount = recipientCount
}
public init?(resolution: BuzzPushResolution) {
guard let target = resolution.navigationTarget,
let senderPubkey = resolution.senderPubkey,
!senderPubkey.isEmpty,
let conversationIdentifier = resolution.conversationIdentifier,
!conversationIdentifier.isEmpty,
let recipientCount = resolution.conversationRecipientCount,
recipientCount > 0
else { return nil }
self.init(
senderDisplayName: resolution.title,
senderIdentifier: BuzzPushPresentationIdentity.sender(
communityID: target.communityID,
pubkey: senderPubkey
),
senderAvatarPNG: resolution.senderAvatarPNG,
messageBody: resolution.body,
conversationIdentifier: conversationIdentifier,
conversationDisplayName: resolution.conversationDisplayName,
recipientCount: recipientCount
)
}
}
#if os(iOS)
import Intents
import UserNotifications
/// Donates and applies Apple's supported Communication Notifications intent.
public final class BuzzCommunicationNotificationPresenter {
public typealias Donation = (INInteraction, @escaping (Error?) -> Void) -> Void
/// Deletes previously donated interactions and reports when deletion finishes.
public typealias InteractionDeletion = (@escaping (Error?) -> Void) -> Void
public typealias ContentUpdate = (
UNMutableNotificationContent,
INSendMessageIntent
) throws -> UNNotificationContent
private let donate: Donation
private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline
private let updateContent: ContentUpdate
public convenience init() {
self.init(
donate: { interaction, completion in
interaction.donate(completion: completion)
},
deleteAllInteractions: { completion in
INInteraction.deleteAll(completion: completion)
},
updateContent: { content, intent in
try content.updating(from: intent)
}
)
}
public init(
donate: @escaping Donation,
deleteAllInteractions: @escaping InteractionDeletion,
updateContent: @escaping ContentUpdate,
scheduleDeletionTimeout: @escaping BuzzInteractionDeletionDeadline.ScheduleTimeout = { delay, action in
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay, execute: action)
}
) {
self.donate = donate
self.interactionDeletionDeadline = BuzzInteractionDeletionDeadline(
timeout: 5,
deleteAllInteractions: deleteAllInteractions,
scheduleTimeout: scheduleDeletionTimeout
)
self.updateContent = updateContent
}
/// Donates only while the caller's privacy fence remains unchanged.
public func present(
ordinaryContent: UNMutableNotificationContent,
resolution: BuzzPushResolution,
isStillAllowed: @escaping () -> Bool = { true },
onDeletionFailure: @escaping (Error) -> Void = { _ in },
completion: @escaping (UNNotificationContent) -> Void
) {
guard isStillAllowed(),
let descriptor = BuzzCommunicationNotificationDescriptor(resolution: resolution)
else {
completion(ordinaryContent)
return
}
let intent = Self.makeIntent(descriptor)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
donate(interaction) { [interactionDeletionDeadline, updateContent] error in
guard error == nil else {
completion(ordinaryContent)
return
}
guard isStillAllowed() else {
interactionDeletionDeadline.deleteAll { error in
if let error {
onDeletionFailure(error)
}
completion(ordinaryContent)
}
return
}
completion((try? updateContent(ordinaryContent, intent)) ?? ordinaryContent)
}
}
public static func makeIntent(
_ descriptor: BuzzCommunicationNotificationDescriptor
) -> INSendMessageIntent {
let senderAvatar = descriptor.senderAvatarPNG.map(INImage.init(imageData:))
let sender = INPerson(
personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown),
nameComponents: nil,
displayName: descriptor.senderDisplayName,
image: senderAvatar,
contactIdentifier: nil,
customIdentifier: descriptor.senderIdentifier,
isMe: false,
suggestionType: .none
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: descriptor.messageBody,
speakableGroupName: descriptor.conversationDisplayName.map {
INSpeakableString(spokenPhrase: $0)
},
conversationIdentifier: descriptor.conversationIdentifier,
serviceName: "Buzz",
sender: sender,
attachments: nil
)
if descriptor.conversationDisplayName != nil {
let donationMetadata = INSendMessageIntentDonationMetadata()
donationMetadata.recipientCount = descriptor.recipientCount
intent.donationMetadata = donationMetadata
if let senderAvatar {
// Communication Notifications render a group conversation's image
// from the speakable-group parameter rather than INPerson.image.
// Buzz channels do not have a separate avatar, so use the verified
// sender thumbnail for the visible incoming-message avatar.
intent.setImage(senderAvatar, forParameterNamed: \.speakableGroupName)
}
}
return intent
}
}
#endif