mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +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>
182 lines
6.6 KiB
Swift
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
|