mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
This commit is contained in:
@@ -157,6 +157,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.google.android.play:age-signals:0.0.4")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
@@ -10,6 +12,13 @@ import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMuxer
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.google.android.play.agesignals.AgeSignalsException
|
||||
import com.google.android.play.agesignals.model.AgeSignalsErrorCode
|
||||
import com.google.android.play.agesignals.AgeSignalsAccessRequest
|
||||
import com.google.android.play.agesignals.AgeSignalsManager
|
||||
import com.google.android.play.agesignals.AgeSignalsManagerFactory
|
||||
import com.google.android.play.agesignals.AgeSignalsRequest
|
||||
import com.google.android.play.agesignals.model.AgeSignalsStatus
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -18,6 +27,46 @@ import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.UUID
|
||||
|
||||
internal fun ageSignalPayload(ageUpper: Int?): Map<String, Any?> {
|
||||
return mapOf(
|
||||
"status" to "signal",
|
||||
"ageUpper" to ageUpper,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun noAgeSignalPayload(): Map<String, Any?> {
|
||||
return mapOf(
|
||||
"status" to "noSignal",
|
||||
"ageUpper" to null,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun replyWithAgeSignalError(
|
||||
result: MethodChannel.Result,
|
||||
error: Exception,
|
||||
) {
|
||||
// Missing/outdated Play installations and non-Play installs cannot supply
|
||||
// a signal. Preserve Buzz's unsupported-environment no-signal policy.
|
||||
// Transport, binding, SDK integration, and unknown failures stay gated.
|
||||
if (error is AgeSignalsException && error.errorCode in setOf(
|
||||
AgeSignalsErrorCode.API_NOT_AVAILABLE,
|
||||
AgeSignalsErrorCode.PLAY_STORE_NOT_FOUND,
|
||||
AgeSignalsErrorCode.PLAY_SERVICES_NOT_FOUND,
|
||||
AgeSignalsErrorCode.PLAY_STORE_VERSION_OUTDATED,
|
||||
AgeSignalsErrorCode.PLAY_SERVICES_VERSION_OUTDATED,
|
||||
AgeSignalsErrorCode.APP_NOT_OWNED,
|
||||
)
|
||||
) {
|
||||
result.success(noAgeSignalPayload())
|
||||
return
|
||||
}
|
||||
result.error(
|
||||
"age_signal_unavailable",
|
||||
"The age signal request failed.",
|
||||
error.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
|
||||
internal object AndroidImageProcessor {
|
||||
fun decodeSrgbBitmap(bytes: ByteArray): Bitmap? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -79,6 +128,9 @@ internal object AndroidImageProcessor {
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
private var mediaUploadChannel: MethodChannel? = null
|
||||
private var ageSignalChannel: MethodChannel? = null
|
||||
private var ageSignalRequestGeneration = 0
|
||||
private var pendingAgeSignalResult: MethodChannel.Result? = null
|
||||
private var huddleMediaPlugin: HuddleMediaPlugin? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
@@ -117,6 +169,107 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ageSignalChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
AGE_SIGNAL_CHANNEL,
|
||||
).also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
REQUEST_AGE_SIGNAL_METHOD -> {
|
||||
handleRequestAgeSignal(
|
||||
AgeSignalsManagerFactory.create(applicationContext),
|
||||
result,
|
||||
)
|
||||
}
|
||||
CANCEL_AGE_SIGNAL_METHOD -> cancelAgeSignalRequest(result)
|
||||
RESTART_AGE_SIGNAL_METHOD -> restartForAgeSignal(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRequestAgeSignal(
|
||||
ageSignalsManager: AgeSignalsManager,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
if (pendingAgeSignalResult != null) {
|
||||
result.error("age_signal_in_flight", "An age signal request is already active.", null)
|
||||
return
|
||||
}
|
||||
ageSignalRequestGeneration += 1
|
||||
val generation = ageSignalRequestGeneration
|
||||
pendingAgeSignalResult = result
|
||||
val accessRequest = AgeSignalsAccessRequest.builder()
|
||||
.setActivity(this)
|
||||
.build()
|
||||
ageSignalsManager.requestAgeSignalsAccess(accessRequest)
|
||||
.addOnSuccessListener { accessResult ->
|
||||
if (accessResult.ageSignalsStatus() != AgeSignalsStatus.SHARED) {
|
||||
completeAgeSignalRequest(generation, result) { replyWithNoAgeSignal(result) }
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
|
||||
ageSignalsManager.checkAgeSignals(AgeSignalsRequest.builder().build())
|
||||
.addOnSuccessListener { ageSignalsResult ->
|
||||
completeAgeSignalRequest(generation, result) {
|
||||
replyWithAgeSignal(result, ageSignalsResult.ageUpper())
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
completeAgeSignalRequest(generation, result) {
|
||||
replyWithAgeSignalError(result, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
completeAgeSignalRequest(generation, result) {
|
||||
replyWithAgeSignalError(result, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeAgeSignalRequest(
|
||||
generation: Int,
|
||||
result: MethodChannel.Result,
|
||||
reply: () -> Unit,
|
||||
) {
|
||||
if (generation != ageSignalRequestGeneration || pendingAgeSignalResult !== result) return
|
||||
pendingAgeSignalResult = null
|
||||
reply()
|
||||
}
|
||||
|
||||
private fun cancelAgeSignalRequest(result: MethodChannel.Result) {
|
||||
// Play age-signals 0.0.4 exposes non-cancellable Tasks. Retain the
|
||||
// original single flight rather than allowing an overlapping prompt.
|
||||
result.success(false)
|
||||
}
|
||||
|
||||
private fun restartForAgeSignal(result: MethodChannel.Result) {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (launchIntent == null) {
|
||||
result.error("age_signal_restart_failed", "Buzz could not restart.", null)
|
||||
return
|
||||
}
|
||||
result.success(false)
|
||||
window.decorView.post {
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
startActivity(launchIntent)
|
||||
finishAffinity()
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun replyWithAgeSignal(
|
||||
result: MethodChannel.Result,
|
||||
ageUpper: Int?,
|
||||
) {
|
||||
result.success(ageSignalPayload(ageUpper))
|
||||
}
|
||||
|
||||
private fun replyWithNoAgeSignal(result: MethodChannel.Result) {
|
||||
result.success(noAgeSignalPayload())
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -388,6 +541,10 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload"
|
||||
private const val AGE_SIGNAL_CHANNEL = "buzz/age_signal"
|
||||
private const val REQUEST_AGE_SIGNAL_METHOD = "requestAgeSignal"
|
||||
private const val CANCEL_AGE_SIGNAL_METHOD = "cancelAgeSignalRequest"
|
||||
private const val RESTART_AGE_SIGNAL_METHOD = "restartForAgeSignal"
|
||||
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
|
||||
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
|
||||
private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import com.google.android.play.agesignals.AgeSignalsException
|
||||
import com.google.android.play.agesignals.model.AgeSignalsErrorCode
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AgeSignalPayloadTest {
|
||||
@Test
|
||||
fun `signal payload contains only status and upper age bound`() {
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"status" to "signal",
|
||||
"ageUpper" to 17,
|
||||
),
|
||||
ageSignalPayload(17),
|
||||
)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"status" to "signal",
|
||||
"ageUpper" to null,
|
||||
),
|
||||
ageSignalPayload(null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no-signal payload contains only status and null upper age bound`() {
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"status" to "noSignal",
|
||||
"ageUpper" to null,
|
||||
),
|
||||
noAgeSignalPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `platform failures return a distinct retryable error`() {
|
||||
val result = RecordingResult()
|
||||
|
||||
replyWithAgeSignalError(result, IllegalStateException("transient"))
|
||||
|
||||
assertFalse(result.succeeded)
|
||||
assertEquals("age_signal_unavailable", result.errorCode)
|
||||
assertEquals("The age signal request failed.", result.errorMessage)
|
||||
assertEquals("IllegalStateException", result.errorDetails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unavailable Play environments return no signal`() {
|
||||
for (code in listOf(
|
||||
AgeSignalsErrorCode.API_NOT_AVAILABLE,
|
||||
AgeSignalsErrorCode.PLAY_STORE_NOT_FOUND,
|
||||
AgeSignalsErrorCode.PLAY_SERVICES_NOT_FOUND,
|
||||
AgeSignalsErrorCode.PLAY_STORE_VERSION_OUTDATED,
|
||||
AgeSignalsErrorCode.PLAY_SERVICES_VERSION_OUTDATED,
|
||||
AgeSignalsErrorCode.APP_NOT_OWNED,
|
||||
)) {
|
||||
val result = RecordingResult()
|
||||
replyWithAgeSignalError(result, AgeSignalsException(code))
|
||||
assertTrue(result.succeeded, "code=$code")
|
||||
assertEquals(mapOf("status" to "noSignal", "ageUpper" to null), result.payload)
|
||||
assertNull(result.errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient integration and unknown Play errors stay gated`() {
|
||||
for (code in listOf(
|
||||
AgeSignalsErrorCode.NETWORK_ERROR,
|
||||
AgeSignalsErrorCode.CANNOT_BIND_TO_SERVICE,
|
||||
AgeSignalsErrorCode.CLIENT_TRANSIENT_ERROR,
|
||||
AgeSignalsErrorCode.SDK_VERSION_OUTDATED,
|
||||
AgeSignalsErrorCode.INTERNAL_ERROR,
|
||||
-999,
|
||||
)) {
|
||||
val result = RecordingResult()
|
||||
replyWithAgeSignalError(result, AgeSignalsException(code))
|
||||
assertFalse(result.succeeded, "code=$code")
|
||||
assertEquals("age_signal_unavailable", result.errorCode)
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingResult : MethodChannel.Result {
|
||||
var succeeded = false
|
||||
var payload: Any? = null
|
||||
var errorCode: String? = null
|
||||
var errorMessage: String? = null
|
||||
var errorDetails: Any? = null
|
||||
|
||||
override fun success(result: Any?) {
|
||||
succeeded = true
|
||||
payload = result
|
||||
}
|
||||
|
||||
override fun error(
|
||||
errorCode: String,
|
||||
errorMessage: String?,
|
||||
errorDetails: Any?,
|
||||
) {
|
||||
this.errorCode = errorCode
|
||||
this.errorMessage = errorMessage
|
||||
this.errorDetails = errorDetails
|
||||
}
|
||||
|
||||
override fun notImplemented() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// Cross-process generation fence for age-restricted notification delivery.
|
||||
public struct BuzzAgeRestrictionFence: Codable, Equatable, Sendable {
|
||||
/// Opaque generation changed at both ends of a restricted cleanup.
|
||||
public let token: String
|
||||
|
||||
/// Whether native notification state is still being cleared.
|
||||
public let isFencing: Bool
|
||||
|
||||
/// Creates a persisted cross-process fence value.
|
||||
public init(token: String, isFencing: Bool) {
|
||||
self.token = token
|
||||
self.isFencing = isFencing
|
||||
}
|
||||
|
||||
/// In-memory sentinel used before a notification extension loads the store.
|
||||
public static let initial = BuzzAgeRestrictionFence(
|
||||
token: "initial",
|
||||
isFencing: false
|
||||
)
|
||||
|
||||
/// Fail-closed value used when the shared fence cannot be read.
|
||||
public static let unavailable = BuzzAgeRestrictionFence(
|
||||
token: "unavailable",
|
||||
isFencing: true
|
||||
)
|
||||
|
||||
/// Whether an extension started under [earlier] must discard its result.
|
||||
public func requiresDiscard(since earlier: BuzzAgeRestrictionFence) -> Bool {
|
||||
isFencing || token != earlier.token
|
||||
}
|
||||
}
|
||||
|
||||
/// The app begins a durable fence before clearing native notification state.
|
||||
/// A notification extension discards resolved content while the fence is
|
||||
/// active or whenever the token differs from the one captured at startup.
|
||||
public final class BuzzAgeRestrictionFenceStore: @unchecked Sendable {
|
||||
/// App-group file shared by Runner and the notification extension.
|
||||
public static let fileName = "age-restriction-fence.json"
|
||||
private static let lockFileName = "age-restriction-fence.lock"
|
||||
|
||||
private let fileURL: URL
|
||||
private let lockFileURL: URL
|
||||
private let beforeSettledWrite: (() -> Void)?
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Creates a fence store rooted in the app-group container.
|
||||
public convenience init(containerURL: URL) {
|
||||
self.init(containerURL: containerURL, beforeSettledWrite: nil)
|
||||
}
|
||||
|
||||
init(containerURL: URL, beforeSettledWrite: (() -> Void)?) {
|
||||
fileURL = containerURL.appendingPathComponent(Self.fileName)
|
||||
lockFileURL = containerURL.appendingPathComponent(Self.lockFileName)
|
||||
self.beforeSettledWrite = beforeSettledWrite
|
||||
}
|
||||
|
||||
/// Returns the latest fence, failing closed when persisted data is absent or
|
||||
/// malformed. An absent file can represent an upgraded installation whose
|
||||
/// legacy notification credentials have not passed the age gate yet.
|
||||
public func current() -> BuzzAgeRestrictionFence {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return (try? withProcessLock { loadLocked() }) ?? .unavailable
|
||||
}
|
||||
|
||||
/// Protects notifications before startup or an age request. If the shared
|
||||
/// fence cannot be written, removes presentation credentials independently,
|
||||
/// then propagates the failure so the caller stays gated and can retry.
|
||||
public static func beginLaunch(
|
||||
containerURL: URL?,
|
||||
clearPresentationCredentials: () throws -> Void
|
||||
) throws {
|
||||
do {
|
||||
guard let containerURL else {
|
||||
throw NSError(
|
||||
domain: "BuzzAgeRestrictionFenceStore",
|
||||
code: 1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "The push app-group container is unavailable."
|
||||
]
|
||||
)
|
||||
}
|
||||
try BuzzAgeRestrictionFenceStore(containerURL: containerURL).begin()
|
||||
} catch {
|
||||
// A failed atomic write can leave an older allowed fence readable. Remove
|
||||
// the extension's signing keys independently of the app-group filesystem.
|
||||
// Even successful removal must not turn a failed fence into an age result.
|
||||
try clearPresentationCredentials()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a durable cleanup phase with a fresh generation token.
|
||||
@discardableResult
|
||||
public func begin() throws -> BuzzAgeRestrictionFence {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return try withProcessLock {
|
||||
let fence = BuzzAgeRestrictionFence(
|
||||
token: UUID().uuidString.lowercased(),
|
||||
isFencing: true
|
||||
)
|
||||
try writeLocked(fence)
|
||||
return fence
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotates the durable fence before cleanup begins and settles it only after
|
||||
/// every cleanup write succeeds. A thrown cleanup leaves the fence active so
|
||||
/// notification extensions continue to fail closed.
|
||||
public func performFencedCleanup(_ cleanup: () throws -> Void) throws {
|
||||
let active = try begin()
|
||||
try cleanup()
|
||||
try settleIfFencing(expectedToken: active.token)
|
||||
}
|
||||
|
||||
/// Rotates the durable fence before asynchronous cleanup begins and settles
|
||||
/// it only after the cleanup callback acknowledges success. A thrown setup
|
||||
/// error or callback error leaves the fence active.
|
||||
public func performFencedAsyncCleanup(
|
||||
_ cleanup: (@escaping (Error?) -> Void) throws -> Void,
|
||||
completion: @escaping (Error?) -> Void
|
||||
) throws {
|
||||
let active = try begin()
|
||||
try cleanup { [self] error in
|
||||
guard error == nil else {
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try settleIfFencing(expectedToken: active.token)
|
||||
completion(nil)
|
||||
} catch {
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a synchronous handoff only when the persisted fence still
|
||||
/// matches [earlier], while keeping cleanup transitions ordered behind it.
|
||||
public func performIfUnchanged(
|
||||
since earlier: BuzzAgeRestrictionFence,
|
||||
_ handoff: () -> Void
|
||||
) throws -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return try withProcessLock {
|
||||
guard !loadLocked().requiresDiscard(since: earlier) else { return false }
|
||||
handoff()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes a cleanup phase with another generation change.
|
||||
@discardableResult
|
||||
public func settleIfFencing(expectedToken: String? = nil) throws -> BuzzAgeRestrictionFence {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return try withProcessLock {
|
||||
let current = loadLocked()
|
||||
guard current.isFencing,
|
||||
expectedToken == nil || current.token == expectedToken
|
||||
else { return current }
|
||||
beforeSettledWrite?()
|
||||
let settled = BuzzAgeRestrictionFence(
|
||||
token: UUID().uuidString.lowercased(),
|
||||
isFencing: false
|
||||
)
|
||||
try writeLocked(settled)
|
||||
return settled
|
||||
}
|
||||
}
|
||||
|
||||
private func withProcessLock<T>(_ operation: () throws -> T) throws -> T {
|
||||
let descriptor = open(lockFileURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
|
||||
guard descriptor >= 0 else { throw currentPOSIXError() }
|
||||
guard flock(descriptor, LOCK_EX) == 0 else {
|
||||
let error = currentPOSIXError()
|
||||
close(descriptor)
|
||||
throw error
|
||||
}
|
||||
defer {
|
||||
flock(descriptor, LOCK_UN)
|
||||
close(descriptor)
|
||||
}
|
||||
return try operation()
|
||||
}
|
||||
|
||||
private func currentPOSIXError() -> POSIXError {
|
||||
POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
|
||||
private func loadLocked() -> BuzzAgeRestrictionFence {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
return .unavailable
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let fence = try? JSONDecoder().decode(BuzzAgeRestrictionFence.self, from: data)
|
||||
else {
|
||||
return .unavailable
|
||||
}
|
||||
return fence
|
||||
}
|
||||
|
||||
private func writeLocked(_ fence: BuzzAgeRestrictionFence) throws {
|
||||
let data = try JSONEncoder().encode(fence)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// Converts Apple's age gates to the inclusive upper age used by Flutter.
|
||||
public enum BuzzAgeSignalPayload {
|
||||
/// Apple's upper bound is the gate the person is under; nil means unbounded.
|
||||
public static func sharing(exclusiveUpperBound: Int?) -> [String: Any] {
|
||||
let ageUpper = exclusiveUpperBound.map { ($0 - 1) as Any } ?? NSNull()
|
||||
return ["status": "signal", "ageUpper": ageUpper]
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,15 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
/// 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() {
|
||||
@@ -73,6 +76,9 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
donate: { interaction, completion in
|
||||
interaction.donate(completion: completion)
|
||||
},
|
||||
deleteAllInteractions: { completion in
|
||||
INInteraction.deleteAll(completion: completion)
|
||||
},
|
||||
updateContent: { content, intent in
|
||||
try content.updating(from: intent)
|
||||
}
|
||||
@@ -81,32 +87,53 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
|
||||
public init(
|
||||
donate: @escaping Donation,
|
||||
updateContent: @escaping ContentUpdate
|
||||
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 let descriptor = BuzzCommunicationNotificationDescriptor(resolution: resolution) else {
|
||||
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) { [updateContent] error in
|
||||
guard error == nil,
|
||||
let specialized = try? updateContent(ordinaryContent, intent)
|
||||
else {
|
||||
donate(interaction) { [interactionDeletionDeadline, updateContent] error in
|
||||
guard error == nil else {
|
||||
completion(ordinaryContent)
|
||||
return
|
||||
}
|
||||
completion(specialized)
|
||||
guard isStillAllowed() else {
|
||||
interactionDeletionDeadline.deleteAll { error in
|
||||
if let error {
|
||||
onDeletionFailure(error)
|
||||
}
|
||||
completion(ordinaryContent)
|
||||
}
|
||||
return
|
||||
}
|
||||
completion((try? updateContent(ordinaryContent, intent)) ?? ordinaryContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Bounds an asynchronous communication-interaction deletion operation.
|
||||
public final class BuzzInteractionDeletionDeadline {
|
||||
/// Callback-based interaction deletion operation.
|
||||
public typealias DeleteAllInteractions = (@escaping (Error?) -> Void) -> Void
|
||||
|
||||
/// Schedules a timeout action after the supplied interval.
|
||||
public typealias ScheduleTimeout = (TimeInterval, @escaping () -> Void) -> Void
|
||||
|
||||
private let deleteAllInteractions: DeleteAllInteractions
|
||||
private let scheduleTimeout: ScheduleTimeout
|
||||
private let timeout: TimeInterval
|
||||
|
||||
/// Creates a deadline around an injected deletion operation and scheduler.
|
||||
public init(
|
||||
timeout: TimeInterval,
|
||||
deleteAllInteractions: @escaping DeleteAllInteractions,
|
||||
scheduleTimeout: @escaping ScheduleTimeout
|
||||
) {
|
||||
self.timeout = timeout
|
||||
self.deleteAllInteractions = deleteAllInteractions
|
||||
self.scheduleTimeout = scheduleTimeout
|
||||
}
|
||||
|
||||
/// Deletes all interactions, failing once if the callback misses its deadline.
|
||||
public func deleteAll(completion: @escaping (Error?) -> Void) {
|
||||
let completion = BuzzOneShotErrorCompletion(completion)
|
||||
scheduleTimeout(timeout) {
|
||||
completion.complete(
|
||||
NSError(
|
||||
domain: "BuzzInteractionDeletionDeadline",
|
||||
code: 1,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Timed out deleting communication interactions."
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
deleteAllInteractions { error in
|
||||
completion.complete(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class BuzzOneShotErrorCompletion {
|
||||
private let lock = NSLock()
|
||||
private var completion: ((Error?) -> Void)?
|
||||
|
||||
init(_ completion: @escaping (Error?) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func complete(_ error: Error?) {
|
||||
lock.lock()
|
||||
let completion = completion
|
||||
self.completion = nil
|
||||
lock.unlock()
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
|
||||
/// Hands notification content back exactly once, before asynchronous cleanup.
|
||||
public final class BuzzNotificationHandoff<Content> {
|
||||
private let lock = NSRecursiveLock()
|
||||
private var handler: ((Content) -> Void)?
|
||||
|
||||
/// Creates a handoff shared by normal resolution and service expiration.
|
||||
public init(handler: @escaping (Content) -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
/// Checks the native fence and synchronously delivers content or its safe fallback.
|
||||
/// `handoffIfAllowed` must invoke its closure synchronously when returning true.
|
||||
/// Cleanup runs only after restricted content has been handed off.
|
||||
public func finish(
|
||||
_ content: Content,
|
||||
restrictedFallback: Content,
|
||||
handoffIfAllowed: (() -> Void) -> Bool,
|
||||
cleanup: () -> Void
|
||||
) {
|
||||
// Keep expiry from observing a consumed handler before delivery finishes.
|
||||
lock.lock()
|
||||
guard let handler else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
self.handler = nil
|
||||
let handedOff = handoffIfAllowed { handler(content) }
|
||||
if !handedOff { handler(restrictedFallback) }
|
||||
lock.unlock()
|
||||
if !handedOff { cleanup() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
struct BuzzAgeSignalPayloadTests {
|
||||
@Test func `Apple under 18 gate produces a restricted inclusive age`() {
|
||||
let payload = BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18)
|
||||
#expect(payload["status"] as? String == "signal")
|
||||
#expect(payload["ageUpper"] as? Int == 17)
|
||||
}
|
||||
|
||||
@Test func `Apple unbounded adult range stays unbounded`() {
|
||||
let payload = BuzzAgeSignalPayload.sharing(exclusiveUpperBound: nil)
|
||||
#expect(payload["status"] as? String == "signal")
|
||||
#expect(payload["ageUpper"] is NSNull)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#if os(iOS)
|
||||
import BuzzPushKit
|
||||
import UserNotifications
|
||||
import XCTest
|
||||
|
||||
final class BuzzCommunicationDeletionTimeoutTests: XCTestCase {
|
||||
func testLateDonationDeletionTimeoutCompletesOnce() {
|
||||
let ordinary = UNMutableNotificationContent()
|
||||
var allowed = true
|
||||
var timeout: (() -> Void)?
|
||||
var deletionCallback: ((Error?) -> Void)?
|
||||
var completions = 0
|
||||
var failures = 0
|
||||
let presenter = BuzzCommunicationNotificationPresenter(
|
||||
donate: { _, completion in
|
||||
allowed = false
|
||||
completion(nil)
|
||||
},
|
||||
deleteAllInteractions: { deletionCallback = $0 },
|
||||
updateContent: { content, _ in
|
||||
XCTFail("Restricted donation must not update content")
|
||||
return content
|
||||
},
|
||||
scheduleDeletionTimeout: { delay, action in
|
||||
XCTAssertEqual(delay, 5)
|
||||
timeout = action
|
||||
}
|
||||
)
|
||||
presenter.present(
|
||||
ordinaryContent: ordinary,
|
||||
resolution: communicationResolution(),
|
||||
isStillAllowed: { allowed },
|
||||
onDeletionFailure: { _ in failures += 1 }
|
||||
) { _ in completions += 1 }
|
||||
XCTAssertEqual(completions, 0)
|
||||
XCTAssertNotNil(timeout)
|
||||
timeout?()
|
||||
XCTAssertEqual(failures, 1)
|
||||
XCTAssertEqual(completions, 1)
|
||||
deletionCallback?(nil)
|
||||
XCTAssertEqual(failures, 1)
|
||||
XCTAssertEqual(completions, 1)
|
||||
}
|
||||
|
||||
private func communicationResolution(
|
||||
displayName: String = "Alice",
|
||||
groupName: String? = "General",
|
||||
avatarPNG: Data? = nil,
|
||||
recipientCount: Int? = 1
|
||||
) -> BuzzPushResolution {
|
||||
let communityID = "community-id"
|
||||
let channelID = "channel/general:v5"
|
||||
return BuzzPushResolution(
|
||||
title: displayName,
|
||||
body: "Hello Buzz",
|
||||
subtitle: "Community",
|
||||
threadIdentifier: BuzzPushPresentationIdentity.conversation(
|
||||
communityID: communityID,
|
||||
channelID: channelID
|
||||
),
|
||||
navigationTarget: BuzzPushNavigationTarget(
|
||||
eventID: "message-id",
|
||||
communityID: communityID,
|
||||
channelID: channelID
|
||||
),
|
||||
senderPubkey: String(repeating: "a", count: 64),
|
||||
senderAvatarPNG: avatarPNG,
|
||||
conversationIdentifier: BuzzPushPresentationIdentity.conversation(
|
||||
communityID: communityID,
|
||||
channelID: channelID
|
||||
),
|
||||
conversationDisplayName: groupName,
|
||||
conversationRecipientCount: recipientCount
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
@Suite("Interaction deletion deadline")
|
||||
struct BuzzInteractionDeletionDeadlineTests {
|
||||
@Test("A stalled deletion times out exactly once")
|
||||
func stalledDeletionTimesOutExactlyOnce() throws {
|
||||
var deletionCompletion: ((Error?) -> Void)?
|
||||
var timeoutAction: (() -> Void)?
|
||||
var completionErrors: [Error?] = []
|
||||
let deadline = BuzzInteractionDeletionDeadline(
|
||||
timeout: 5,
|
||||
deleteAllInteractions: { deletionCompletion = $0 },
|
||||
scheduleTimeout: { delay, action in
|
||||
#expect(delay == 5)
|
||||
timeoutAction = action
|
||||
}
|
||||
)
|
||||
|
||||
deadline.deleteAll { completionErrors.append($0) }
|
||||
|
||||
#expect(completionErrors.isEmpty)
|
||||
let fireTimeout = try #require(timeoutAction)
|
||||
fireTimeout()
|
||||
#expect(completionErrors.count == 1)
|
||||
#expect((completionErrors[0] as NSError?)?.code == 1)
|
||||
|
||||
let finishDeletion = try #require(deletionCompletion)
|
||||
finishDeletion(nil)
|
||||
#expect(completionErrors.count == 1)
|
||||
}
|
||||
|
||||
@Test("A completed deletion ignores the later timeout")
|
||||
func completedDeletionIgnoresLaterTimeout() throws {
|
||||
var deletionCompletion: ((Error?) -> Void)?
|
||||
var timeoutAction: (() -> Void)?
|
||||
var completionErrors: [Error?] = []
|
||||
let deadline = BuzzInteractionDeletionDeadline(
|
||||
timeout: 5,
|
||||
deleteAllInteractions: { deletionCompletion = $0 },
|
||||
scheduleTimeout: { _, action in timeoutAction = action }
|
||||
)
|
||||
|
||||
deadline.deleteAll { completionErrors.append($0) }
|
||||
|
||||
let finishDeletion = try #require(deletionCompletion)
|
||||
finishDeletion(nil)
|
||||
#expect(completionErrors.count == 1)
|
||||
#expect(completionErrors[0] == nil)
|
||||
|
||||
let fireTimeout = try #require(timeoutAction)
|
||||
fireTimeout()
|
||||
#expect(completionErrors.count == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
struct BuzzNotificationHandoffTests {
|
||||
@Test func `Expiration delivers fallback before stalled cleanup and ignores late resolution`() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let initial = store.current()
|
||||
try store.begin()
|
||||
var delivered: [String] = []
|
||||
var cleanupCount = 0
|
||||
var deletionCompletion: ((Error?) -> Void)?
|
||||
let deletion = BuzzInteractionDeletionDeadline(
|
||||
timeout: 5,
|
||||
deleteAllInteractions: { deletionCompletion = $0 },
|
||||
scheduleTimeout: { _, _ in }
|
||||
)
|
||||
let handoff = BuzzNotificationHandoff<String> { delivered.append($0) }
|
||||
func finish(_ content: String) {
|
||||
handoff.finish(
|
||||
content,
|
||||
restrictedFallback: "Open Buzz to view this message.",
|
||||
handoffIfAllowed: { deliver in
|
||||
(try? store.performIfUnchanged(since: initial, deliver)) ?? false
|
||||
},
|
||||
cleanup: {
|
||||
// Neither the Intents callback nor its timer needs to fire for delivery.
|
||||
#expect(delivered == ["Open Buzz to view this message."])
|
||||
cleanupCount += 1
|
||||
deletion.deleteAll { _ in }
|
||||
}
|
||||
)
|
||||
}
|
||||
// Resolution is still pending when the system calls expiration.
|
||||
finish("Reconnect to your relay now")
|
||||
#expect(delivered == ["Open Buzz to view this message."])
|
||||
#expect(deletionCompletion != nil)
|
||||
// Expiration during pending cleanup, followed by a late resolver callback.
|
||||
finish("Reconnect to your relay now")
|
||||
finish("Private message")
|
||||
#expect(delivered == ["Open Buzz to view this message."])
|
||||
#expect(cleanupCount == 1)
|
||||
}
|
||||
|
||||
@Test func `Unchanged fence delivers resolved content once without cleanup`() {
|
||||
var delivered: [String] = []
|
||||
let handoff = BuzzNotificationHandoff<String> { delivered.append($0) }
|
||||
for _ in 0..<2 {
|
||||
handoff.finish(
|
||||
"Resolved message",
|
||||
restrictedFallback: "Safe fallback",
|
||||
handoffIfAllowed: { deliver in deliver(); return true },
|
||||
cleanup: { Issue.record("Allowed delivery must not remove notifications") }
|
||||
)
|
||||
}
|
||||
#expect(delivered == ["Resolved message"])
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,253 @@ struct BuzzPushPresentationCacheTests {
|
||||
private let relayKey = String(repeating: "0", count: 63) + "2"
|
||||
private let otherRelayKey = String(repeating: "0", count: 63) + "3"
|
||||
|
||||
@Test("Age restriction fence persists a new token for other processes")
|
||||
func ageRestrictionFencePersistsGeneration() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let writer = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let reader = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
|
||||
#expect(reader.current() == .unavailable)
|
||||
let first = try writer.begin()
|
||||
#expect(first.isFencing)
|
||||
#expect(reader.current() == first)
|
||||
let second = try writer.settleIfFencing()
|
||||
#expect(!second.isFencing)
|
||||
#expect(second.token != first.token)
|
||||
#expect(reader.current() == second)
|
||||
}
|
||||
|
||||
@Test("Legacy notification state without a fence fails closed until restored")
|
||||
func legacyNotificationStateWithoutFenceFailsClosed() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let legacySnapshot = directory.appendingPathComponent(
|
||||
BuzzPushPresentationCacheStore.fileName
|
||||
)
|
||||
try Data("legacy notification snapshot".utf8).write(to: legacySnapshot)
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
|
||||
let beforeAgeCheck = store.current()
|
||||
#expect(beforeAgeCheck == .unavailable)
|
||||
#expect(beforeAgeCheck.requiresDiscard(since: beforeAgeCheck))
|
||||
|
||||
let restored = try store.settleIfFencing()
|
||||
#expect(!restored.isFencing)
|
||||
#expect(store.current() == restored)
|
||||
#expect(!restored.requiresDiscard(since: restored))
|
||||
}
|
||||
|
||||
@Test("Fenced cleanup rotates before work and settles only after success")
|
||||
func fencedCleanupOrdersTransitions() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let writer = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let reader = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let initial = reader.current()
|
||||
var observedDuringCleanup: BuzzAgeRestrictionFence?
|
||||
|
||||
try writer.performFencedCleanup {
|
||||
observedDuringCleanup = reader.current()
|
||||
}
|
||||
|
||||
let active = try #require(observedDuringCleanup)
|
||||
let settled = reader.current()
|
||||
#expect(active.isFencing)
|
||||
#expect(active.token != initial.token)
|
||||
#expect(!settled.isFencing)
|
||||
#expect(settled.token != active.token)
|
||||
}
|
||||
|
||||
@Test("Failed fenced cleanup remains active")
|
||||
func failedFencedCleanupRemainsActive() throws {
|
||||
struct CleanupFailure: Error {}
|
||||
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
|
||||
#expect(throws: CleanupFailure.self) {
|
||||
try store.performFencedCleanup {
|
||||
throw CleanupFailure()
|
||||
}
|
||||
}
|
||||
#expect(store.current().isFencing)
|
||||
}
|
||||
|
||||
@Test("Asynchronous fenced cleanup waits for acknowledged success")
|
||||
func asynchronousFencedCleanupWaitsForSuccess() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
var acknowledge: ((Error?) -> Void)?
|
||||
var didComplete = false
|
||||
|
||||
try store.performFencedAsyncCleanup(
|
||||
{ acknowledge = $0 },
|
||||
completion: { error in
|
||||
#expect(error == nil)
|
||||
didComplete = true
|
||||
}
|
||||
)
|
||||
|
||||
#expect(store.current().isFencing)
|
||||
#expect(!didComplete)
|
||||
let acknowledgeCleanup = try #require(acknowledge)
|
||||
acknowledgeCleanup(nil)
|
||||
#expect(!store.current().isFencing)
|
||||
#expect(didComplete)
|
||||
}
|
||||
|
||||
@Test("Failed asynchronous cleanup remains fenced")
|
||||
func failedAsynchronousCleanupRemainsFenced() throws {
|
||||
struct CleanupFailure: Error {}
|
||||
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
var reportedFailure = false
|
||||
|
||||
try store.performFencedAsyncCleanup(
|
||||
{ $0(CleanupFailure()) },
|
||||
completion: { error in
|
||||
reportedFailure = error is CleanupFailure
|
||||
}
|
||||
)
|
||||
|
||||
#expect(reportedFailure)
|
||||
#expect(store.current().isFencing)
|
||||
}
|
||||
|
||||
@Test("Older asynchronous cleanup cannot settle a newer fence")
|
||||
func olderAsynchronousCleanupCannotSettleNewerFence() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
var acknowledgeOlder: ((Error?) -> Void)?
|
||||
var acknowledgeNewer: ((Error?) -> Void)?
|
||||
|
||||
try store.performFencedAsyncCleanup(
|
||||
{ acknowledgeOlder = $0 },
|
||||
completion: { _ in }
|
||||
)
|
||||
try store.performFencedAsyncCleanup(
|
||||
{ acknowledgeNewer = $0 },
|
||||
completion: { _ in }
|
||||
)
|
||||
let newerFence = store.current()
|
||||
|
||||
let completeOlder = try #require(acknowledgeOlder)
|
||||
completeOlder(nil)
|
||||
#expect(store.current() == newerFence)
|
||||
#expect(store.current().isFencing)
|
||||
|
||||
let completeNewer = try #require(acknowledgeNewer)
|
||||
completeNewer(nil)
|
||||
#expect(!store.current().isFencing)
|
||||
}
|
||||
|
||||
@Test("Cross-process lock prevents an older settle from overwriting a newer fence")
|
||||
func crossProcessLockSerializesSettleAndBegin() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let settleReachedWrite = DispatchSemaphore(value: 0)
|
||||
let releaseSettle = DispatchSemaphore(value: 0)
|
||||
let settleFinished = DispatchSemaphore(value: 0)
|
||||
let beginFinished = DispatchSemaphore(value: 0)
|
||||
let settlingStore = BuzzAgeRestrictionFenceStore(
|
||||
containerURL: directory,
|
||||
beforeSettledWrite: {
|
||||
settleReachedWrite.signal()
|
||||
releaseSettle.wait()
|
||||
}
|
||||
)
|
||||
let beginningStore = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let active = try settlingStore.begin()
|
||||
|
||||
DispatchQueue.global().async {
|
||||
_ = try? settlingStore.settleIfFencing(expectedToken: active.token)
|
||||
settleFinished.signal()
|
||||
}
|
||||
#expect(settleReachedWrite.wait(timeout: .now() + 1) == .success)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
_ = try? beginningStore.begin()
|
||||
beginFinished.signal()
|
||||
}
|
||||
#expect(beginFinished.wait(timeout: .now() + 0.05) == .timedOut)
|
||||
|
||||
releaseSettle.signal()
|
||||
#expect(settleFinished.wait(timeout: .now() + 1) == .success)
|
||||
#expect(beginFinished.wait(timeout: .now() + 1) == .success)
|
||||
let newest = beginningStore.current()
|
||||
#expect(newest.isFencing)
|
||||
#expect(newest.token != active.token)
|
||||
}
|
||||
|
||||
@Test("Cross-process lock orders a final handoff before cleanup begins")
|
||||
func crossProcessLockSerializesHandoffAndBegin() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let handoffEntered = DispatchSemaphore(value: 0)
|
||||
let releaseHandoff = DispatchSemaphore(value: 0)
|
||||
let handoffFinished = DispatchSemaphore(value: 0)
|
||||
let beginFinished = DispatchSemaphore(value: 0)
|
||||
let handingOffStore = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let beginningStore = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let settled = try handingOffStore.settleIfFencing()
|
||||
|
||||
DispatchQueue.global().async {
|
||||
_ = try? handingOffStore.performIfUnchanged(since: settled) {
|
||||
handoffEntered.signal()
|
||||
releaseHandoff.wait()
|
||||
}
|
||||
handoffFinished.signal()
|
||||
}
|
||||
#expect(handoffEntered.wait(timeout: .now() + 1) == .success)
|
||||
|
||||
DispatchQueue.global().async {
|
||||
_ = try? beginningStore.begin()
|
||||
beginFinished.signal()
|
||||
}
|
||||
#expect(beginFinished.wait(timeout: .now() + 0.05) == .timedOut)
|
||||
|
||||
releaseHandoff.signal()
|
||||
#expect(handoffFinished.wait(timeout: .now() + 1) == .success)
|
||||
#expect(beginFinished.wait(timeout: .now() + 1) == .success)
|
||||
#expect(beginningStore.current().isFencing)
|
||||
}
|
||||
|
||||
@Test("A changed fence refuses the final handoff")
|
||||
func changedFenceRefusesHandoff() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let settled = try store.settleIfFencing()
|
||||
_ = try store.begin()
|
||||
var handedOff = false
|
||||
|
||||
let accepted = try store.performIfUnchanged(since: settled) {
|
||||
handedOff = true
|
||||
}
|
||||
|
||||
#expect(!accepted)
|
||||
#expect(!handedOff)
|
||||
}
|
||||
|
||||
@Test("Age restriction fence discards active and superseded resolutions")
|
||||
func ageRestrictionFenceDiscardPolicy() {
|
||||
let initial = BuzzAgeRestrictionFence.initial
|
||||
let active = BuzzAgeRestrictionFence(token: "active", isFencing: true)
|
||||
let settled = BuzzAgeRestrictionFence(token: "settled", isFencing: false)
|
||||
|
||||
#expect(!initial.requiresDiscard(since: initial))
|
||||
#expect(active.requiresDiscard(since: initial))
|
||||
#expect(BuzzAgeRestrictionFence.unavailable.requiresDiscard(since: initial))
|
||||
#expect(settled.requiresDiscard(since: initial))
|
||||
#expect(!settled.requiresDiscard(since: settled))
|
||||
}
|
||||
|
||||
@Test("Verified profile uses display_name, then name, and attaches a bounded local avatar")
|
||||
func verifiedProfilePrecedenceAndAvatar() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
@@ -746,3 +993,48 @@ struct BuzzPushPresentationCacheTests {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct BuzzLaunchNotificationProtectionTests {
|
||||
@Test func failedLaunchClearsCredentialsAndStillRequiresRetry() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
let store = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
try store.begin()
|
||||
let allowed = try store.settleIfFencing()
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path)
|
||||
var credentials = ["community": "saved signing key"]
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) {
|
||||
credentials.removeAll()
|
||||
}
|
||||
}
|
||||
#expect(credentials.isEmpty)
|
||||
#expect(store.current() == allowed, "The old allowed fence remains readable")
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) {
|
||||
Issue.record("Recovered storage should establish the fence")
|
||||
}
|
||||
#expect(store.current().isFencing)
|
||||
}
|
||||
|
||||
@Test func missingContainerStillAttemptsCredentialRemoval() {
|
||||
var cleared = false
|
||||
#expect(throws: (any Error).self) {
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: nil) { cleared = true }
|
||||
}
|
||||
#expect(cleared)
|
||||
}
|
||||
|
||||
@Test func credentialRemovalFailurePropagates() {
|
||||
let failure = NSError(domain: "test.keychain", code: 1)
|
||||
#expect(throws: failure) {
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: nil) { throw failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import BuzzPushKit
|
||||
import Foundation
|
||||
import Intents
|
||||
import Security
|
||||
import UserNotifications
|
||||
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
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 appGroupIdentifier =
|
||||
Bundle.main.object(
|
||||
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
|
||||
) as? String
|
||||
let keychainAccessGroup =
|
||||
Bundle.main.object(
|
||||
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
||||
) as? String
|
||||
return BuzzPushNotificationResolver(
|
||||
session: .shared,
|
||||
loadCommunitiesData: {
|
||||
Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier)
|
||||
loadCommunitiesData: { [self] in
|
||||
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
||||
},
|
||||
loadPrivateKey: { communityID in
|
||||
Self.loadPrivateKey(
|
||||
@@ -27,8 +42,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
keychainAccessGroup: keychainAccessGroup
|
||||
)
|
||||
},
|
||||
loadPresentationCacheData: {
|
||||
Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier)
|
||||
loadPresentationCacheData: { [self] in
|
||||
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
||||
}
|
||||
)
|
||||
}()
|
||||
@@ -37,9 +52,13 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
self.contentHandler = contentHandler
|
||||
handoff = BuzzNotificationHandoff(handler: contentHandler)
|
||||
restrictionFenceAtStart = Self.loadRestrictionFence(
|
||||
appGroupIdentifier: appGroupIdentifier
|
||||
)
|
||||
restrictedFallbackContent = Self.restrictedFallback(from: request.content)
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
contentHandler(request.content)
|
||||
finish(request.content)
|
||||
return
|
||||
}
|
||||
bestAttemptContent = content
|
||||
@@ -66,7 +85,13 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
self.bestAttemptContent = content
|
||||
self.communicationPresenter.present(
|
||||
ordinaryContent: content,
|
||||
resolution: resolution
|
||||
resolution: resolution,
|
||||
isStillAllowed: { [weak self] in
|
||||
self?.restrictionFenceIsUnchanged() ?? false
|
||||
},
|
||||
onDeletionFailure: { [weak self] _ in
|
||||
self?.activateRestrictionFence()
|
||||
}
|
||||
) { [weak self] specializedContent in
|
||||
self?.finish(specializedContent)
|
||||
}
|
||||
@@ -83,9 +108,88 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
private func finish(_ content: UNNotificationContent) {
|
||||
guard let contentHandler else { return }
|
||||
self.contentHandler = nil
|
||||
contentHandler(content)
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AVFoundation
|
||||
import BuzzPushKit
|
||||
import DeclaredAgeRange
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@@ -16,7 +17,7 @@ import os.log
|
||||
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
||||
)
|
||||
private var enrollmentTask: Task<Void, Never>?
|
||||
private var appGroupIdentifier: String? {
|
||||
var appGroupIdentifier: String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
|
||||
}
|
||||
private var pushKeychainAccessGroup: String? {
|
||||
@@ -29,6 +30,10 @@ import os.log
|
||||
)
|
||||
private var qrScannerChannel: FlutterMethodChannel?
|
||||
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
|
||||
private var ageSignalChannel: FlutterMethodChannel?
|
||||
private var ageSignalTask: Task<Void, Never>?
|
||||
private var ageSignalRequestID: UUID?
|
||||
private var ageSignalResult: FlutterResult?
|
||||
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
|
||||
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
|
||||
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
|
||||
@@ -40,10 +45,28 @@ import os.log
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
do {
|
||||
try prepareLaunchAgeRestrictionFence()
|
||||
} catch {
|
||||
// Flutter must start so the existing age-check retry screen is reachable.
|
||||
// requestAgeSignal retries this protection before returning any age result.
|
||||
os_log(
|
||||
"Launch notification protection failed: %{public}@", type: .error,
|
||||
error.localizedDescription)
|
||||
}
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
private func prepareLaunchAgeRestrictionFence() throws {
|
||||
let container = appGroupIdentifier.flatMap {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0)
|
||||
}
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: container) {
|
||||
try BuzzPushKeychain.replace(signingKeys: [:], accessGroup: self.pushKeychainAccessGroup)
|
||||
}
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
let messenger = engineBridge.applicationRegistrar.messenger()
|
||||
@@ -88,6 +111,21 @@ import os.log
|
||||
}
|
||||
}
|
||||
|
||||
ageSignalChannel = FlutterMethodChannel(
|
||||
name: "buzz/age_signal",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
let ageSignalRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzAgeSignal"
|
||||
)
|
||||
ageSignalChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleAgeSignalMethodCall(
|
||||
call,
|
||||
viewController: ageSignalRegistrar?.viewController,
|
||||
result: result
|
||||
)
|
||||
}
|
||||
|
||||
if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar(
|
||||
forPlugin: "BuzzInlinePhotoPicker"
|
||||
) {
|
||||
@@ -224,6 +262,119 @@ import os.log
|
||||
}
|
||||
}
|
||||
|
||||
func handleAgeSignalMethodCall(
|
||||
_ call: FlutterMethodCall,
|
||||
viewController: UIViewController?,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
// iOS can retire the request in process. The generation fence prevents
|
||||
// a late result from the cancelled task from completing a fresh request.
|
||||
if call.method == "cancelAgeSignalRequest" || call.method == "restartForAgeSignal" {
|
||||
cancelAgeSignalRequest()
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
guard call.method == "requestAgeSignal" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try prepareLaunchAgeRestrictionFence()
|
||||
} catch {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "age_signal_notification_protection_failed",
|
||||
message: "Unable to protect notifications before checking age. Please retry.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
guard #available(iOS 26.0, *) else {
|
||||
result(Self.noAgeSignalResponse)
|
||||
return
|
||||
}
|
||||
guard let viewController else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "age_signal_unavailable",
|
||||
message: "The age signal presenter is unavailable.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let requestID = UUID()
|
||||
ageSignalRequestID = requestID
|
||||
ageSignalResult = result
|
||||
ageSignalTask = Task { @MainActor [weak self] in
|
||||
do {
|
||||
let response = try await AgeRangeService.shared.requestAgeRange(
|
||||
ageGates: 18,
|
||||
in: viewController
|
||||
)
|
||||
switch response {
|
||||
case .declinedSharing:
|
||||
self?.completeAgeSignalRequest(requestID, value: Self.noAgeSignalResponse)
|
||||
case .sharing(let range):
|
||||
self?.completeAgeSignalRequest(
|
||||
requestID,
|
||||
value: BuzzAgeSignalPayload.sharing(exclusiveUpperBound: range.upperBound)
|
||||
)
|
||||
@unknown default:
|
||||
self?.completeAgeSignalRequest(
|
||||
requestID,
|
||||
value:
|
||||
FlutterError(
|
||||
code: "age_signal_unavailable",
|
||||
message: "The age signal response is unsupported.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self?.completeAgeSignalRequest(
|
||||
requestID,
|
||||
value:
|
||||
FlutterError(
|
||||
code: "age_signal_unavailable",
|
||||
message: "The age signal request failed.",
|
||||
details: String(describing: type(of: error))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeAgeSignalRequest(_ requestID: UUID, value: Any?) {
|
||||
guard ageSignalRequestID == requestID, let result = ageSignalResult else { return }
|
||||
ageSignalRequestID = nil
|
||||
ageSignalResult = nil
|
||||
ageSignalTask = nil
|
||||
result(value)
|
||||
}
|
||||
|
||||
private func cancelAgeSignalRequest() {
|
||||
let result = ageSignalResult
|
||||
ageSignalRequestID = nil
|
||||
ageSignalResult = nil
|
||||
ageSignalTask?.cancel()
|
||||
ageSignalTask = nil
|
||||
result?(
|
||||
FlutterError(
|
||||
code: "age_signal_cancelled",
|
||||
message: "The age signal request was cancelled.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static let noAgeSignalResponse: [String: Any] = [
|
||||
"status": "noSignal",
|
||||
"ageUpper": NSNull(),
|
||||
]
|
||||
|
||||
private static func handleQrScannerMethodCall(
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
|
||||
@@ -45,7 +45,16 @@ enum BuzzPushKeychain {
|
||||
|
||||
static func replace(signingKeys: [String: String], accessGroup: String?) throws {
|
||||
var query = baseQuery(accessGroup: accessGroup)
|
||||
SecItemDelete(query as CFDictionary)
|
||||
let deletionStatus = SecItemDelete(query as CFDictionary)
|
||||
guard deletionStatus == errSecSuccess || deletionStatus == errSecItemNotFound else {
|
||||
throw NSError(
|
||||
domain: NSOSStatusErrorDomain, code: Int(deletionStatus),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: SecCopyErrorMessageString(deletionStatus, nil)
|
||||
?? "Keychain deletion failed" as CFString
|
||||
]
|
||||
)
|
||||
}
|
||||
for (communityID, privateKeyHex) in signingKeys {
|
||||
query[kSecAttrAccount as String] = communityID
|
||||
query[kSecValueData as String] = Data(privateKeyHex.utf8)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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",
|
||||
@@ -18,27 +21,59 @@ final class BuzzPushSnapshotBridge {
|
||||
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?
|
||||
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 {
|
||||
guard call.method == "syncPushSnapshot",
|
||||
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, result: result)
|
||||
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)
|
||||
@@ -47,10 +82,62 @@ final class BuzzPushSnapshotBridge {
|
||||
return true
|
||||
}
|
||||
|
||||
private func syncCommunities(_ arguments: [String: Any], result: @escaping FlutterResult) {
|
||||
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
|
||||
communities.count <= BuzzPushPresentationCacheStore.maximumCommunities,
|
||||
!requiresStore || arguments["settleFence"] is Bool
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
@@ -63,8 +150,30 @@ final class BuzzPushSnapshotBridge {
|
||||
}
|
||||
queue.async { [weak self] in
|
||||
do {
|
||||
guard let self, let store else {
|
||||
Self.complete(result, value: nil)
|
||||
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
|
||||
@@ -83,11 +192,71 @@ final class BuzzPushSnapshotBridge {
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys])
|
||||
let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data)
|
||||
try store.replaceCommunities(decoded)
|
||||
try BuzzPushKeychain.replace(
|
||||
signingKeys: signingKeys,
|
||||
accessGroup: keychainAccessGroup
|
||||
)
|
||||
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(
|
||||
@@ -252,7 +421,8 @@ final class BuzzPushSnapshotBridge {
|
||||
let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
||||
),
|
||||
let data = try? Data(contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)),
|
||||
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 }
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<string>$(BUZZ_IOS_PUSH_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||
<string>$(BUZZ_APP_ATTEST_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.declared-age-range</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.usernotifications.communication</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BuzzPushKit
|
||||
import Flutter
|
||||
import Intents
|
||||
import UserNotifications
|
||||
import XCTest
|
||||
@@ -69,6 +70,7 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
|
||||
donate: { _, completion in
|
||||
completion(NSError(domain: "test", code: 1))
|
||||
},
|
||||
deleteAllInteractions: { completion in completion(nil) },
|
||||
updateContent: { content, _ in
|
||||
updateCalled = true
|
||||
return content
|
||||
@@ -98,6 +100,7 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
|
||||
order.append("donate")
|
||||
completion(nil)
|
||||
},
|
||||
deleteAllInteractions: { completion in completion(nil) },
|
||||
updateContent: { _, _ in
|
||||
order.append("update")
|
||||
let specialized = UNMutableNotificationContent()
|
||||
@@ -125,6 +128,7 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
|
||||
ordinary.body = "Hello Buzz"
|
||||
let presenter = BuzzCommunicationNotificationPresenter(
|
||||
donate: { _, completion in completion(nil) },
|
||||
deleteAllInteractions: { completion in completion(nil) },
|
||||
updateContent: { _, _ in throw NSError(domain: "test", code: 2) }
|
||||
)
|
||||
let completed = expectation(description: "ordinary fallback returned")
|
||||
@@ -141,6 +145,134 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
|
||||
wait(for: [completed], timeout: 1)
|
||||
}
|
||||
|
||||
func testFenceChangeDeletesDonatedInteractionBeforeCompleting() {
|
||||
let ordinary = UNMutableNotificationContent()
|
||||
ordinary.title = "Alice"
|
||||
var allowed = true
|
||||
var order: [String] = []
|
||||
let presenter = BuzzCommunicationNotificationPresenter(
|
||||
donate: { _, completion in
|
||||
order.append("donate")
|
||||
allowed = false
|
||||
completion(nil)
|
||||
},
|
||||
deleteAllInteractions: { completion in
|
||||
order.append("delete")
|
||||
completion(nil)
|
||||
},
|
||||
updateContent: { content, _ in
|
||||
order.append("update")
|
||||
return content
|
||||
}
|
||||
)
|
||||
let completed = expectation(description: "restricted donation deleted")
|
||||
|
||||
presenter.present(
|
||||
ordinaryContent: ordinary,
|
||||
resolution: communicationResolution(),
|
||||
isStillAllowed: { allowed }
|
||||
) { content in
|
||||
order.append("complete")
|
||||
XCTAssertEqual(content.title, "Alice")
|
||||
completed.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [completed], timeout: 1)
|
||||
XCTAssertEqual(order, ["donate", "delete", "complete"])
|
||||
}
|
||||
|
||||
func testBlockedFenceDoesNotDonate() {
|
||||
let ordinary = UNMutableNotificationContent()
|
||||
ordinary.title = "Alice"
|
||||
var donateCalled = false
|
||||
let presenter = BuzzCommunicationNotificationPresenter(
|
||||
donate: { _, completion in
|
||||
donateCalled = true
|
||||
completion(nil)
|
||||
},
|
||||
deleteAllInteractions: { completion in completion(nil) },
|
||||
updateContent: { content, _ in content }
|
||||
)
|
||||
let completed = expectation(description: "blocked donation skipped")
|
||||
|
||||
presenter.present(
|
||||
ordinaryContent: ordinary,
|
||||
resolution: communicationResolution(),
|
||||
isStillAllowed: { false }
|
||||
) { content in
|
||||
XCTAssertEqual(content.title, "Alice")
|
||||
completed.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [completed], timeout: 1)
|
||||
XCTAssertFalse(donateCalled)
|
||||
}
|
||||
|
||||
func testFailedLateDonationDeletionReactivatesFenceUntilAppRetry() throws {
|
||||
let ordinary = UNMutableNotificationContent()
|
||||
ordinary.title = "Alice"
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let fenceStore = BuzzAgeRestrictionFenceStore(containerURL: directory)
|
||||
let fenceAtStart = fenceStore.current()
|
||||
var order: [String] = []
|
||||
let deletionFailure = NSError(domain: "test", code: 3)
|
||||
let presenter = BuzzCommunicationNotificationPresenter(
|
||||
donate: { _, completion in
|
||||
order.append("donate")
|
||||
do {
|
||||
try fenceStore.performFencedCleanup {}
|
||||
} catch {
|
||||
XCTFail("Unable to prepare the donation cleanup: \(error)")
|
||||
}
|
||||
completion(nil)
|
||||
},
|
||||
deleteAllInteractions: { completion in
|
||||
order.append("delete")
|
||||
completion(deletionFailure)
|
||||
},
|
||||
updateContent: { content, _ in content }
|
||||
)
|
||||
let completed = expectation(description: "failed deletion fenced")
|
||||
|
||||
presenter.present(
|
||||
ordinaryContent: ordinary,
|
||||
resolution: communicationResolution(),
|
||||
isStillAllowed: {
|
||||
!fenceStore.current().requiresDiscard(since: fenceAtStart)
|
||||
},
|
||||
onDeletionFailure: { error in
|
||||
XCTAssertEqual(error as NSError, deletionFailure)
|
||||
do {
|
||||
try fenceStore.begin()
|
||||
} catch {
|
||||
XCTFail("Unable to reactivate the restriction: \(error)")
|
||||
}
|
||||
order.append("fence")
|
||||
}
|
||||
) { content in
|
||||
order.append("complete")
|
||||
XCTAssertEqual(content.title, "Alice")
|
||||
completed.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [completed], timeout: 1)
|
||||
XCTAssertEqual(order, ["donate", "delete", "fence", "complete"])
|
||||
XCTAssertTrue(fenceStore.current().isFencing)
|
||||
|
||||
var retryError: Error?
|
||||
try fenceStore.performFencedAsyncCleanup(
|
||||
{ $0(nil) },
|
||||
completion: { retryError = $0 }
|
||||
)
|
||||
XCTAssertNil(retryError)
|
||||
XCTAssertFalse(fenceStore.current().isFencing)
|
||||
}
|
||||
|
||||
private func communicationResolution(
|
||||
displayName: String = "Alice",
|
||||
groupName: String? = "General",
|
||||
@@ -175,6 +307,62 @@ final class BuzzCommunicationNotificationTests: XCTestCase {
|
||||
}
|
||||
|
||||
final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
|
||||
func testLaunchBeginsAgeRestrictionFenceBeforeFlutterState() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) {
|
||||
XCTFail("A successful fence must not remove credentials")
|
||||
}
|
||||
|
||||
XCTAssertTrue(BuzzAgeRestrictionFenceStore(containerURL: directory).current().isFencing)
|
||||
}
|
||||
|
||||
func testAgeSignalRequestRejectsFailedLaunchProtectionBeforeCheckingAge() {
|
||||
let delegate = MissingAppGroupDelegate()
|
||||
var completed = false
|
||||
delegate.handleAgeSignalMethodCall(
|
||||
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
|
||||
viewController: nil
|
||||
) { value in
|
||||
XCTAssertEqual((value as? FlutterError)?.code, "age_signal_notification_protection_failed")
|
||||
completed = true
|
||||
}
|
||||
XCTAssertTrue(completed)
|
||||
}
|
||||
|
||||
func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() {
|
||||
let bridge = BuzzPushSnapshotBridge(
|
||||
appGroupIdentifier: nil,
|
||||
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
||||
keychainAccessGroup: nil
|
||||
)
|
||||
let completed = expectation(description: "strict write rejected")
|
||||
|
||||
XCTAssertTrue(
|
||||
bridge.handle(
|
||||
FlutterMethodCall(
|
||||
methodName: "syncAgeGatePushSnapshot",
|
||||
arguments: [
|
||||
"section": "communities",
|
||||
"communities": [[String: Any]](),
|
||||
"signingKeys": [String: String](),
|
||||
"settleFence": false,
|
||||
]
|
||||
)
|
||||
) { value in
|
||||
XCTAssertEqual((value as? FlutterError)?.code, "snapshot_sync_unavailable")
|
||||
completed.fulfill()
|
||||
}
|
||||
)
|
||||
|
||||
wait(for: [completed], timeout: 1)
|
||||
}
|
||||
|
||||
func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() {
|
||||
let correctProfile = grant(
|
||||
appProfile: BuzzDevPushEnrollmentDriver.appProfile,
|
||||
@@ -280,3 +468,7 @@ final class BuzzPushNotificationResponseTests: XCTestCase {
|
||||
XCTAssertEqual(completions, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MissingAppGroupDelegate: AppDelegate {
|
||||
override var appGroupIdentifier: String? { nil }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'features/age_gate/age_restriction_page.dart';
|
||||
import 'features/age_gate/age_signal_provider.dart';
|
||||
import 'features/activity/activity_provider.dart';
|
||||
import 'features/activity/inbox_local_state_provider.dart';
|
||||
import 'features/activity/inbox_read_state.dart';
|
||||
@@ -292,7 +294,10 @@ class App extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final communityTheme = ref.watch(communityThemeProvider);
|
||||
final ageSignalState = ref.watch(ageSignalProvider);
|
||||
final communityTheme = ageSignalState == AgeSignalState.allowed
|
||||
? ref.watch(communityThemeProvider)
|
||||
: defaultCommunityTheme;
|
||||
final themeMode = communityTheme.mode;
|
||||
final accentIndex = effectiveAccentIndex(
|
||||
communityTheme.theme,
|
||||
@@ -301,6 +306,13 @@ class App extends HookConsumerWidget {
|
||||
final schemeName = communityTheme.theme;
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(ref.read(ageSignalProvider.notifier).request());
|
||||
});
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
final resolved = resolveSchemes(schemeName, themeMode);
|
||||
final lightScheme = applyAccent(resolved.light, accentIndex);
|
||||
final darkScheme = applyAccent(resolved.dark, accentIndex);
|
||||
@@ -323,7 +335,8 @@ class App extends HookConsumerWidget {
|
||||
// Eagerly initialize websocket session and lifecycle observer when
|
||||
// authenticated. These providers connect and manage the websocket.
|
||||
var hasUnreadInbox = false;
|
||||
if (authState.value?.status == AuthStatus.authenticated) {
|
||||
if (ageSignalState == AgeSignalState.allowed &&
|
||||
authState.value?.status == AuthStatus.authenticated) {
|
||||
ref.watch(relaySessionProvider);
|
||||
ref.watch(observerRelayProvider);
|
||||
ref.watch(appLifecycleProvider);
|
||||
@@ -351,12 +364,18 @@ class App extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
applyBadge(ref.read(unreadBadgeProvider));
|
||||
if (ageSignalState == AgeSignalState.allowed) {
|
||||
applyBadge(ref.read(unreadBadgeProvider));
|
||||
} else {
|
||||
AppBadgePlus.updateBadge(0);
|
||||
}
|
||||
return null;
|
||||
}, const []);
|
||||
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
|
||||
applyBadge(next);
|
||||
});
|
||||
}, [ageSignalState]);
|
||||
if (ageSignalState == AgeSignalState.allowed) {
|
||||
ref.listen<UnreadBadgeState>(unreadBadgeProvider, (_, next) {
|
||||
applyBadge(next);
|
||||
});
|
||||
}
|
||||
|
||||
return MaterialApp(
|
||||
navigatorKey: _mobileRootNavigatorKey,
|
||||
@@ -371,15 +390,19 @@ class App extends HookConsumerWidget {
|
||||
topSectionGradient: buzzDarkGradient,
|
||||
),
|
||||
themeMode: effectiveMode,
|
||||
// Above the navigator, so a burst keeps playing over a pushed thread page
|
||||
// or a modal sheet — the same reason desktop pins its canvas to the
|
||||
// viewport rather than to the message row.
|
||||
builder: (context, child) => AppMarkdownTheme(
|
||||
child: MobileHuddleShell(
|
||||
navigatorKey: _mobileRootNavigatorKey,
|
||||
child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
|
||||
// Above the navigator, so an age restriction cannot be bypassed by a
|
||||
// route that was pushed while the store signal request was in flight.
|
||||
builder: (context, child) => switch (ageSignalState) {
|
||||
AgeSignalState.checking => const _AgeSignalLoadingPage(),
|
||||
AgeSignalState.retryableFailure => const _AgeSignalRetryPage(),
|
||||
AgeSignalState.restricted => const AgeRestrictionPage(),
|
||||
AgeSignalState.allowed => AppMarkdownTheme(
|
||||
child: MobileHuddleShell(
|
||||
navigatorKey: _mobileRootNavigatorKey,
|
||||
child: EmojiBurstOverlay(child: child ?? const SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
home: authState.when(
|
||||
loading: () => const _SplashScreen(),
|
||||
error: (_, _) => const PairingPage(),
|
||||
@@ -432,3 +455,56 @@ class _SplashScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgeSignalLoadingPage extends StatelessWidget {
|
||||
const _AgeSignalLoadingPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: BuzzLoadingIndicator(
|
||||
size: 56,
|
||||
semanticLabel: 'Checking age eligibility',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AgeSignalRetryPage extends ConsumerWidget {
|
||||
const _AgeSignalRetryPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Unable to check age eligibility',
|
||||
style: context.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Check your connection and try again.',
|
||||
style: context.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
unawaited(ref.read(ageSignalProvider.notifier).request()),
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
51
mobile/lib/features/age_gate/age_restriction_page.dart
Normal file
51
mobile/lib/features/age_gate/age_restriction_page.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
class AgeRestrictionPage extends StatelessWidget {
|
||||
const AgeRestrictionPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xl),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 48,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.lg),
|
||||
Text(
|
||||
'Buzz is for people 18 and older',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.sm),
|
||||
Text(
|
||||
"You must be 18 or older to use Buzz under Buzz's Terms.",
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
256
mobile/lib/features/age_gate/age_signal_provider.dart
Normal file
256
mobile/lib/features/age_gate/age_signal_provider.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
const ageSignalChannel = MethodChannel('buzz/age_signal');
|
||||
|
||||
/// Delay before the single retry of a failed native age-signal request.
|
||||
const ageSignalRetryDelay = Duration(seconds: 1);
|
||||
|
||||
/// Maximum time allowed for each native age-signal request attempt.
|
||||
const ageSignalRequestTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Maximum time allowed for native cancellation acknowledgement.
|
||||
const ageSignalCancellationTimeout = Duration(seconds: 5);
|
||||
|
||||
/// Invokes the native age-signal request.
|
||||
typedef AgeSignalRequest = Future<Map<Object?, Object?>?> Function();
|
||||
|
||||
/// Waits before retrying a failed native age-signal request.
|
||||
typedef AgeSignalDelay = Future<void> Function(Duration duration);
|
||||
typedef AgeSignalCancel = Future<bool> Function();
|
||||
|
||||
/// Returns true only when native state is retired for an in-process retry.
|
||||
typedef AgeSignalRestart = Future<bool> Function();
|
||||
|
||||
Future<Map<Object?, Object?>?> _requestPlatformAgeSignal() =>
|
||||
ageSignalChannel.invokeMapMethod<Object?, Object?>('requestAgeSignal');
|
||||
|
||||
Future<void> _delayAgeSignalRetry(Duration duration) =>
|
||||
Future<void>.delayed(duration);
|
||||
Future<bool> _cancelPlatformAgeSignal() async =>
|
||||
await ageSignalChannel.invokeMethod<bool>('cancelAgeSignalRequest') ??
|
||||
false;
|
||||
Future<bool> _restartForPlatformAgeSignal() async {
|
||||
final retired = await ageSignalChannel.invokeMethod<bool>(
|
||||
'restartForAgeSignal',
|
||||
);
|
||||
if (retired == null) {
|
||||
throw StateError('Missing age signal reset acknowledgement.');
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
bool shouldBlockForAgeSignal(Map<Object?, Object?> response) {
|
||||
if (response.length != 2 ||
|
||||
!response.containsKey('status') ||
|
||||
!response.containsKey('ageUpper')) {
|
||||
throw StateError('Unexpected age signal response.');
|
||||
}
|
||||
|
||||
final status = response['status'];
|
||||
if (status == 'noSignal') {
|
||||
if (response['ageUpper'] != null) {
|
||||
throw StateError('Unexpected age signal upper bound.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (status != 'signal') {
|
||||
throw StateError('Unexpected age signal status.');
|
||||
}
|
||||
|
||||
// Native adapters provide an inclusive upper age. iOS converts its
|
||||
// exclusive age gate (18 for a minor) to 17 before sending this payload.
|
||||
final ageUpper = response['ageUpper'];
|
||||
if (ageUpper == null) {
|
||||
return false;
|
||||
}
|
||||
if (ageUpper is! int) {
|
||||
throw StateError('Unexpected age signal upper bound.');
|
||||
}
|
||||
return ageUpper < 18;
|
||||
}
|
||||
|
||||
/// Result of the platform age-signal check for this app launch.
|
||||
enum AgeSignalState { checking, retryableFailure, allowed, restricted }
|
||||
|
||||
class AgeSignalNotifier extends Notifier<AgeSignalState> {
|
||||
/// Creates an age-signal notifier, optionally with test request hooks.
|
||||
AgeSignalNotifier({
|
||||
AgeSignalRequest? requestSignal,
|
||||
AgeSignalDelay? delay,
|
||||
AgeSignalCancel? cancelSignal,
|
||||
AgeSignalRestart? restartSignal,
|
||||
Duration requestTimeout = ageSignalRequestTimeout,
|
||||
Duration cancellationTimeout = ageSignalCancellationTimeout,
|
||||
}) : _requestSignal = requestSignal ?? _requestPlatformAgeSignal,
|
||||
_delay = delay ?? _delayAgeSignalRetry,
|
||||
_cancelSignal = cancelSignal ?? _cancelPlatformAgeSignal,
|
||||
_restartSignal = restartSignal ?? _restartForPlatformAgeSignal,
|
||||
_requestTimeout = requestTimeout,
|
||||
_cancellationTimeout = cancellationTimeout;
|
||||
|
||||
static const _maxAttempts = 2;
|
||||
|
||||
final AgeSignalRequest _requestSignal;
|
||||
final AgeSignalDelay _delay;
|
||||
final AgeSignalCancel _cancelSignal;
|
||||
final AgeSignalRestart _restartSignal;
|
||||
final Duration _requestTimeout;
|
||||
final Duration _cancellationTimeout;
|
||||
bool _completed = false;
|
||||
bool _restartRequired = false;
|
||||
Future<void>? _requestInFlight;
|
||||
Future<Map<Object?, Object?>?>? _nativeRequestInFlight;
|
||||
|
||||
@override
|
||||
AgeSignalState build() => AgeSignalState.checking;
|
||||
|
||||
Future<void> request() async {
|
||||
if (_completed) return;
|
||||
final requestInFlight = _requestInFlight;
|
||||
if (requestInFlight != null) {
|
||||
await requestInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
state = AgeSignalState.checking;
|
||||
final request = _requestWithRetry();
|
||||
_requestInFlight = request;
|
||||
try {
|
||||
await request;
|
||||
} finally {
|
||||
if (identical(_requestInFlight, request)) {
|
||||
_requestInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestWithRetry() async {
|
||||
if (_restartRequired) {
|
||||
var retired = false;
|
||||
try {
|
||||
retired = await _restartSignal().timeout(_cancellationTimeout);
|
||||
} on TimeoutException {
|
||||
// Keep the retry affordance when native recovery does not acknowledge.
|
||||
} on MissingPluginException {
|
||||
// A missing recovery handler must not allow access.
|
||||
} on PlatformException {
|
||||
// A failed reset must not start an overlapping native request.
|
||||
} on TypeError {
|
||||
// A malformed acknowledgement is not evidence of retirement.
|
||||
} on StateError {
|
||||
// A missing acknowledgement is not evidence of retirement.
|
||||
}
|
||||
if (!retired) {
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
}
|
||||
_nativeRequestInFlight = null;
|
||||
_restartRequired = false;
|
||||
}
|
||||
for (var attempt = 0; attempt < _maxAttempts; attempt += 1) {
|
||||
final Map<Object?, Object?>? response;
|
||||
try {
|
||||
response = await _awaitNativeRequest();
|
||||
} on MissingPluginException {
|
||||
if (attempt + 1 < _maxAttempts) {
|
||||
await _delay(ageSignalRetryDelay);
|
||||
continue;
|
||||
}
|
||||
// A missing channel is an integration failure, not evidence that the
|
||||
// platform has no age signal. Keep the launch gated for a retry.
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
} on PlatformException {
|
||||
if (attempt + 1 < _maxAttempts) {
|
||||
await _delay(ageSignalRetryDelay);
|
||||
continue;
|
||||
}
|
||||
// A transient native failure is not evidence that access is allowed.
|
||||
// Keep the launch gated and expose a deliberate retry action.
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
} on TimeoutException {
|
||||
if (attempt + 1 < _maxAttempts) {
|
||||
await _delay(ageSignalRetryDelay);
|
||||
continue;
|
||||
}
|
||||
await _retireNativeRequest();
|
||||
// A stalled platform flow is not evidence that access is allowed.
|
||||
// Keep the launch gated and expose a deliberate retry action.
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
} on TypeError {
|
||||
// A method-channel envelope with the wrong shape fails before the map
|
||||
// validator runs. Keep the launch gated with an explicit retry.
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
}
|
||||
|
||||
final bool shouldBlock;
|
||||
try {
|
||||
if (response == null) {
|
||||
throw StateError('Missing age signal response.');
|
||||
}
|
||||
shouldBlock = shouldBlockForAgeSignal(response);
|
||||
} on StateError {
|
||||
// A malformed native response is an integration failure, not evidence
|
||||
// that access is allowed. Keep the launch gated for a deliberate retry.
|
||||
state = AgeSignalState.retryableFailure;
|
||||
return;
|
||||
}
|
||||
|
||||
_completed = true;
|
||||
state = shouldBlock ? AgeSignalState.restricted : AgeSignalState.allowed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<Object?, Object?>?> _awaitNativeRequest() async {
|
||||
final request = _nativeRequestInFlight ??= _requestSignal();
|
||||
try {
|
||||
final response = await request.timeout(_requestTimeout);
|
||||
if (identical(_nativeRequestInFlight, request)) {
|
||||
_nativeRequestInFlight = null;
|
||||
}
|
||||
return response;
|
||||
} on TimeoutException {
|
||||
// A Dart timeout cannot cancel the platform consent flow. Keep this
|
||||
// future as the single flight so retries cannot start overlapping native
|
||||
// prompts and can still consume a late response.
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
if (identical(_nativeRequestInFlight, request)) {
|
||||
_nativeRequestInFlight = null;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retireNativeRequest() async {
|
||||
var retired = false;
|
||||
try {
|
||||
retired = await _cancelSignal().timeout(_cancellationTimeout);
|
||||
} on TimeoutException {
|
||||
// Stay gated if cancellation does not acknowledge within its deadline.
|
||||
} on MissingPluginException {
|
||||
// Stay gated if cancellation cannot be acknowledged.
|
||||
} on PlatformException {
|
||||
// Stay gated if cancellation cannot be acknowledged.
|
||||
} on TypeError {
|
||||
// Stay gated if the method channel returns a malformed acknowledgement.
|
||||
} finally {
|
||||
if (retired) {
|
||||
_nativeRequestInFlight = null;
|
||||
} else {
|
||||
_restartRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final ageSignalProvider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
AgeSignalNotifier.new,
|
||||
);
|
||||
245
mobile/lib/features/age_gate/age_signal_push_bootstrap.dart
Normal file
245
mobile/lib/features/age_gate/age_signal_push_bootstrap.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/push/push_bootstrap.dart';
|
||||
import '../../shared/push/push_bridge.dart';
|
||||
import 'age_signal_provider.dart';
|
||||
|
||||
/// Delay between failed age-gate snapshot transitions.
|
||||
const ageSignalPushSnapshotInitialRetryDelay = Duration(seconds: 5);
|
||||
|
||||
/// Maximum delay between failed age-gate snapshot transitions.
|
||||
const ageSignalPushSnapshotMaximumRetryDelay = Duration(minutes: 5);
|
||||
|
||||
/// Exponential retry delay for a zero-based consecutive failure count.
|
||||
Duration ageSignalPushSnapshotRetryDelay(int failures) {
|
||||
final boundedFailures = failures.clamp(0, 6);
|
||||
final seconds =
|
||||
ageSignalPushSnapshotInitialRetryDelay.inSeconds * (1 << boundedFailures);
|
||||
return Duration(
|
||||
seconds: seconds.clamp(0, ageSignalPushSnapshotMaximumRetryDelay.inSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
/// Waits before retrying a failed age-gate snapshot transition.
|
||||
typedef AgeSignalPushSnapshotRetryWait =
|
||||
Future<void> Function(Duration duration);
|
||||
|
||||
/// Retry wait used by the launch age gate's notification snapshot boundary.
|
||||
final ageSignalPushSnapshotRetryWaitProvider =
|
||||
Provider<AgeSignalPushSnapshotRetryWait>((ref) {
|
||||
return Future<void>.delayed;
|
||||
});
|
||||
|
||||
/// Native notification purge performed once restriction is confirmed.
|
||||
final ageRestrictedNotificationPurgerProvider =
|
||||
Provider<Future<void> Function()>(
|
||||
(ref) => purgeAgeRestrictedBuzzNotifications,
|
||||
);
|
||||
|
||||
/// Delay between successful maintenance purges while restriction remains active.
|
||||
const ageRestrictedNotificationMaintenanceDelay = Duration(seconds: 30);
|
||||
|
||||
/// Number of delayed purge passes after an initial successful purge.
|
||||
const ageRestrictedNotificationMaintenancePurgeLimit = 3;
|
||||
|
||||
/// Schedules a recheck for interactions donated by stale extensions.
|
||||
final ageRestrictedNotificationMaintenanceScheduleProvider =
|
||||
Provider<VoidCallback Function(VoidCallback)>((ref) {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return (_) => () {};
|
||||
}
|
||||
return (callback) {
|
||||
final timer = Timer(
|
||||
ageRestrictedNotificationMaintenanceDelay,
|
||||
callback,
|
||||
);
|
||||
return timer.cancel;
|
||||
};
|
||||
});
|
||||
|
||||
/// Starts the push lifecycle only after the launch age check allows access.
|
||||
class AgeSignalPushBootstrap extends HookConsumerWidget {
|
||||
/// Creates the production push boundary around [child].
|
||||
const AgeSignalPushBootstrap({required this.child, super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(ageSignalProvider);
|
||||
final suspendSnapshot = ref.watch(
|
||||
suspendCommunitySnapshotForAgeCheckProvider,
|
||||
);
|
||||
final resumeSnapshot = ref.watch(
|
||||
resumeCommunitySnapshotAfterAgeCheckProvider,
|
||||
);
|
||||
final waitBeforeRetry = ref.watch(ageSignalPushSnapshotRetryWaitProvider);
|
||||
final retryGeneration = useState(0);
|
||||
final consecutiveFailures = useRef(0);
|
||||
final previousState = useRef<AgeSignalState?>(null);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (previousState.value != state) {
|
||||
previousState.value = state;
|
||||
consecutiveFailures.value = 0;
|
||||
}
|
||||
var cancelled = false;
|
||||
unawaited(() async {
|
||||
try {
|
||||
await (state == AgeSignalState.allowed
|
||||
? resumeSnapshot()
|
||||
: suspendSnapshot());
|
||||
consecutiveFailures.value = 0;
|
||||
} catch (_) {
|
||||
final delay = ageSignalPushSnapshotRetryDelay(
|
||||
consecutiveFailures.value,
|
||||
);
|
||||
await waitBeforeRetry(delay);
|
||||
if (!cancelled) {
|
||||
consecutiveFailures.value += 1;
|
||||
retryGeneration.value += 1;
|
||||
}
|
||||
}
|
||||
}());
|
||||
return () => cancelled = true;
|
||||
},
|
||||
[
|
||||
state,
|
||||
suspendSnapshot,
|
||||
resumeSnapshot,
|
||||
waitBeforeRetry,
|
||||
retryGeneration.value,
|
||||
],
|
||||
);
|
||||
|
||||
return switch (state) {
|
||||
AgeSignalState.allowed => BuzzPushBootstrap(child: child),
|
||||
AgeSignalState.restricted => _AgeRestrictedPushCleanup(child: child),
|
||||
AgeSignalState.checking || AgeSignalState.retryableFailure => child,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _AgeRestrictedPushCleanup extends HookConsumerWidget {
|
||||
const _AgeRestrictedPushCleanup({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final communitiesReady = ref.watch(communityListProvider).hasValue;
|
||||
final waitBeforeRetry = ref.watch(ageSignalPushSnapshotRetryWaitProvider);
|
||||
final purgeNotifications = ref.watch(
|
||||
ageRestrictedNotificationPurgerProvider,
|
||||
);
|
||||
final scheduleMaintenancePurge = ref.watch(
|
||||
ageRestrictedNotificationMaintenanceScheduleProvider,
|
||||
);
|
||||
final resumeGeneration = useState(0);
|
||||
final retryGeneration = useState(0);
|
||||
final consecutiveFailures = useRef(0);
|
||||
final purgeRetryGeneration = useState(0);
|
||||
final consecutivePurgeFailures = useRef(0);
|
||||
final remainingMaintenancePurges = useRef(
|
||||
ageRestrictedNotificationMaintenancePurgeLimit,
|
||||
);
|
||||
|
||||
useEffect(() {
|
||||
final listener = AppLifecycleListener(
|
||||
onResume: () {
|
||||
if (ref.read(communityListProvider).hasError) {
|
||||
ref.invalidate(communityListProvider);
|
||||
}
|
||||
remainingMaintenancePurges.value =
|
||||
ageRestrictedNotificationMaintenancePurgeLimit;
|
||||
resumeGeneration.value += 1;
|
||||
},
|
||||
);
|
||||
return listener.dispose;
|
||||
}, const []);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
var cancelled = false;
|
||||
VoidCallback? cancelMaintenancePurge;
|
||||
unawaited(() async {
|
||||
try {
|
||||
await purgeNotifications();
|
||||
consecutivePurgeFailures.value = 0;
|
||||
if (!cancelled && remainingMaintenancePurges.value > 0) {
|
||||
remainingMaintenancePurges.value -= 1;
|
||||
cancelMaintenancePurge = scheduleMaintenancePurge(() {
|
||||
if (!cancelled) {
|
||||
purgeRetryGeneration.value += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
reportPushLeaseCleanupError(error, stackTrace);
|
||||
final delay = ageSignalPushSnapshotRetryDelay(
|
||||
consecutivePurgeFailures.value,
|
||||
);
|
||||
await waitBeforeRetry(delay);
|
||||
if (!cancelled) {
|
||||
consecutivePurgeFailures.value += 1;
|
||||
purgeRetryGeneration.value += 1;
|
||||
}
|
||||
}
|
||||
}());
|
||||
return () {
|
||||
cancelled = true;
|
||||
cancelMaintenancePurge?.call();
|
||||
};
|
||||
},
|
||||
[
|
||||
purgeNotifications,
|
||||
scheduleMaintenancePurge,
|
||||
waitBeforeRetry,
|
||||
resumeGeneration.value,
|
||||
purgeRetryGeneration.value,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
var cancelled = false;
|
||||
if (communitiesReady) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
await ref
|
||||
.read(communityListProvider.notifier)
|
||||
.enforceAgeRestrictionOnPush();
|
||||
consecutiveFailures.value = 0;
|
||||
} catch (error, stackTrace) {
|
||||
reportPushLeaseCleanupError(error, stackTrace);
|
||||
final delay = ageSignalPushSnapshotRetryDelay(
|
||||
consecutiveFailures.value,
|
||||
);
|
||||
await waitBeforeRetry(delay);
|
||||
if (!cancelled) {
|
||||
consecutiveFailures.value += 1;
|
||||
retryGeneration.value += 1;
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
return () => cancelled = true;
|
||||
},
|
||||
[
|
||||
communitiesReady,
|
||||
waitBeforeRetry,
|
||||
resumeGeneration.value,
|
||||
retryGeneration.value,
|
||||
],
|
||||
);
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'features/age_gate/age_signal_push_bootstrap.dart';
|
||||
import 'features/invites/invite_join_provider.dart';
|
||||
import 'shared/push/push_bootstrap.dart';
|
||||
import 'shared/push/push_bridge.dart';
|
||||
import 'shared/theme/theme_provider.dart';
|
||||
|
||||
@@ -27,7 +27,7 @@ Future<void> runBuzzApp(Widget app) async {
|
||||
(scope) => buildMobileInviteJoinRecovery(ref, scope),
|
||||
),
|
||||
],
|
||||
child: BuzzPushBootstrap(child: app),
|
||||
child: AgeSignalPushBootstrap(child: app),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,12 @@ final communityStorageProvider = Provider<CommunityStorage>((ref) {
|
||||
typedef CommunitySnapshotWriter =
|
||||
Future<void> Function(List<Community> communities);
|
||||
|
||||
typedef AgeGateCommunitySnapshotWriter =
|
||||
Future<void> Function(
|
||||
List<Community> communities, {
|
||||
required bool settleFence,
|
||||
});
|
||||
|
||||
/// Writes the complete persisted community set to storage shared with the iOS
|
||||
/// notification service extension. Tests override this provider to verify that
|
||||
/// every persistence path refreshes (or clears) the native snapshot.
|
||||
@@ -89,10 +95,54 @@ final communitySnapshotWriterProvider = Provider<CommunitySnapshotWriter>((
|
||||
return registerBuzzPushCommunitySnapshot;
|
||||
});
|
||||
|
||||
/// Strict writer used only for security-sensitive launch age-gate transitions.
|
||||
final ageGateCommunitySnapshotWriterProvider =
|
||||
Provider<AgeGateCommunitySnapshotWriter>((ref) {
|
||||
return registerBuzzPushCommunitySnapshotStrict;
|
||||
});
|
||||
|
||||
final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) {
|
||||
return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider));
|
||||
return _CommunitySnapshotSync(
|
||||
ref.read(communitySnapshotWriterProvider),
|
||||
ref.read(ageGateCommunitySnapshotWriterProvider),
|
||||
);
|
||||
});
|
||||
|
||||
/// Suspends or restores notification-service state at the launch age gate.
|
||||
typedef CommunitySnapshotAgeGateAction = Future<void> Function();
|
||||
|
||||
/// Temporarily removes notification-service presentation material while the
|
||||
/// launch age signal has not allowed access.
|
||||
final suspendCommunitySnapshotForAgeCheckProvider =
|
||||
Provider<CommunitySnapshotAgeGateAction>((ref) {
|
||||
return () async {
|
||||
try {
|
||||
await ref.read(_communitySnapshotSyncProvider).suspendForAgeCheck();
|
||||
pushCommunitySnapshotError.value = null;
|
||||
} catch (error, stackTrace) {
|
||||
reportPushCommunitySnapshotError(error, stackTrace);
|
||||
Error.throwWithStackTrace(error, stackTrace);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/// Restores notification-service presentation material after the launch age
|
||||
/// signal allows access.
|
||||
final resumeCommunitySnapshotAfterAgeCheckProvider =
|
||||
Provider<CommunitySnapshotAgeGateAction>((ref) {
|
||||
return () async {
|
||||
try {
|
||||
await ref
|
||||
.read(_communitySnapshotSyncProvider)
|
||||
.resumeAfterAgeCheck(ref.read(communityStorageProvider).loadAll);
|
||||
pushCommunitySnapshotError.value = null;
|
||||
} catch (error, stackTrace) {
|
||||
reportPushCommunitySnapshotError(error, stackTrace);
|
||||
Error.throwWithStackTrace(error, stackTrace);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
typedef CommunityPushLeaseDeactivator =
|
||||
Future<void> Function(Community community, {int? generation});
|
||||
|
||||
@@ -178,13 +228,70 @@ Future<void> _deactivateCommunityPushLease(
|
||||
}
|
||||
|
||||
class _CommunitySnapshotSync {
|
||||
_CommunitySnapshotSync(this._writer);
|
||||
_CommunitySnapshotSync(this._writer, this._ageGateWriter);
|
||||
|
||||
final CommunitySnapshotWriter _writer;
|
||||
String? _lastSuccessfulSnapshot;
|
||||
final AgeGateCommunitySnapshotWriter _ageGateWriter;
|
||||
({String content, bool strict, bool settleFence})? _lastSuccessfulSnapshot;
|
||||
bool _ageRestricted = false;
|
||||
bool _ageCheckSuspended = false;
|
||||
Future<void> _mutationTail = Future.value();
|
||||
|
||||
Future<void> write(List<Community> communities) async {
|
||||
final fingerprint = communities
|
||||
Future<void> _serializeMutation(Future<void> Function() operation) {
|
||||
final result = _mutationTail.then((_) => operation());
|
||||
_mutationTail = result.then<void>(
|
||||
(_) {},
|
||||
onError: (Object _, StackTrace _) {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> suspendForAgeCheck() => _serializeMutation(() async {
|
||||
if (!_ageRestricted) _ageCheckSuspended = true;
|
||||
await _write(
|
||||
const <Community>[],
|
||||
useAgeGateWriter: true,
|
||||
settleAgeGateFence: false,
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> resumeAfterAgeCheck(
|
||||
Future<List<Community>> Function() loadCommunities,
|
||||
) => _serializeMutation(() async {
|
||||
if (_ageRestricted) return;
|
||||
final communities = await loadCommunities();
|
||||
_ageCheckSuspended = false;
|
||||
await _write(communities, useAgeGateWriter: true, settleAgeGateFence: true);
|
||||
});
|
||||
|
||||
Future<void> write(
|
||||
List<Community> communities, {
|
||||
bool enforceAgeRestriction = false,
|
||||
bool useAgeGateWriter = false,
|
||||
bool settleAgeGateFence = false,
|
||||
}) {
|
||||
if (enforceAgeRestriction) _ageRestricted = true;
|
||||
// Ordinary exports must queue behind the entire restore, including its
|
||||
// storage read. Otherwise an export can finish during that read and then
|
||||
// be overwritten by the older list it returned.
|
||||
return _serializeMutation(
|
||||
() => _write(
|
||||
communities,
|
||||
useAgeGateWriter: useAgeGateWriter,
|
||||
settleAgeGateFence: settleAgeGateFence,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _write(
|
||||
List<Community> communities, {
|
||||
bool useAgeGateWriter = false,
|
||||
bool settleAgeGateFence = false,
|
||||
}) async {
|
||||
final effectiveCommunities = _ageRestricted || _ageCheckSuspended
|
||||
? const <Community>[]
|
||||
: communities;
|
||||
final contentFingerprint = effectiveCommunities
|
||||
.map(
|
||||
(community) => [
|
||||
community.id,
|
||||
@@ -199,9 +306,23 @@ class _CommunitySnapshotSync {
|
||||
].join('\u0000'),
|
||||
)
|
||||
.join('\u0001');
|
||||
// Both paths replace the same native data. Strict writes additionally
|
||||
// acknowledge the age-gate fence, so their mode is part of the request.
|
||||
final fingerprint = (
|
||||
content: contentFingerprint,
|
||||
strict: useAgeGateWriter,
|
||||
settleFence: settleAgeGateFence,
|
||||
);
|
||||
if (fingerprint == _lastSuccessfulSnapshot) return;
|
||||
|
||||
await _writer(communities);
|
||||
if (useAgeGateWriter) {
|
||||
await _ageGateWriter(
|
||||
effectiveCommunities,
|
||||
settleFence: settleAgeGateFence,
|
||||
);
|
||||
} else {
|
||||
await _writer(effectiveCommunities);
|
||||
}
|
||||
_lastSuccessfulSnapshot = fingerprint;
|
||||
}
|
||||
}
|
||||
@@ -220,6 +341,22 @@ Future<void> syncStoredCommunitySnapshot(Ref ref) async {
|
||||
await syncCommunitySnapshot(ref, communities);
|
||||
}
|
||||
|
||||
Future<void> _enforceAgeRestrictedCommunitySnapshot(Ref ref) async {
|
||||
try {
|
||||
await ref
|
||||
.read(_communitySnapshotSyncProvider)
|
||||
.write(
|
||||
const [],
|
||||
enforceAgeRestriction: true,
|
||||
useAgeGateWriter: true,
|
||||
settleAgeGateFence: false,
|
||||
);
|
||||
pushCommunitySnapshotError.value = null;
|
||||
} catch (error, stackTrace) {
|
||||
reportPushCommunitySnapshotError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
Future<void> _pushMutationTail = Future.value();
|
||||
final Map<String, Future<void>> _tombstoneAttempts = {};
|
||||
@@ -479,6 +616,68 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disables every stored push lease after the platform age gate restricts
|
||||
/// access, and retries journals left pending by an earlier launch.
|
||||
Future<void> enforceAgeRestrictionOnPush() async {
|
||||
// Fence every older or later authenticated export before touching storage.
|
||||
// The final empty write wins even if a stale export is already in I/O.
|
||||
await _enforceAgeRestrictedCommunitySnapshot(ref);
|
||||
final attempts = <({String id, bool advanceGeneration})>[];
|
||||
await _serializePushMutation(() async {
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
final current = state.value ?? await storage.loadAll();
|
||||
final updatedList = [...current];
|
||||
var changed = false;
|
||||
|
||||
for (var index = 0; index < current.length; index += 1) {
|
||||
final community = current[index];
|
||||
final pending =
|
||||
community.pushSubscriptionState.pendingTombstoneGeneration;
|
||||
if (!community.pushNotificationsEnabled) {
|
||||
if (pending != null) {
|
||||
attempts.add((id: community.id, advanceGeneration: true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var pushState = community.pushSubscriptionState;
|
||||
if (pushState.acceptedGeneration != null ||
|
||||
pushState.generationCursor != null) {
|
||||
final cursor =
|
||||
pushState.generationCursor ?? pushState.acceptedGeneration ?? 0;
|
||||
pushState = pushState.withPendingTombstone(cursor + 1);
|
||||
attempts.add((id: community.id, advanceGeneration: false));
|
||||
}
|
||||
final updated = community.copyWith(
|
||||
pushNotificationsEnabled: false,
|
||||
pushSubscriptionState: pushState,
|
||||
);
|
||||
updatedList[index] = updated;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
// Persist the complete restricted state in one secure-storage write so
|
||||
// termination can never leave later communities push-enabled.
|
||||
await storage.saveAll(updatedList);
|
||||
state = AsyncData(updatedList);
|
||||
}
|
||||
// A failed native clear is retried on the next restricted launch/resume.
|
||||
await syncCommunitySnapshot(ref, updatedList);
|
||||
});
|
||||
|
||||
for (final attempt in attempts) {
|
||||
try {
|
||||
await retryPendingPushLeaseTombstone(
|
||||
attempt.id,
|
||||
advanceGeneration: attempt.advanceGeneration,
|
||||
);
|
||||
} catch (_) {
|
||||
// The durable journal remains available for the next launch/resume.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes a durably journaled opt-out tombstone.
|
||||
///
|
||||
/// A retry advances the generation before network I/O. That makes an
|
||||
|
||||
@@ -80,6 +80,9 @@ class CommunityStorage {
|
||||
await _saveList(all);
|
||||
}
|
||||
|
||||
/// Replaces the complete stored community list in one secure-storage write.
|
||||
Future<void> saveAll(List<Community> communities) => _saveList(communities);
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
final all = await loadAll();
|
||||
all.removeWhere((w) => w.id == id);
|
||||
|
||||
@@ -251,9 +251,31 @@ void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) {
|
||||
debugPrintStack(stackTrace: stackTrace);
|
||||
}
|
||||
|
||||
Future<void> registerBuzzPushCommunitySnapshot(
|
||||
List<Community> communities,
|
||||
) async {
|
||||
Future<void> registerBuzzPushCommunitySnapshot(List<Community> communities) =>
|
||||
_registerBuzzPushCommunitySnapshot(communities, strict: false);
|
||||
|
||||
/// Writes the age-gate snapshot through a native path that must acknowledge
|
||||
/// both the app-group store and signing-key update.
|
||||
Future<void> registerBuzzPushCommunitySnapshotStrict(
|
||||
List<Community> communities, {
|
||||
required bool settleFence,
|
||||
}) => _registerBuzzPushCommunitySnapshot(
|
||||
communities,
|
||||
strict: true,
|
||||
settleFence: settleFence,
|
||||
);
|
||||
|
||||
/// Removes notifications rendered before a confirmed age restriction.
|
||||
Future<void> purgeAgeRestrictedBuzzNotifications() async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
||||
await _channel.invokeMethod<void>('purgeAgeRestrictedNotifications');
|
||||
}
|
||||
|
||||
Future<void> _registerBuzzPushCommunitySnapshot(
|
||||
List<Community> communities, {
|
||||
required bool strict,
|
||||
bool settleFence = false,
|
||||
}) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return;
|
||||
try {
|
||||
final snapshots = [
|
||||
@@ -283,12 +305,17 @@ Future<void> registerBuzzPushCommunitySnapshot(
|
||||
// Native storage is fail-closed; malformed keys are never exported.
|
||||
}
|
||||
}
|
||||
await _channel.invokeMethod<void>('syncPushSnapshot', {
|
||||
'section': 'communities',
|
||||
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
|
||||
'signingKeys': signingKeys,
|
||||
});
|
||||
await _channel.invokeMethod<void>(
|
||||
strict ? 'syncAgeGatePushSnapshot' : 'syncPushSnapshot',
|
||||
{
|
||||
'section': 'communities',
|
||||
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
|
||||
'signingKeys': signingKeys,
|
||||
if (strict) 'settleFence': settleFence,
|
||||
},
|
||||
);
|
||||
} on MissingPluginException {
|
||||
if (strict) rethrow;
|
||||
// Flutter tests and non-Runner embeddings do not install the native bridge.
|
||||
}
|
||||
}
|
||||
|
||||
463
mobile/test/features/age_gate/age_gate_app_test.dart
Normal file
463
mobile/test/features/age_gate/age_gate_app_test.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/app.dart';
|
||||
import 'package:buzz/features/age_gate/age_restriction_page.dart';
|
||||
import 'package:buzz/features/age_gate/age_signal_push_bootstrap.dart';
|
||||
import 'package:buzz/features/age_gate/age_signal_provider.dart';
|
||||
import 'package:buzz/features/channels/unread_badge/unread_badge_provider.dart';
|
||||
import 'package:buzz/features/home/home_page.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const appBadgeChannel = MethodChannel('app_badge_plus');
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(appBadgeChannel, null);
|
||||
});
|
||||
|
||||
test('backs off repeated snapshot transition failures', () {
|
||||
expect(ageSignalPushSnapshotRetryDelay(0), const Duration(seconds: 5));
|
||||
expect(ageSignalPushSnapshotRetryDelay(1), const Duration(seconds: 10));
|
||||
expect(ageSignalPushSnapshotRetryDelay(5), const Duration(seconds: 160));
|
||||
expect(ageSignalPushSnapshotRetryDelay(6), const Duration(minutes: 5));
|
||||
expect(ageSignalPushSnapshotRetryDelay(100), const Duration(minutes: 5));
|
||||
});
|
||||
|
||||
testWidgets('blocks authenticated app content', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: App()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(AgeRestrictionPage), findsOneWidget);
|
||||
expect(find.byType(HomePage), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('clears the app badge until age access is allowed', (
|
||||
tester,
|
||||
) async {
|
||||
final badgeCounts = <int>[];
|
||||
final ageSignal = _MutableAgeSignalNotifier();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(appBadgeChannel, (call) async {
|
||||
if (call.method == 'updateBadge') {
|
||||
badgeCounts.add(
|
||||
(call.arguments as Map<Object?, Object?>)['count']! as int,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()),
|
||||
ageSignalProvider.overrideWith(() => ageSignal),
|
||||
unreadBadgeProvider.overrideWithValue(
|
||||
const UnreadBadgeState(highPriorityCount: 7),
|
||||
),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(badgeCounts, isNotEmpty);
|
||||
expect(badgeCounts.last, 0);
|
||||
|
||||
ageSignal.setState(AgeSignalState.allowed);
|
||||
await tester.pump();
|
||||
expect(badgeCounts.last, 7);
|
||||
|
||||
ageSignal.setState(AgeSignalState.retryableFailure);
|
||||
await tester.pump();
|
||||
expect(badgeCounts.last, 0);
|
||||
});
|
||||
|
||||
testWidgets('keeps app content unmounted until the signal resolves', (
|
||||
tester,
|
||||
) async {
|
||||
final response = Completer<Object?>();
|
||||
final relaySession = _CountingRelaySessionNotifier();
|
||||
var requests = 0;
|
||||
var snapshotSuspensions = 0;
|
||||
var snapshotRestorations = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (call) {
|
||||
requests += 1;
|
||||
return response.future;
|
||||
});
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
relaySessionProvider.overrideWith(() => relaySession),
|
||||
suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue(
|
||||
() async {
|
||||
snapshotSuspensions += 1;
|
||||
if (snapshotSuspensions == 1) {
|
||||
throw StateError('injected suspension failure');
|
||||
}
|
||||
},
|
||||
),
|
||||
resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue(
|
||||
() async {
|
||||
snapshotRestorations += 1;
|
||||
if (snapshotRestorations == 1) {
|
||||
throw StateError('injected restoration failure');
|
||||
}
|
||||
},
|
||||
),
|
||||
ageSignalPushSnapshotRetryWaitProvider.overrideWithValue(
|
||||
(_) async {},
|
||||
),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: App()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(requests, 1);
|
||||
expect(find.bySemanticsLabel('Checking age eligibility'), findsOneWidget);
|
||||
expect(find.byType(HomePage), findsNothing);
|
||||
expect(find.byType(Navigator), findsNothing);
|
||||
expect(relaySession.builds, 0);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(snapshotSuspensions, 2);
|
||||
expect(snapshotRestorations, 0);
|
||||
|
||||
response.complete({'status': 'noSignal', 'ageUpper': null});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.bySemanticsLabel('Checking age eligibility'), findsNothing);
|
||||
expect(find.byType(HomePage), findsOneWidget);
|
||||
expect(find.byType(Navigator), findsOneWidget);
|
||||
expect(relaySession.builds, 1);
|
||||
expect(requests, 1);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(snapshotRestorations, 2);
|
||||
});
|
||||
|
||||
testWidgets('offers a retry after the native age check fails', (
|
||||
tester,
|
||||
) async {
|
||||
var requests = 0;
|
||||
var snapshotSuspensions = 0;
|
||||
var snapshotRestorations = 0;
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
ageSignalProvider.overrideWith(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
if (requests <= 2) {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
return {'status': 'noSignal', 'ageUpper': null};
|
||||
},
|
||||
delay: (_) async {},
|
||||
),
|
||||
),
|
||||
suspendCommunitySnapshotForAgeCheckProvider.overrideWithValue(
|
||||
() async => snapshotSuspensions += 1,
|
||||
),
|
||||
resumeCommunitySnapshotAfterAgeCheckProvider.overrideWithValue(
|
||||
() async => snapshotRestorations += 1,
|
||||
),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: App()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Try again'), findsOneWidget);
|
||||
expect(find.byType(HomePage), findsNothing);
|
||||
expect(snapshotSuspensions, greaterThanOrEqualTo(1));
|
||||
expect(snapshotRestorations, 0);
|
||||
|
||||
await tester.tap(find.text('Try again'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(requests, 3);
|
||||
expect(find.text('Try again'), findsNothing);
|
||||
expect(find.byType(HomePage), findsOneWidget);
|
||||
expect(snapshotRestorations, 1);
|
||||
});
|
||||
|
||||
testWidgets('reloads failed community storage on resume before cleanup', (
|
||||
tester,
|
||||
) async {
|
||||
final communities = _RecoveringCommunityListNotifier();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: (_, _) => null,
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
communityListProvider.overrideWith(() => communities),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: SizedBox()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(communities.builds, 1);
|
||||
expect(communities.cleanups, 0);
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(communities.builds, 2);
|
||||
expect(communities.cleanups, 1);
|
||||
});
|
||||
|
||||
testWidgets('retries failed restricted push cleanup without an app resume', (
|
||||
tester,
|
||||
) async {
|
||||
final communities = _RetryingCleanupCommunityListNotifier();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
communityListProvider.overrideWith(() => communities),
|
||||
ageSignalPushSnapshotRetryWaitProvider.overrideWithValue(
|
||||
(_) async {},
|
||||
),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: SizedBox()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(communities.cleanups, 2);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'purges restricted notifications before community storage recovers',
|
||||
(tester) async {
|
||||
var purges = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: (_, _) => null,
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
communityListProvider.overrideWith(
|
||||
() => _UnavailableCommunityListNotifier(),
|
||||
),
|
||||
ageRestrictedNotificationPurgerProvider.overrideWithValue(() async {
|
||||
purges += 1;
|
||||
}),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: SizedBox()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(purges, 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('retries a failed restricted notification purge', (tester) async {
|
||||
var purges = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
communityListProvider.overrideWith(
|
||||
() => _RetryingCleanupCommunityListNotifier(),
|
||||
),
|
||||
ageRestrictedNotificationPurgerProvider.overrideWithValue(() async {
|
||||
purges += 1;
|
||||
if (purges == 1) {
|
||||
throw StateError('injected notification purge failure');
|
||||
}
|
||||
}),
|
||||
ageSignalPushSnapshotRetryWaitProvider.overrideWithValue(
|
||||
(_) async {},
|
||||
),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: SizedBox()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(purges, 2);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'retries a successful purge for interactions donated by stale extensions',
|
||||
(tester) async {
|
||||
var purges = 0;
|
||||
final scheduledMaintenance = <VoidCallback>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(() => _BlockingAgeSignalNotifier()),
|
||||
communityListProvider.overrideWith(
|
||||
() => _SuccessfulCleanupCommunityListNotifier(),
|
||||
),
|
||||
ageRestrictedNotificationPurgerProvider.overrideWithValue(() async {
|
||||
purges += 1;
|
||||
}),
|
||||
ageRestrictedNotificationMaintenanceScheduleProvider
|
||||
.overrideWithValue((callback) {
|
||||
scheduledMaintenance.add(callback);
|
||||
return () {};
|
||||
}),
|
||||
],
|
||||
child: const AgeSignalPushBootstrap(child: SizedBox()),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(purges, 1);
|
||||
expect(scheduledMaintenance, hasLength(1));
|
||||
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < ageRestrictedNotificationMaintenancePurgeLimit;
|
||||
attempt += 1
|
||||
) {
|
||||
scheduledMaintenance.removeAt(0)();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(purges, 1 + ageRestrictedNotificationMaintenancePurgeLimit);
|
||||
expect(scheduledMaintenance, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _AuthenticatedAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
return const AuthState(status: AuthStatus.authenticated);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnauthenticatedAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
return const AuthState(status: AuthStatus.unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
class _MutableAgeSignalNotifier extends AgeSignalNotifier {
|
||||
@override
|
||||
AgeSignalState build() => AgeSignalState.checking;
|
||||
|
||||
@override
|
||||
Future<void> request() async {}
|
||||
|
||||
void setState(AgeSignalState next) => state = next;
|
||||
}
|
||||
|
||||
class _BlockingAgeSignalNotifier extends AgeSignalNotifier {
|
||||
@override
|
||||
AgeSignalState build() => AgeSignalState.restricted;
|
||||
|
||||
@override
|
||||
Future<void> request() async {}
|
||||
}
|
||||
|
||||
class _CountingRelaySessionNotifier extends RelaySessionNotifier {
|
||||
int builds = 0;
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
builds += 1;
|
||||
return const SessionState(status: SessionStatus.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecoveringCommunityListNotifier extends CommunityListNotifier {
|
||||
int builds = 0;
|
||||
int cleanups = 0;
|
||||
|
||||
@override
|
||||
Future<List<Community>> build() async {
|
||||
builds += 1;
|
||||
if (builds == 1) throw StateError('secure storage unavailable');
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> enforceAgeRestrictionOnPush() async {
|
||||
cleanups += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class _RetryingCleanupCommunityListNotifier extends CommunityListNotifier {
|
||||
int cleanups = 0;
|
||||
|
||||
@override
|
||||
Future<List<Community>> build() async => const [];
|
||||
|
||||
@override
|
||||
Future<void> enforceAgeRestrictionOnPush() async {
|
||||
cleanups += 1;
|
||||
if (cleanups == 1) {
|
||||
throw StateError('injected restricted cleanup failure');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SuccessfulCleanupCommunityListNotifier extends CommunityListNotifier {
|
||||
@override
|
||||
Future<List<Community>> build() async => const [];
|
||||
|
||||
@override
|
||||
Future<void> enforceAgeRestrictionOnPush() async {}
|
||||
}
|
||||
|
||||
class _UnavailableCommunityListNotifier extends CommunityListNotifier {
|
||||
@override
|
||||
Future<List<Community>> build() async {
|
||||
throw StateError('secure storage unavailable');
|
||||
}
|
||||
}
|
||||
18
mobile/test/features/age_gate/age_restriction_page_test.dart
Normal file
18
mobile/test/features/age_gate/age_restriction_page_test.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:buzz/features/age_gate/age_restriction_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows the 18+ restriction without a bypass', (tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: AgeRestrictionPage()));
|
||||
|
||||
expect(find.text('Buzz is for people 18 and older'), findsOneWidget);
|
||||
expect(
|
||||
find.text("You must be 18 or older to use Buzz under Buzz's Terms."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(TextButton), findsNothing);
|
||||
expect(find.byType(FilledButton), findsNothing);
|
||||
expect(find.byType(ElevatedButton), findsNothing);
|
||||
});
|
||||
}
|
||||
534
mobile/test/features/age_gate/age_signal_provider_test.dart
Normal file
534
mobile/test/features/age_gate/age_signal_provider_test.dart
Normal file
@@ -0,0 +1,534 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/age_gate/age_signal_provider.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, null);
|
||||
});
|
||||
|
||||
Future<AgeSignalState> requestWithResponse(Object? response) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (call) async {
|
||||
expect(call.method, 'requestAgeSignal');
|
||||
expect(call.arguments, isNull);
|
||||
return response;
|
||||
});
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(ageSignalProvider.notifier).request();
|
||||
return container.read(ageSignalProvider);
|
||||
}
|
||||
|
||||
test(
|
||||
'notification protection failure stays gated until a successful retry',
|
||||
() async {
|
||||
var protected = false;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (call) async {
|
||||
expect(call.method, 'requestAgeSignal');
|
||||
if (!protected) {
|
||||
throw PlatformException(
|
||||
code: 'age_signal_notification_protection_failed',
|
||||
);
|
||||
}
|
||||
return {'status': 'noSignal', 'ageUpper': null};
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(
|
||||
() => AgeSignalNotifier(delay: (_) async {}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(ageSignalProvider.notifier);
|
||||
|
||||
await notifier.request();
|
||||
expect(
|
||||
container.read(ageSignalProvider),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
protected = true;
|
||||
await notifier.request();
|
||||
expect(container.read(ageSignalProvider), AgeSignalState.allowed);
|
||||
},
|
||||
);
|
||||
|
||||
for (final failure in ['missing', 'malformed', 'timeout']) {
|
||||
test('failed native recovery stays retryable: $failure', () async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
var requests = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (call) async {
|
||||
if (call.method == 'requestAgeSignal') {
|
||||
requests += 1;
|
||||
return Completer<Object?>().future;
|
||||
}
|
||||
if (call.method == 'cancelAgeSignalRequest') return false;
|
||||
if (failure == 'malformed') return 'invalid';
|
||||
if (failure == 'timeout') return Completer<Object?>().future;
|
||||
throw MissingPluginException();
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, null),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(
|
||||
() => AgeSignalNotifier(
|
||||
delay: (_) async {},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
cancellationTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(ageSignalProvider.notifier);
|
||||
await notifier.request();
|
||||
await notifier.request();
|
||||
expect(requests, 1);
|
||||
expect(
|
||||
container.read(ageSignalProvider),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('native recovery acknowledgement permits a fresh request', () async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
var requests = 0;
|
||||
var resets = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (call) async {
|
||||
if (call.method == 'requestAgeSignal') {
|
||||
requests += 1;
|
||||
if (requests == 1) return Completer<Object?>().future;
|
||||
return {'status': 'signal', 'ageUpper': 17};
|
||||
}
|
||||
if (call.method == 'cancelAgeSignalRequest') return false;
|
||||
if (call.method == 'restartForAgeSignal') {
|
||||
resets += 1;
|
||||
return true;
|
||||
}
|
||||
throw MissingPluginException();
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, null),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
ageSignalProvider.overrideWith(
|
||||
() => AgeSignalNotifier(
|
||||
delay: (_) async {},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(ageSignalProvider.notifier);
|
||||
await notifier.request();
|
||||
expect(container.read(ageSignalProvider), AgeSignalState.retryableFailure);
|
||||
await notifier.request();
|
||||
expect(resets, 1);
|
||||
expect(requests, 2);
|
||||
expect(container.read(ageSignalProvider), AgeSignalState.restricted);
|
||||
});
|
||||
|
||||
test('blocks when the signal upper bound is 17', () async {
|
||||
expect(
|
||||
await requestWithResponse({'status': 'signal', 'ageUpper': 17}),
|
||||
AgeSignalState.restricted,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows when the signal upper bound is 18', () async {
|
||||
expect(
|
||||
await requestWithResponse({'status': 'signal', 'ageUpper': 18}),
|
||||
AgeSignalState.allowed,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows when a signal has an open-ended upper bound', () async {
|
||||
expect(
|
||||
await requestWithResponse({'status': 'signal', 'ageUpper': null}),
|
||||
AgeSignalState.allowed,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows when no signal is available', () async {
|
||||
expect(
|
||||
await requestWithResponse({'status': 'noSignal', 'ageUpper': null}),
|
||||
AgeSignalState.allowed,
|
||||
);
|
||||
});
|
||||
|
||||
test('exposes a gated retry after exhausted platform failures', () async {
|
||||
var requests = 0;
|
||||
var delays = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
if (requests <= 2) {
|
||||
throw PlatformException(code: 'unavailable');
|
||||
}
|
||||
return {'status': 'noSignal', 'ageUpper': null};
|
||||
},
|
||||
delay: (duration) async {
|
||||
expect(duration, ageSignalRetryDelay);
|
||||
delays += 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
expect(requests, 2);
|
||||
expect(delays, 1);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.allowed);
|
||||
expect(requests, 3);
|
||||
expect(delays, 1);
|
||||
});
|
||||
|
||||
test('retries a transient platform failure and applies the signal', () async {
|
||||
var requests = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
if (requests == 1) {
|
||||
throw PlatformException(code: 'age_signal_unavailable');
|
||||
}
|
||||
return {'status': 'signal', 'ageUpper': 17};
|
||||
},
|
||||
delay: (duration) async {
|
||||
expect(duration, ageSignalRetryDelay);
|
||||
},
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.restricted);
|
||||
expect(requests, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'times out a stalled single native request and exposes a retry',
|
||||
() async {
|
||||
var requests = 0;
|
||||
var delays = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () {
|
||||
requests += 1;
|
||||
return Completer<Map<Object?, Object?>?>().future;
|
||||
},
|
||||
delay: (duration) async {
|
||||
expect(duration, ageSignalRetryDelay);
|
||||
delays += 1;
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
cancelSignal: () async => false,
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
expect(requests, 1);
|
||||
expect(delays, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('a retry consumes the late result from a timed-out request', () async {
|
||||
var requests = 0;
|
||||
var delays = 0;
|
||||
final response = Completer<Map<Object?, Object?>?>();
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () {
|
||||
requests += 1;
|
||||
return response.future;
|
||||
},
|
||||
delay: (duration) async {
|
||||
expect(duration, ageSignalRetryDelay);
|
||||
delays += 1;
|
||||
response.complete({'status': 'signal', 'ageUpper': 17});
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
cancelSignal: () async => false,
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.restricted);
|
||||
expect(requests, 1);
|
||||
expect(delays, 1);
|
||||
});
|
||||
|
||||
test('a deliberate retry replaces an exhausted stalled request', () async {
|
||||
var requests = 0;
|
||||
var cancellations = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () {
|
||||
requests += 1;
|
||||
if (requests == 1) {
|
||||
return Completer<Map<Object?, Object?>?>().future;
|
||||
}
|
||||
return Future.value({'status': 'noSignal', 'ageUpper': null});
|
||||
},
|
||||
delay: (_) async {},
|
||||
cancelSignal: () async {
|
||||
cancellations += 1;
|
||||
return true;
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.allowed);
|
||||
expect(requests, 2);
|
||||
expect(cancellations, 1);
|
||||
});
|
||||
|
||||
test('an uncancellable stalled request remains the single flight', () async {
|
||||
var requests = 0;
|
||||
var restarts = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () {
|
||||
requests += 1;
|
||||
return Completer<Map<Object?, Object?>?>().future;
|
||||
},
|
||||
delay: (_) async {},
|
||||
cancelSignal: () async => false,
|
||||
restartSignal: () async {
|
||||
restarts += 1;
|
||||
return false;
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
expect(requests, 1);
|
||||
expect(restarts, 1);
|
||||
});
|
||||
|
||||
test('a stalled cancellation still exposes the retry action', () async {
|
||||
var cancellations = 0;
|
||||
var restarts = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () => Completer<Map<Object?, Object?>?>().future,
|
||||
delay: (_) async {},
|
||||
cancelSignal: () {
|
||||
cancellations += 1;
|
||||
return Completer<bool>().future;
|
||||
},
|
||||
restartSignal: () async {
|
||||
restarts += 1;
|
||||
return false;
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
cancellationTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(cancellations, 1);
|
||||
expect(restarts, 1);
|
||||
});
|
||||
|
||||
test('a malformed cancellation still exposes the retry action', () async {
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () => Completer<Map<Object?, Object?>?>().future,
|
||||
delay: (_) async {},
|
||||
cancelSignal: () async {
|
||||
final dynamic malformed = 'not-a-boolean';
|
||||
return malformed;
|
||||
},
|
||||
requestTimeout: const Duration(milliseconds: 1),
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
});
|
||||
|
||||
test('keeps a missing native channel gated and retryable', () async {
|
||||
var requests = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
throw MissingPluginException('buzz/age_signal');
|
||||
},
|
||||
delay: (duration) async {
|
||||
expect(duration, ageSignalRetryDelay);
|
||||
},
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
expect(requests, 2);
|
||||
});
|
||||
|
||||
test('keeps malformed platform responses gated and retryable', () async {
|
||||
expect(
|
||||
await requestWithResponse({'status': 'unknown', 'ageUpper': null}),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
expect(
|
||||
await requestWithResponse({'status': 'signal', 'ageUpper': '17'}),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
expect(
|
||||
await requestWithResponse({
|
||||
'status': 'signal',
|
||||
'ageUpper': 17,
|
||||
'ageLower': 13,
|
||||
}),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
expect(
|
||||
await requestWithResponse({'status': 'noSignal', 'ageUpper': 17}),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
expect(await requestWithResponse(null), AgeSignalState.retryableFailure);
|
||||
expect(
|
||||
await requestWithResponse(['not', 'a', 'map']),
|
||||
AgeSignalState.retryableFailure,
|
||||
);
|
||||
});
|
||||
|
||||
test('a deliberate retry can recover from a malformed response', () async {
|
||||
var requests = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
return requests == 1
|
||||
? {'status': 'unknown', 'ageUpper': null}
|
||||
: {'status': 'noSignal', 'ageUpper': null};
|
||||
},
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.allowed);
|
||||
expect(requests, 2);
|
||||
});
|
||||
|
||||
test('a deliberate retry can recover from a null response', () async {
|
||||
var requests = 0;
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(
|
||||
requestSignal: () async {
|
||||
requests += 1;
|
||||
return requests == 1
|
||||
? null
|
||||
: {'status': 'noSignal', 'ageUpper': null};
|
||||
},
|
||||
),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.retryableFailure);
|
||||
|
||||
await container.read(provider.notifier).request();
|
||||
expect(container.read(provider), AgeSignalState.allowed);
|
||||
expect(requests, 2);
|
||||
});
|
||||
|
||||
test('requests the signal at most once', () async {
|
||||
var requests = 0;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ageSignalChannel, (_) async {
|
||||
requests += 1;
|
||||
return {'status': 'noSignal', 'ageUpper': null};
|
||||
});
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final notifier = container.read(ageSignalProvider.notifier);
|
||||
|
||||
await notifier.request();
|
||||
await notifier.request();
|
||||
|
||||
expect(requests, 1);
|
||||
});
|
||||
|
||||
test('remains checking until the platform request completes', () async {
|
||||
final response = Completer<Map<Object?, Object?>?>();
|
||||
final provider = NotifierProvider<AgeSignalNotifier, AgeSignalState>(
|
||||
() => AgeSignalNotifier(requestSignal: () => response.future),
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final request = container.read(provider.notifier).request();
|
||||
|
||||
expect(container.read(provider), AgeSignalState.checking);
|
||||
response.complete({'status': 'noSignal', 'ageUpper': null});
|
||||
await request;
|
||||
expect(container.read(provider), AgeSignalState.allowed);
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:buzz/shared/deeplink/deep_link.dart';
|
||||
import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/community/community_storage_test.dart';
|
||||
@@ -342,7 +343,7 @@ void main() {
|
||||
name: 'Relay',
|
||||
relayUrl: relayUrl,
|
||||
pubkey: 'pubkey',
|
||||
nsec: 'nsec',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
addedAt: DateTime.utc(2026),
|
||||
starterSetupIncomplete: true,
|
||||
),
|
||||
@@ -416,7 +417,7 @@ void main() {
|
||||
name: 'Relay',
|
||||
relayUrl: relayUrl,
|
||||
pubkey: 'pubkey',
|
||||
nsec: 'nsec',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
addedAt: DateTime.utc(2026),
|
||||
starterSetupIncomplete: true,
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/auth/auth_provider.dart';
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/community/community_provider.dart';
|
||||
import 'package:buzz/shared/community/community_storage.dart';
|
||||
@@ -43,12 +44,24 @@ void main() {
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
ProviderContainer createContainer() {
|
||||
Future<void> writeSnapshot(List<Community> communities) async {
|
||||
snapshots.add(List.of(communities));
|
||||
}
|
||||
|
||||
Future<void> writeAgeGateSnapshot(
|
||||
List<Community> communities, {
|
||||
required bool settleFence,
|
||||
}) async {
|
||||
snapshots.add(List.of(communities));
|
||||
}
|
||||
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((communities) async {
|
||||
snapshots.add(List.of(communities));
|
||||
}),
|
||||
communitySnapshotWriterProvider.overrideWithValue(writeSnapshot),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue(
|
||||
writeAgeGateSnapshot,
|
||||
),
|
||||
communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator),
|
||||
communityPushLeaseRevocationEnqueuerProvider.overrideWithValue((
|
||||
community,
|
||||
@@ -71,6 +84,93 @@ void main() {
|
||||
expect(snapshots, [isEmpty]);
|
||||
});
|
||||
|
||||
test(
|
||||
'age gate strict clear is not deduplicated against an ordinary clear',
|
||||
() async {
|
||||
final ordinarySnapshots = <List<Community>>[];
|
||||
final ageGateSnapshots = <List<Community>>[];
|
||||
final settleFenceValues = <bool>[];
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((
|
||||
communities,
|
||||
) async {
|
||||
ordinarySnapshots.add(List.of(communities));
|
||||
}),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
ageGateSnapshots.add(List.of(communities));
|
||||
settleFenceValues.add(settleFence);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await container.read(communityListProvider.future);
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
|
||||
expect(ordinarySnapshots, [isEmpty]);
|
||||
expect(ageGateSnapshots, [isEmpty]);
|
||||
expect(settleFenceValues, [isFalse]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'age gate strict clear keeps retrying after restriction becomes final',
|
||||
() async {
|
||||
var strictAttempts = 0;
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((_) async {}),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
strictAttempts += 1;
|
||||
expect(communities, isEmpty);
|
||||
expect(settleFence, isFalse);
|
||||
if (strictAttempts < 3) {
|
||||
throw StateError('strict snapshot unavailable');
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
container.read(suspendCommunitySnapshotForAgeCheckProvider)(),
|
||||
throwsStateError,
|
||||
);
|
||||
await container
|
||||
.read(communityListProvider.notifier)
|
||||
.enforceAgeRestrictionOnPush();
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
|
||||
expect(strictAttempts, 3);
|
||||
},
|
||||
);
|
||||
|
||||
test('empty community resume still acknowledges the age fence', () async {
|
||||
final acknowledgements = <bool>[];
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
expect(communities, isEmpty);
|
||||
acknowledgements.add(settleFence);
|
||||
}),
|
||||
],
|
||||
);
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)();
|
||||
expect(acknowledgements, [false, true]);
|
||||
});
|
||||
|
||||
test('exports migrated communities on startup', () async {
|
||||
final community = Community.create(
|
||||
name: 'Migrated',
|
||||
@@ -355,6 +455,332 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'age restriction clears push state and retries pending leases',
|
||||
() async {
|
||||
var failPendingOnce = true;
|
||||
deactivator = (community, {generation}) async {
|
||||
deactivatedCommunityIds.add(community.id);
|
||||
deactivationGenerations.add(generation);
|
||||
if (community.name == 'Pending' && failPendingOnce) {
|
||||
failPendingOnce = false;
|
||||
throw StateError('injected pending failure');
|
||||
}
|
||||
};
|
||||
container = createContainer();
|
||||
await container.read(communityListProvider.future);
|
||||
final subscription = BuzzPushSubscription(
|
||||
filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]),
|
||||
notificationClass: 'default',
|
||||
);
|
||||
final active =
|
||||
Community.create(
|
||||
name: 'Active',
|
||||
relayUrl: 'https://active.example.com',
|
||||
).copyWith(
|
||||
pushNotificationsEnabled: true,
|
||||
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: [subscription],
|
||||
).withAccepted(subscriptions: [subscription], generation: 4),
|
||||
);
|
||||
final pending =
|
||||
Community.create(
|
||||
name: 'Pending',
|
||||
relayUrl: 'https://pending.example.com',
|
||||
).copyWith(
|
||||
pushSubscriptionState:
|
||||
BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: [subscription],
|
||||
)
|
||||
.withAccepted(
|
||||
subscriptions: [subscription],
|
||||
generation: 6,
|
||||
)
|
||||
.withPendingTombstone(7),
|
||||
);
|
||||
final notifier = container.read(communityListProvider.notifier);
|
||||
await notifier.addCommunity(active);
|
||||
await notifier.addCommunity(pending);
|
||||
|
||||
await notifier.enforceAgeRestrictionOnPush();
|
||||
await notifier.enforceAgeRestrictionOnPush();
|
||||
|
||||
final stored = await communityStorage.loadAll();
|
||||
expect(
|
||||
stored.every((community) => !community.pushNotificationsEnabled),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
stored.every(
|
||||
(community) =>
|
||||
community.pushSubscriptionState.pendingTombstoneGeneration ==
|
||||
null,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
snapshots.last.every(
|
||||
(community) => !community.pushNotificationsEnabled,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(deactivatedCommunityIds, [active.id, pending.id, pending.id]);
|
||||
expect(deactivationGenerations, [5, 8, 9]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'age restriction persists all disabled communities in one write',
|
||||
() async {
|
||||
container = createContainer();
|
||||
final first = Community.create(
|
||||
name: 'First',
|
||||
relayUrl: 'https://first.example.com',
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
final second = Community.create(
|
||||
name: 'Second',
|
||||
relayUrl: 'https://second.example.com',
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(first);
|
||||
await communityStorage.save(second);
|
||||
await container.read(communityListProvider.future);
|
||||
final writesBefore = fakeSecure.writeCount('buzz_communities');
|
||||
|
||||
await container
|
||||
.read(communityListProvider.notifier)
|
||||
.enforceAgeRestrictionOnPush();
|
||||
|
||||
final stored = await communityStorage.loadAll();
|
||||
expect(stored, hasLength(2));
|
||||
expect(
|
||||
stored.every((community) => !community.pushNotificationsEnabled),
|
||||
isTrue,
|
||||
);
|
||||
expect(fakeSecure.writeCount('buzz_communities') - writesBefore, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'age restriction fences a stale authenticated snapshot export',
|
||||
() async {
|
||||
final staleWriteStarted = Completer<void>();
|
||||
final releaseStaleWrite = Completer<void>();
|
||||
final completedSnapshots = <List<Community>>[];
|
||||
final community = Community.create(
|
||||
name: 'Restricted',
|
||||
relayUrl: 'https://restricted.example.com',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(community);
|
||||
await communityStorage.saveActiveId(community.id);
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((
|
||||
communities,
|
||||
) async {
|
||||
final captured = List.of(communities);
|
||||
if (captured.isNotEmpty && !staleWriteStarted.isCompleted) {
|
||||
staleWriteStarted.complete();
|
||||
await releaseStaleWrite.future;
|
||||
}
|
||||
completedSnapshots.add(captured);
|
||||
}),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
completedSnapshots.add(List.of(communities));
|
||||
expect(settleFence, isFalse);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
final staleAuthBuild = container.read(authProvider.future);
|
||||
await staleWriteStarted.future;
|
||||
final restriction = container
|
||||
.read(communityListProvider.notifier)
|
||||
.enforceAgeRestrictionOnPush();
|
||||
|
||||
releaseStaleWrite.complete();
|
||||
await staleAuthBuild;
|
||||
await restriction;
|
||||
|
||||
expect(
|
||||
completedSnapshots.any((snapshot) => snapshot.isNotEmpty),
|
||||
isTrue,
|
||||
);
|
||||
expect(completedSnapshots.last, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'age check suspension clears a stale snapshot and allowed restores it',
|
||||
() async {
|
||||
final staleWriteStarted = Completer<void>();
|
||||
final releaseStaleWrite = Completer<void>();
|
||||
final completedSnapshots = <List<Community>>[];
|
||||
final settleFenceValues = <bool>[];
|
||||
final community = Community.create(
|
||||
name: 'Age gated',
|
||||
relayUrl: 'https://age-gated.example.com',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(community);
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((
|
||||
communities,
|
||||
) async {
|
||||
final captured = List.of(communities);
|
||||
if (captured.isNotEmpty && !staleWriteStarted.isCompleted) {
|
||||
staleWriteStarted.complete();
|
||||
await releaseStaleWrite.future;
|
||||
}
|
||||
completedSnapshots.add(captured);
|
||||
}),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
completedSnapshots.add(List.of(communities));
|
||||
settleFenceValues.add(settleFence);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
final staleExport = container.read(communityListProvider.future);
|
||||
await staleWriteStarted.future;
|
||||
final suspension = container.read(
|
||||
suspendCommunitySnapshotForAgeCheckProvider,
|
||||
)();
|
||||
|
||||
releaseStaleWrite.complete();
|
||||
await staleExport;
|
||||
await suspension;
|
||||
|
||||
expect(completedSnapshots.last, isEmpty);
|
||||
expect(settleFenceValues, [isFalse]);
|
||||
|
||||
await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)();
|
||||
|
||||
expect(completedSnapshots.last.single.id, community.id);
|
||||
expect(settleFenceValues, [isFalse, isTrue]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'removal clears an ordinary empty snapshot after strict restoration',
|
||||
() async {
|
||||
final community = Community.create(
|
||||
name: 'Restored',
|
||||
relayUrl: 'https://restored.example.com',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(community);
|
||||
container = createContainer();
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
// Bootstrap loads C while suspension exports an ordinary empty snapshot.
|
||||
await container.read(communityListProvider.future);
|
||||
await container.read(resumeCommunitySnapshotAfterAgeCheckProvider)();
|
||||
expect(snapshots.last.single.nsec, community.nsec);
|
||||
|
||||
await container
|
||||
.read(communityListProvider.notifier)
|
||||
.removeCommunity(community.id);
|
||||
|
||||
expect(await communityStorage.loadAll(), isEmpty);
|
||||
expect(
|
||||
snapshots.map((items) => items.map((c) => c.id).toList()).toList(),
|
||||
[
|
||||
<String>[],
|
||||
<String>[],
|
||||
[community.id],
|
||||
<String>[],
|
||||
],
|
||||
);
|
||||
expect(snapshots.last, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('resume reloads storage after an in-flight snapshot mutation', () async {
|
||||
final community = Community.create(
|
||||
name: 'Changing',
|
||||
relayUrl: 'https://changing.example.com',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(community);
|
||||
final writeStarted = Completer<void>();
|
||||
final releaseWrite = Completer<void>();
|
||||
final strictSnapshots = <List<Community>>[];
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
communityStorageProvider.overrideWithValue(communityStorage),
|
||||
communitySnapshotWriterProvider.overrideWithValue((_) async {
|
||||
writeStarted.complete();
|
||||
await releaseWrite.future;
|
||||
await communityStorage.save(
|
||||
community.copyWith(pushNotificationsEnabled: false),
|
||||
);
|
||||
}),
|
||||
ageGateCommunitySnapshotWriterProvider.overrideWithValue((
|
||||
communities, {
|
||||
required settleFence,
|
||||
}) async {
|
||||
strictSnapshots.add(List.of(communities));
|
||||
}),
|
||||
],
|
||||
);
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
final bootstrap = container.read(communityListProvider.future);
|
||||
await writeStarted.future;
|
||||
final resume = container.read(
|
||||
resumeCommunitySnapshotAfterAgeCheckProvider,
|
||||
)();
|
||||
// Let resume reach its in-flight-write barrier before completing the mutation.
|
||||
await Future<void>(() {});
|
||||
releaseWrite.complete();
|
||||
await bootstrap;
|
||||
await resume;
|
||||
expect(strictSnapshots.last.single.pushNotificationsEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('removal during resume cannot leave restored credentials', () async {
|
||||
final controlledStorage = _PausedCommunityStorage();
|
||||
communityStorage = controlledStorage;
|
||||
final community = Community.create(
|
||||
name: 'Removing',
|
||||
relayUrl: 'https://removing.example.com',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
).copyWith(pushNotificationsEnabled: true);
|
||||
await communityStorage.save(community);
|
||||
container = createContainer();
|
||||
await container.read(communityListProvider.future);
|
||||
await container.read(suspendCommunitySnapshotForAgeCheckProvider)();
|
||||
|
||||
final removal = container
|
||||
.read(communityListProvider.notifier)
|
||||
.removeCommunity(community.id);
|
||||
await controlledStorage.removeStarted.future;
|
||||
controlledStorage.pauseNextLoad = true;
|
||||
final resume = container.read(
|
||||
resumeCommunitySnapshotAfterAgeCheckProvider,
|
||||
)();
|
||||
await controlledStorage.loadStarted.future;
|
||||
controlledStorage.releaseRemoval.complete();
|
||||
await controlledStorage.removalPersisted.future;
|
||||
// Let the removal submit its native update while resume holds the old list.
|
||||
await Future<void>(() {});
|
||||
controlledStorage.releaseLoad.complete();
|
||||
await Future.wait([removal, resume]);
|
||||
|
||||
expect(await communityStorage.loadAll(), isEmpty);
|
||||
expect(snapshots.last, isEmpty);
|
||||
});
|
||||
|
||||
test('removeCommunity removes from list', () async {
|
||||
container = createContainer();
|
||||
await container.read(communityListProvider.future);
|
||||
@@ -655,3 +1081,34 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _PausedCommunityStorage extends CommunityStorage {
|
||||
_PausedCommunityStorage() : super(secure: FakeSecureStorage());
|
||||
|
||||
final removeStarted = Completer<void>();
|
||||
final releaseRemoval = Completer<void>();
|
||||
final removalPersisted = Completer<void>();
|
||||
final loadStarted = Completer<void>();
|
||||
final releaseLoad = Completer<void>();
|
||||
bool pauseNextLoad = false;
|
||||
|
||||
@override
|
||||
Future<void> remove(String id) async {
|
||||
removeStarted.complete();
|
||||
await releaseRemoval.future;
|
||||
await super.remove(id);
|
||||
removalPersisted.complete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Community>> loadAll() async {
|
||||
final pause = pauseNextLoad;
|
||||
pauseNextLoad = false;
|
||||
final communities = await super.loadAll();
|
||||
if (pause) {
|
||||
loadStarted.complete();
|
||||
await releaseLoad.future;
|
||||
}
|
||||
return communities;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:buzz/shared/push/push_subscription.dart';
|
||||
/// in-memory logic.
|
||||
class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
||||
final Map<String, String> _data = {};
|
||||
final Map<String, int> _writeCounts = {};
|
||||
|
||||
@override
|
||||
Future<String?> read({
|
||||
@@ -34,6 +35,7 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
||||
AppleOptions? mOptions,
|
||||
WindowsOptions? wOptions,
|
||||
}) async {
|
||||
_writeCounts[key] = (_writeCounts[key] ?? 0) + 1;
|
||||
if (value != null) {
|
||||
_data[key] = value;
|
||||
} else {
|
||||
@@ -86,6 +88,7 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage {
|
||||
// Convenience for setting up test data.
|
||||
String? operator [](String key) => _data[key];
|
||||
void operator []=(String key, String value) => _data[key] = value;
|
||||
int writeCount(String key) => _writeCounts[key] ?? 0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -60,6 +60,52 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('strict age-gate snapshot requires a native handler', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, null);
|
||||
|
||||
await expectLater(
|
||||
registerBuzzPushCommunitySnapshotStrict(const [], settleFence: false),
|
||||
throwsA(isA<MissingPluginException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'strict age-gate snapshot uses the acknowledged native method',
|
||||
() async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, (call) async {
|
||||
expect(call.method, 'syncAgeGatePushSnapshot');
|
||||
expect(call.arguments, {
|
||||
'section': 'communities',
|
||||
'communities': <Object?>[],
|
||||
'signingKeys': <String, String>{},
|
||||
'settleFence': true,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
await registerBuzzPushCommunitySnapshotStrict(
|
||||
const [],
|
||||
settleFence: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('purges notifications delivered before age restriction', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_channel, (call) async {
|
||||
expect(call.method, 'purgeAgeRestrictedNotifications');
|
||||
expect(call.arguments, isNull);
|
||||
return null;
|
||||
});
|
||||
|
||||
await purgeAgeRestrictedBuzzNotifications();
|
||||
});
|
||||
|
||||
test('reads native notification authorization status', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/app.dart';
|
||||
import 'package:buzz/features/age_gate/age_signal_provider.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
|
||||
@@ -16,6 +17,7 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _FakeAuthNotifier()),
|
||||
ageSignalProvider.overrideWith(() => _AllowedAgeSignalNotifier()),
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
],
|
||||
child: const App(),
|
||||
@@ -26,6 +28,14 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
class _AllowedAgeSignalNotifier extends AgeSignalNotifier {
|
||||
@override
|
||||
AgeSignalState build() => AgeSignalState.allowed;
|
||||
|
||||
@override
|
||||
Future<void> request() async {}
|
||||
}
|
||||
|
||||
class _FakeAuthNotifier extends AuthNotifier {
|
||||
@override
|
||||
Future<AuthState> build() async {
|
||||
|
||||
Reference in New Issue
Block a user