diff --git a/Cargo.lock b/Cargo.lock index e57698003fc..88344f53606 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,9 +455,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -466,14 +466,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -2319,7 +2320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -8108,9 +8109,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -8173,9 +8174,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af27ee2438..87d9eb1b474 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -613,9 +613,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -623,9 +623,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -2308,7 +2308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -8928,9 +8928,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -8993,9 +8993,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -10905,7 +10905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index d0ae877e9dd..8ba678d1dd0 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -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")) diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index 66cf6059793..a977aa31b9c 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -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 { + return mapOf( + "status" to "signal", + "ageUpper" to ageUpper, + ) +} + +internal fun noAgeSignalPayload(): Map { + 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" diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt new file mode 100644 index 00000000000..251e5fc39a4 --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgeSignalPayloadTest.kt @@ -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 + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift new file mode 100644 index 00000000000..3b7aea43cf9 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeRestrictionFence.swift @@ -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(_ 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) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift new file mode 100644 index 00000000000..8db96ae0342 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzAgeSignalPayload.swift @@ -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] + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift index 60f4d9e5110..8ae0a5f81e1 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -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) } } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionDeletionDeadline.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionDeletionDeadline.swift new file mode 100644 index 00000000000..66eaa60314f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzInteractionDeletionDeadline.swift @@ -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) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationHandoff.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationHandoff.swift new file mode 100644 index 00000000000..4b8f5eea311 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationHandoff.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Hands notification content back exactly once, before asynchronous cleanup. +public final class BuzzNotificationHandoff { + 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() } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift new file mode 100644 index 00000000000..67c7966a4e3 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzAgeSignalPayloadTests.swift @@ -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) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzCommunicationDeletionTimeoutTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzCommunicationDeletionTimeoutTests.swift new file mode 100644 index 00000000000..90f7e6dab90 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzCommunicationDeletionTimeoutTests.swift @@ -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 diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionDeletionDeadlineTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionDeletionDeadlineTests.swift new file mode 100644 index 00000000000..114dcde9fdc --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzInteractionDeletionDeadlineTests.swift @@ -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) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift new file mode 100644 index 00000000000..c4f09554e6f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationHandoffTests.swift @@ -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 { 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 { 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"]) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index ed592c576f6..bc6c62e013b 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -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 } + } + } +} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 44965720a9f..ac3191b1691 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -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? 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( diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 47c99eb9eab..b5b21445c69 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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? - 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? + 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 diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift index 6253f656ce4..5feba1cc2cc 100644 --- a/mobile/ios/Runner/PushNativeState.swift +++ b/mobile/ios/Runner/PushNativeState.swift @@ -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) diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index 5c7bfb5fac0..cf2201d29c4 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -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 } diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements index 7fca08a0f35..242ba96517d 100644 --- a/mobile/ios/Runner/Runner.entitlements +++ b/mobile/ios/Runner/Runner.entitlements @@ -6,6 +6,8 @@ $(BUZZ_IOS_PUSH_ENVIRONMENT) com.apple.developer.devicecheck.appattest-environment $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.declared-age-range + com.apple.developer.usernotifications.communication com.apple.security.application-groups diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 156aa615210..cb8f453c14e 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -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 } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 9f3724fd2d1..6531bd26f63 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -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(unreadBadgeProvider, (_, next) { - applyBadge(next); - }); + }, [ageSignalState]); + if (ageSignalState == AgeSignalState.allowed) { + ref.listen(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'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/age_gate/age_restriction_page.dart b/mobile/lib/features/age_gate/age_restriction_page.dart new file mode 100644 index 00000000000..f5be3e683c6 --- /dev/null +++ b/mobile/lib/features/age_gate/age_restriction_page.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/age_gate/age_signal_provider.dart b/mobile/lib/features/age_gate/age_signal_provider.dart new file mode 100644 index 00000000000..2577e342ce1 --- /dev/null +++ b/mobile/lib/features/age_gate/age_signal_provider.dart @@ -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?> Function(); + +/// Waits before retrying a failed native age-signal request. +typedef AgeSignalDelay = Future Function(Duration duration); +typedef AgeSignalCancel = Future Function(); + +/// Returns true only when native state is retired for an in-process retry. +typedef AgeSignalRestart = Future Function(); + +Future?> _requestPlatformAgeSignal() => + ageSignalChannel.invokeMapMethod('requestAgeSignal'); + +Future _delayAgeSignalRetry(Duration duration) => + Future.delayed(duration); +Future _cancelPlatformAgeSignal() async => + await ageSignalChannel.invokeMethod('cancelAgeSignalRequest') ?? + false; +Future _restartForPlatformAgeSignal() async { + final retired = await ageSignalChannel.invokeMethod( + 'restartForAgeSignal', + ); + if (retired == null) { + throw StateError('Missing age signal reset acknowledgement.'); + } + return retired; +} + +bool shouldBlockForAgeSignal(Map 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 { + /// 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? _requestInFlight; + Future?>? _nativeRequestInFlight; + + @override + AgeSignalState build() => AgeSignalState.checking; + + Future 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 _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? 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?> _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 _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.new, +); diff --git a/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart b/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart new file mode 100644 index 00000000000..073302a926a --- /dev/null +++ b/mobile/lib/features/age_gate/age_signal_push_bootstrap.dart @@ -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 Function(Duration duration); + +/// Retry wait used by the launch age gate's notification snapshot boundary. +final ageSignalPushSnapshotRetryWaitProvider = + Provider((ref) { + return Future.delayed; + }); + +/// Native notification purge performed once restriction is confirmed. +final ageRestrictedNotificationPurgerProvider = + Provider 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((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(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; + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 8f360a3db91..5b40be40d3e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 runBuzzApp(Widget app) async { (scope) => buildMobileInviteJoinRecovery(ref, scope), ), ], - child: BuzzPushBootstrap(child: app), + child: AgeSignalPushBootstrap(child: app), ), ); } diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 7788082745e..8deb39a0b61 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -80,6 +80,12 @@ final communityStorageProvider = Provider((ref) { typedef CommunitySnapshotWriter = Future Function(List communities); +typedef AgeGateCommunitySnapshotWriter = + Future Function( + List 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(( return registerBuzzPushCommunitySnapshot; }); +/// Strict writer used only for security-sensitive launch age-gate transitions. +final ageGateCommunitySnapshotWriterProvider = + Provider((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 Function(); + +/// Temporarily removes notification-service presentation material while the +/// launch age signal has not allowed access. +final suspendCommunitySnapshotForAgeCheckProvider = + Provider((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((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 Function(Community community, {int? generation}); @@ -178,13 +228,70 @@ Future _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 _mutationTail = Future.value(); + + Future _serializeMutation(Future Function() operation) { + final result = _mutationTail.then((_) => operation()); + _mutationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + Future suspendForAgeCheck() => _serializeMutation(() async { + if (!_ageRestricted) _ageCheckSuspended = true; + await _write( + const [], + useAgeGateWriter: true, + settleAgeGateFence: false, + ); + }); + + Future resumeAfterAgeCheck( + Future> Function() loadCommunities, + ) => _serializeMutation(() async { + if (_ageRestricted) return; + final communities = await loadCommunities(); + _ageCheckSuspended = false; + await _write(communities, useAgeGateWriter: true, settleAgeGateFence: true); + }); - Future write(List communities) async { - final fingerprint = communities + Future write( + List 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 _write( + List communities, { + bool useAgeGateWriter = false, + bool settleAgeGateFence = false, + }) async { + final effectiveCommunities = _ageRestricted || _ageCheckSuspended + ? const [] + : 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 syncStoredCommunitySnapshot(Ref ref) async { await syncCommunitySnapshot(ref, communities); } +Future _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> { Future _pushMutationTail = Future.value(); final Map> _tombstoneAttempts = {}; @@ -479,6 +616,68 @@ class CommunityListNotifier extends AsyncNotifier> { } } + /// Disables every stored push lease after the platform age gate restricts + /// access, and retries journals left pending by an earlier launch. + Future 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 diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart index 14e714a1763..cf846c8a828 100644 --- a/mobile/lib/shared/community/community_storage.dart +++ b/mobile/lib/shared/community/community_storage.dart @@ -80,6 +80,9 @@ class CommunityStorage { await _saveList(all); } + /// Replaces the complete stored community list in one secure-storage write. + Future saveAll(List communities) => _saveList(communities); + Future remove(String id) async { final all = await loadAll(); all.removeWhere((w) => w.id == id); diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index a0c674f14f6..6f91e5625dc 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -251,9 +251,31 @@ void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { debugPrintStack(stackTrace: stackTrace); } -Future registerBuzzPushCommunitySnapshot( - List communities, -) async { +Future registerBuzzPushCommunitySnapshot(List 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 registerBuzzPushCommunitySnapshotStrict( + List communities, { + required bool settleFence, +}) => _registerBuzzPushCommunitySnapshot( + communities, + strict: true, + settleFence: settleFence, +); + +/// Removes notifications rendered before a confirmed age restriction. +Future purgeAgeRestrictedBuzzNotifications() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + await _channel.invokeMethod('purgeAgeRestrictedNotifications'); +} + +Future _registerBuzzPushCommunitySnapshot( + List communities, { + required bool strict, + bool settleFence = false, +}) async { if (defaultTargetPlatform != TargetPlatform.iOS) return; try { final snapshots = [ @@ -283,12 +305,17 @@ Future registerBuzzPushCommunitySnapshot( // Native storage is fail-closed; malformed keys are never exported. } } - await _channel.invokeMethod('syncPushSnapshot', { - 'section': 'communities', - 'communities': [for (final snapshot in snapshots) snapshot.toJson()], - 'signingKeys': signingKeys, - }); + await _channel.invokeMethod( + 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. } } diff --git a/mobile/test/features/age_gate/age_gate_app_test.dart b/mobile/test/features/age_gate/age_gate_app_test.dart new file mode 100644 index 00000000000..728505a512a --- /dev/null +++ b/mobile/test/features/age_gate/age_gate_app_test.dart @@ -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 = []; + final ageSignal = _MutableAgeSignalNotifier(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appBadgeChannel, (call) async { + if (call.method == 'updateBadge') { + badgeCounts.add( + (call.arguments as Map)['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(); + 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 = []; + + 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 build() async { + return const AuthState(status: AuthStatus.authenticated); + } +} + +class _UnauthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async { + return const AuthState(status: AuthStatus.unauthenticated); + } +} + +class _MutableAgeSignalNotifier extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.checking; + + @override + Future request() async {} + + void setState(AgeSignalState next) => state = next; +} + +class _BlockingAgeSignalNotifier extends AgeSignalNotifier { + @override + AgeSignalState build() => AgeSignalState.restricted; + + @override + Future 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> build() async { + builds += 1; + if (builds == 1) throw StateError('secure storage unavailable'); + return const []; + } + + @override + Future enforceAgeRestrictionOnPush() async { + cleanups += 1; + } +} + +class _RetryingCleanupCommunityListNotifier extends CommunityListNotifier { + int cleanups = 0; + + @override + Future> build() async => const []; + + @override + Future enforceAgeRestrictionOnPush() async { + cleanups += 1; + if (cleanups == 1) { + throw StateError('injected restricted cleanup failure'); + } + } +} + +class _SuccessfulCleanupCommunityListNotifier extends CommunityListNotifier { + @override + Future> build() async => const []; + + @override + Future enforceAgeRestrictionOnPush() async {} +} + +class _UnavailableCommunityListNotifier extends CommunityListNotifier { + @override + Future> build() async { + throw StateError('secure storage unavailable'); + } +} diff --git a/mobile/test/features/age_gate/age_restriction_page_test.dart b/mobile/test/features/age_gate/age_restriction_page_test.dart new file mode 100644 index 00000000000..6ac2fa1a4ae --- /dev/null +++ b/mobile/test/features/age_gate/age_restriction_page_test.dart @@ -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); + }); +} diff --git a/mobile/test/features/age_gate/age_signal_provider_test.dart b/mobile/test/features/age_gate/age_signal_provider_test.dart new file mode 100644 index 00000000000..3846e651a10 --- /dev/null +++ b/mobile/test/features/age_gate/age_signal_provider_test.dart @@ -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 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().future; + } + if (call.method == 'cancelAgeSignalRequest') return false; + if (failure == 'malformed') return 'invalid'; + if (failure == 'timeout') return Completer().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().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( + 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( + 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( + requestSignal: () { + requests += 1; + return Completer?>().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?>(); + final provider = NotifierProvider( + () => 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( + requestSignal: () { + requests += 1; + if (requests == 1) { + return Completer?>().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( + requestSignal: () { + requests += 1; + return Completer?>().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( + requestSignal: () => Completer?>().future, + delay: (_) async {}, + cancelSignal: () { + cancellations += 1; + return Completer().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( + requestSignal: () => Completer?>().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( + 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( + 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( + 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?>(); + final provider = NotifierProvider( + () => 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); + }); +} diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index da1746df06c..6a581b24a61 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -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, ), diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 345226e84d1..fb49b64d408 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -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 writeSnapshot(List communities) async { + snapshots.add(List.of(communities)); + } + + Future writeAgeGateSnapshot( + List 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 = >[]; + final ageGateSnapshots = >[]; + final settleFenceValues = []; + 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 = []; + 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(); + final releaseStaleWrite = Completer(); + final completedSnapshots = >[]; + 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(); + final releaseStaleWrite = Completer(); + final completedSnapshots = >[]; + final settleFenceValues = []; + 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(), + [ + [], + [], + [community.id], + [], + ], + ); + 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(); + final releaseWrite = Completer(); + final strictSnapshots = >[]; + 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(() {}); + 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(() {}); + 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(); + final releaseRemoval = Completer(); + final removalPersisted = Completer(); + final loadStarted = Completer(); + final releaseLoad = Completer(); + bool pauseNextLoad = false; + + @override + Future remove(String id) async { + removeStarted.complete(); + await releaseRemoval.future; + await super.remove(id); + removalPersisted.complete(); + } + + @override + Future> loadAll() async { + final pause = pauseNextLoad; + pauseNextLoad = false; + final communities = await super.loadAll(); + if (pause) { + loadStarted.complete(); + await releaseLoad.future; + } + return communities; + } +} diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index 486457e6ac5..93874aef253 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/shared/push/push_subscription.dart'; /// in-memory logic. class FakeSecureStorage extends Fake implements FlutterSecureStorage { final Map _data = {}; + final Map _writeCounts = {}; @override Future 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() { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 4bff3a2c2c3..ce25c93bd24 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -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()), + ); + }); + + 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': [], + 'signingKeys': {}, + '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 diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index a72b34852a6..5b0e357a395 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -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 request() async {} +} + class _FakeAuthNotifier extends AuthNotifier { @override Future build() async {