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>
244 lines
8.2 KiB
Swift
244 lines
8.2 KiB
Swift
import BuzzPushKit
|
|
import Foundation
|
|
import Intents
|
|
import Security
|
|
import UserNotifications
|
|
|
|
final class NotificationService: UNNotificationServiceExtension {
|
|
private var handoff: BuzzNotificationHandoff<UNNotificationContent>?
|
|
private var bestAttemptContent: UNMutableNotificationContent?
|
|
private var restrictedFallbackContent: UNMutableNotificationContent?
|
|
private var restrictionFenceAtStart = BuzzAgeRestrictionFence.initial
|
|
private let communicationPresenter = BuzzCommunicationNotificationPresenter()
|
|
private let interactionDeletionDeadline = BuzzInteractionDeletionDeadline(
|
|
timeout: 5,
|
|
deleteAllInteractions: { completion in
|
|
INInteraction.deleteAll(completion: completion)
|
|
},
|
|
scheduleTimeout: { delay, action in
|
|
DispatchQueue.global(qos: .utility).asyncAfter(
|
|
deadline: .now() + delay,
|
|
execute: action
|
|
)
|
|
}
|
|
)
|
|
private lazy var appGroupIdentifier =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
|
|
) as? String
|
|
private lazy var resolver: BuzzPushNotificationResolving = {
|
|
let keychainAccessGroup =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
|
) as? String
|
|
return BuzzPushNotificationResolver(
|
|
session: .shared,
|
|
loadCommunitiesData: { [self] in
|
|
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
|
},
|
|
loadPrivateKey: { communityID in
|
|
Self.loadPrivateKey(
|
|
communityID: communityID,
|
|
keychainAccessGroup: keychainAccessGroup
|
|
)
|
|
},
|
|
loadPresentationCacheData: { [self] in
|
|
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
|
}
|
|
)
|
|
}()
|
|
|
|
override func didReceive(
|
|
_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
) {
|
|
handoff = BuzzNotificationHandoff(handler: contentHandler)
|
|
restrictionFenceAtStart = Self.loadRestrictionFence(
|
|
appGroupIdentifier: appGroupIdentifier
|
|
)
|
|
restrictedFallbackContent = Self.restrictedFallback(from: request.content)
|
|
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
finish(request.content)
|
|
return
|
|
}
|
|
bestAttemptContent = content
|
|
var cleanUserInfo = content.userInfo
|
|
cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
content.userInfo = cleanUserInfo
|
|
|
|
resolver.resolve { [weak self] resolution in
|
|
guard let self else { return }
|
|
if let resolution {
|
|
content.title = resolution.title
|
|
content.body = resolution.body
|
|
if let subtitle = resolution.subtitle {
|
|
content.subtitle = subtitle
|
|
}
|
|
if let threadIdentifier = resolution.threadIdentifier {
|
|
content.threadIdentifier = threadIdentifier
|
|
}
|
|
if let navigationTarget = resolution.navigationTarget {
|
|
var userInfo = content.userInfo
|
|
userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue
|
|
content.userInfo = userInfo
|
|
}
|
|
self.bestAttemptContent = content
|
|
self.communicationPresenter.present(
|
|
ordinaryContent: content,
|
|
resolution: resolution,
|
|
isStillAllowed: { [weak self] in
|
|
self?.restrictionFenceIsUnchanged() ?? false
|
|
},
|
|
onDeletionFailure: { [weak self] _ in
|
|
self?.activateRestrictionFence()
|
|
}
|
|
) { [weak self] specializedContent in
|
|
self?.finish(specializedContent)
|
|
}
|
|
return
|
|
}
|
|
self.finish(content)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let bestAttemptContent {
|
|
finish(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
private func finish(_ content: UNNotificationContent) {
|
|
handoff?.finish(
|
|
content,
|
|
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
|
|
handoffIfAllowed: { deliver in
|
|
Self.handoffIfRestrictionFenceUnchanged(
|
|
appGroupIdentifier: appGroupIdentifier,
|
|
since: restrictionFenceAtStart,
|
|
handoff: deliver
|
|
)
|
|
}
|
|
) { [self] in
|
|
// The service deadline cannot wait for Intents cleanup. The safe content
|
|
// has already been handed back synchronously, including on expiration.
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
interactionDeletionDeadline.deleteAll { [weak self] error in
|
|
if error != nil { self?.activateRestrictionFence() }
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func restrictionFenceIsUnchanged() -> Bool {
|
|
!Self.loadRestrictionFence(
|
|
appGroupIdentifier: appGroupIdentifier
|
|
).requiresDiscard(since: restrictionFenceAtStart)
|
|
}
|
|
|
|
private static func handoffIfRestrictionFenceUnchanged(
|
|
appGroupIdentifier: String?,
|
|
since earlier: BuzzAgeRestrictionFence,
|
|
handoff: () -> Void
|
|
) -> Bool {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return false }
|
|
do {
|
|
return try BuzzAgeRestrictionFenceStore(containerURL: container)
|
|
.performIfUnchanged(since: earlier, handoff)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func activateRestrictionFence() {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return }
|
|
try? BuzzAgeRestrictionFenceStore(containerURL: container).begin()
|
|
}
|
|
|
|
private static func restrictedFallback(
|
|
from content: UNNotificationContent
|
|
) -> UNMutableNotificationContent {
|
|
let fallback =
|
|
(content.mutableCopy() as? UNMutableNotificationContent)
|
|
?? UNMutableNotificationContent()
|
|
fallback.title = "Buzz"
|
|
fallback.subtitle = ""
|
|
fallback.body = "Open Buzz to view this message."
|
|
fallback.threadIdentifier = ""
|
|
var userInfo = fallback.userInfo
|
|
userInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
fallback.userInfo = userInfo
|
|
return fallback
|
|
}
|
|
|
|
private static func loadRestrictionFence(
|
|
appGroupIdentifier: String?
|
|
) -> BuzzAgeRestrictionFence {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return .unavailable }
|
|
return BuzzAgeRestrictionFenceStore(containerURL: container).current()
|
|
}
|
|
|
|
private static func loadPrivateKey(
|
|
communityID: String,
|
|
keychainAccessGroup: String?
|
|
) -> String? {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "buzz.push.nse.signing",
|
|
kSecAttrAccount as String: communityID,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
if let keychainAccessGroup, !keychainAccessGroup.isEmpty {
|
|
query[kSecAttrAccessGroup as String] = keychainAccessGroup
|
|
}
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data
|
|
else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? {
|
|
loadAppGroupData(
|
|
fileName: BuzzPushPresentationCacheStore.fileName,
|
|
appGroupIdentifier: appGroupIdentifier,
|
|
maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes
|
|
)
|
|
}
|
|
|
|
private static func loadAppGroupData(
|
|
fileName: String,
|
|
appGroupIdentifier: String?,
|
|
maximumBytes: Int? = nil
|
|
) -> Data? {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return nil }
|
|
let fileURL = container.appendingPathComponent(fileName)
|
|
if let maximumBytes {
|
|
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
let fileSize = values.fileSize,
|
|
fileSize <= maximumBytes
|
|
else { return nil }
|
|
}
|
|
return try? Data(contentsOf: fileURL)
|
|
}
|
|
}
|