From eaadb814780e279a43707bc4162df9cf6813ca0b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 2 Sep 2026 20:42:58 +1200 Subject: [PATCH 1/3] Add the Declared Age Range entitlement and remote flag Add the `age_requirement_compliance` remote feature flag (defaults off) and the `com.apple.developer.declared-age-range` entitlement to every WordPress and Jetpack build variant, so the app can query Apple's Declared Age Range API once the feature ships. --- Sources/Reader/Reader.entitlements | 2 ++ .../Tests/Utility/RemoteFeatureFlagTests.swift | 13 +++++++++++-- .../BuildInformation/RemoteFeatureFlag.swift | 7 +++++++ WordPress/Jetpack/JetpackDebug.entitlements | 2 ++ WordPress/Jetpack/JetpackRelease-Alpha.entitlements | 2 ++ WordPress/Jetpack/JetpackRelease.entitlements | 2 ++ WordPress/WordPress-Alpha.entitlements | 2 ++ WordPress/WordPress.entitlements | 2 ++ 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/Reader/Reader.entitlements b/Sources/Reader/Reader.entitlements index 9ee1e3b4f334..68fa83977ff3 100644 --- a/Sources/Reader/Reader.entitlements +++ b/Sources/Reader/Reader.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + com.apple.developer.associated-domains webcredentials:wordpress.com diff --git a/Tests/KeystoneTests/Tests/Utility/RemoteFeatureFlagTests.swift b/Tests/KeystoneTests/Tests/Utility/RemoteFeatureFlagTests.swift index 734c90839b25..7011e7e4931e 100644 --- a/Tests/KeystoneTests/Tests/Utility/RemoteFeatureFlagTests.swift +++ b/Tests/KeystoneTests/Tests/Utility/RemoteFeatureFlagTests.swift @@ -38,6 +38,11 @@ class RemoteFeatureFlagTests: XCTestCase { XCTAssertFalse(store.hasValue(for: MockFeatureFlag.remotelyUndefinedLocallyDisabledFeature.remoteKey)) } + func testAgeRequirementComplianceDefaultsOff() { + XCTAssertFalse(RemoteFeatureFlag.ageRequirementCompliance.defaultValue) + XCTAssertEqual(RemoteFeatureFlag.ageRequirementCompliance.remoteKey, "age_requirement_compliance") + } + func testThatUpdateCachesNewFlags() { let mock = MockFeatureFlagRemote(mockFlags: MockFeatureFlag.remoteCases) let store = RemoteFeatureFlagStore(persistenceStore: mockUserDefaults) @@ -70,7 +75,8 @@ class MockFeatureFlagRemote: FeatureFlagRemote { var deviceIdCallback: ((String) -> Void)? init(mockFlags: [MockFeatureFlag] = []) { - self.flags = mockFlags + self.flags = + mockFlags .compactMap { $0.toFeatureFlag } super.init() } @@ -80,7 +86,10 @@ class MockFeatureFlagRemote: FeatureFlagRemote { super.init() } - public override func getRemoteFeatureFlags(forDeviceId deviceId: String, callback: @escaping FeatureFlagResponseCallback) { + public override func getRemoteFeatureFlags( + forDeviceId deviceId: String, + callback: @escaping FeatureFlagResponseCallback + ) { deviceIdCallback?(deviceId) callback(.success(flags)) } diff --git a/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift index 7618900e2fca..e348c234b1f6 100644 --- a/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift @@ -30,6 +30,7 @@ public enum RemoteFeatureFlag: Int, CaseIterable { case newGutenberg case newGutenbergPlugins case statsAds + case ageRequirementCompliance var defaultValue: Bool { let app = BuildSettings.current.brand @@ -89,6 +90,8 @@ public enum RemoteFeatureFlag: Int, CaseIterable { return false case .statsAds: return false + case .ageRequirementCompliance: + return false } } @@ -149,6 +152,8 @@ public enum RemoteFeatureFlag: Int, CaseIterable { return "gutenberg_kit_plugins" case .statsAds: return "stats_ads" + case .ageRequirementCompliance: + return "age_requirement_compliance" } } @@ -208,6 +213,8 @@ public enum RemoteFeatureFlag: Int, CaseIterable { return "Experimental Block Editor Plugins" case .statsAds: return "Stats Ads Tab" + case .ageRequirementCompliance: + return "Age Requirement Compliance" } } diff --git a/WordPress/Jetpack/JetpackDebug.entitlements b/WordPress/Jetpack/JetpackDebug.entitlements index 4e42a7383c25..c3885a50afb5 100644 --- a/WordPress/Jetpack/JetpackDebug.entitlements +++ b/WordPress/Jetpack/JetpackDebug.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + aps-environment development com.apple.developer.applesignin diff --git a/WordPress/Jetpack/JetpackRelease-Alpha.entitlements b/WordPress/Jetpack/JetpackRelease-Alpha.entitlements index 12bd1bd7bdbb..c9bcd80821c4 100644 --- a/WordPress/Jetpack/JetpackRelease-Alpha.entitlements +++ b/WordPress/Jetpack/JetpackRelease-Alpha.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + aps-environment development com.apple.developer.associated-domains diff --git a/WordPress/Jetpack/JetpackRelease.entitlements b/WordPress/Jetpack/JetpackRelease.entitlements index 4e42a7383c25..c3885a50afb5 100644 --- a/WordPress/Jetpack/JetpackRelease.entitlements +++ b/WordPress/Jetpack/JetpackRelease.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + aps-environment development com.apple.developer.applesignin diff --git a/WordPress/WordPress-Alpha.entitlements b/WordPress/WordPress-Alpha.entitlements index 6df9777ecf46..3aff279e1db2 100644 --- a/WordPress/WordPress-Alpha.entitlements +++ b/WordPress/WordPress-Alpha.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + com.apple.developer.associated-domains webcredentials:wordpress.com diff --git a/WordPress/WordPress.entitlements b/WordPress/WordPress.entitlements index 5a08bd31c83c..61f569c3bba4 100644 --- a/WordPress/WordPress.entitlements +++ b/WordPress/WordPress.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.declared-age-range + aps-environment development com.apple.developer.applesignin From 3cf0b7c60f760413269da0696b28cd88798ed633 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 2 Sep 2026 20:43:12 +1200 Subject: [PATCH 2/3] Add the age requirement check, enforcement, and analytics Run a one-shot age requirement check from the root view transition after the initial UI appears. When the Declared Age Range API reports an age under 13, enforce the restriction: sign out of the WordPress.com account, remove self-hosted sites and their local drafts, present the sign-in UI, and show an explanatory alert. Checks fail open, skip unsupported OS versions, and run at most once. Track every outcome. --- .../AgeRequirementCoordinatorTests.swift | 280 ++++++++++++++++++ .../AgeRequirementEnforcerTests.swift | 36 +++ .../AgeRequirement/AgeRangeService.swift | 61 ++++ .../AgeRequirementAnalyticsTracker.swift | 41 +++ .../AgeRequirementCoordinator.swift | 175 +++++++++++ .../AgeRequirementEnforcer.swift | 59 ++++ .../Classes/System/ReaderWindowManager.swift | 13 +- WordPress/Classes/System/WindowManager.swift | 20 +- 8 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementCoordinatorTests.swift create mode 100644 Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementEnforcerTests.swift create mode 100644 WordPress/Classes/Services/AgeRequirement/AgeRangeService.swift create mode 100644 WordPress/Classes/Services/AgeRequirement/AgeRequirementAnalyticsTracker.swift create mode 100644 WordPress/Classes/Services/AgeRequirement/AgeRequirementCoordinator.swift create mode 100644 WordPress/Classes/Services/AgeRequirement/AgeRequirementEnforcer.swift diff --git a/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementCoordinatorTests.swift b/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementCoordinatorTests.swift new file mode 100644 index 000000000000..a568faa63a99 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementCoordinatorTests.swift @@ -0,0 +1,280 @@ +import Testing +import UIKit + +@testable import WordPress + +@Suite("Age requirement coordinator") +@MainActor +struct AgeRequirementCoordinatorTests { + @Test("A disabled flag records a skipped check") + func flagOff() async { + let harness = Harness(flagEnabled: false) + + await harness.check() + + #expect(harness.service.eligibilityCallCount == 0) + #expect(harness.tracker.events.map(\.outcome) == [.notChecked(.flagOff)]) + #expect(!harness.coordinator.isRestricted) + } + + @Test("An unsupported OS records a skipped check") + func unsupportedOS() async { + let harness = Harness(isSupported: false) + + await harness.check() + + #expect(harness.service.eligibilityCallCount == 0) + #expect(harness.tracker.events.map(\.outcome) == [.notChecked(.osUnsupported)]) + } + + @Test("An ineligible region produces no event") + func ineligibleRegion() async { + let harness = Harness(eligible: false) + + await harness.check() + + #expect(harness.service.requestCallCount == 0) + #expect(harness.tracker.events.isEmpty) + } + + @Test( + "Service failures fail open", + arguments: [ + (AgeRangeServiceError.unavailable, AgeRequirementCheckOutcome.unavailable), + (.unknown, .error) + ] + ) + func serviceFailures(error: AgeRangeServiceError, expected: AgeRequirementCheckOutcome) async { + let eligibility = Harness(eligibilityError: error) + await eligibility.check() + #expect(eligibility.tracker.events.map(\.outcome) == [expected]) + + let request = Harness(requestError: error) + await request.check() + #expect(request.tracker.events.map(\.outcome) == [expected]) + #expect(!request.coordinator.isRestricted) + } + + @Test("An anchor that is not on screen records a skipped check") + func missingAnchor() async { + let harness = Harness() + + await harness.coordinator.checkIfNeeded(anchor: UIViewController()) + + #expect(harness.service.eligibilityCallCount == 1) + #expect(harness.service.requestCallCount == 0) + #expect(harness.tracker.events.map(\.outcome) == [.notChecked(.noAnchor)]) + } + + @Test( + "Non-restricted service results fail open", + arguments: [ + (AgeRangeServiceResult.sharing(lowerBound: 13), AgeRequirementCheckOutcome.allowed), + (.sharing(lowerBound: 18), .allowed), + (.declined, .declined) + ] + ) + func allowedResults(result: AgeRangeServiceResult, expected: AgeRequirementCheckOutcome) async { + let harness = Harness(result: result) + + await harness.check() + + #expect(harness.enforcer.enforcementCount == 0) + #expect(harness.tracker.events.map(\.outcome) == [expected]) + #expect(!harness.coordinator.isRestricted) + } + + @Test( + "An affirmative under-13 result enforces and records the original context", + arguments: [ + AgeRequirementUserState(wpComSignedIn: true, selfHostedSiteCount: 0), + AgeRequirementUserState(wpComSignedIn: false, selfHostedSiteCount: 2), + AgeRequirementUserState(wpComSignedIn: false, selfHostedSiteCount: 0) + ] + ) + func restrictedResult(userState: AgeRequirementUserState) async { + let harness = Harness(result: .sharing(lowerBound: nil), userState: userState) + + await harness.check() + + #expect(harness.enforcer.enforcementCount == 1) + #expect(harness.coordinator.isRestricted) + #expect(harness.tracker.events == [.init(outcome: .restricted, userState: userState)]) + #expect(harness.recorder.values == ["enforce", "track"]) + } + + @Test("A second trigger does not repeat the check") + func checksOnce() async { + let harness = Harness() + + await harness.check() + await harness.check() + + #expect(harness.service.eligibilityCallCount == 1) + #expect(harness.service.requestCallCount == 1) + #expect(harness.tracker.events.count == 1) + } + + @Test("Sign-in is refused only after restriction") + func signInRefusal() async { + let allowed = Harness() + #expect(!allowed.coordinator.refuseSignInIfRestricted(from: UIViewController())) + + let restricted = Harness(result: .sharing(lowerBound: nil)) + await restricted.check() + #expect(restricted.coordinator.refuseSignInIfRestricted(from: UIViewController())) + } + + @Test("Analytics maps the outcome and check-time context") + func analyticsProperties() throws { + AnalyticsEventTrackingSpy.reset() + let tracker = AgeRequirementAnalyticsTracker(tracker: AnalyticsEventTrackingSpy.self) + + tracker.track(.notChecked(.noAnchor), userState: .init(wpComSignedIn: true, selfHostedSiteCount: 2)) + + let event = try #require(AnalyticsEventTrackingSpy.trackedEvents.first) + #expect(event.name == "age_requirement_check") + #expect( + event.properties == [ + "outcome": "not_checked", + "reason": "no_anchor", + "logged_out": "false", + "wpcom_signed_in": "true", + "self_hosted_site_count": "2" + ] + ) + } +} + +@MainActor +private struct Harness { + let recorder = Recorder() + let service: AgeRangeServiceFake + let enforcer: AgeRequirementEnforcerSpy + let tracker: AgeRequirementAnalyticsTrackerSpy + let coordinator: AgeRequirementCoordinator + + /// An on-screen view controller for the age range prompt. + private let window: UIWindow + private let anchor = UIViewController() + + init( + flagEnabled: Bool = true, + isSupported: Bool = true, + eligible: Bool = true, + eligibilityError: AgeRangeServiceError? = nil, + result: AgeRangeServiceResult = .sharing(lowerBound: 13), + requestError: AgeRangeServiceError? = nil, + userState: AgeRequirementUserState = .init(wpComSignedIn: true, selfHostedSiteCount: 2) + ) { + let service = AgeRangeServiceFake( + eligible: eligible, + eligibilityError: eligibilityError, + result: result, + requestError: requestError + ) + let enforcer = AgeRequirementEnforcerSpy(userState: userState, recorder: recorder) + let tracker = AgeRequirementAnalyticsTrackerSpy(recorder: recorder) + self.service = service + self.enforcer = enforcer + self.tracker = tracker + self.coordinator = AgeRequirementCoordinator( + service: service, + enforcer: enforcer, + analyticsTracker: tracker, + isFeatureEnabled: { flagEnabled }, + isSupported: isSupported + ) + + window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = anchor + window.isHidden = false + } + + func check() async { + await coordinator.checkIfNeeded(anchor: anchor) + } +} + +@MainActor +private final class AgeRangeServiceFake: AgeRangeServiceProtocol { + let eligible: Bool + let eligibilityError: AgeRangeServiceError? + let result: AgeRangeServiceResult + let requestError: AgeRangeServiceError? + private(set) var eligibilityCallCount = 0 + private(set) var requestCallCount = 0 + + init( + eligible: Bool, + eligibilityError: AgeRangeServiceError?, + result: AgeRangeServiceResult, + requestError: AgeRangeServiceError? + ) { + self.eligible = eligible + self.eligibilityError = eligibilityError + self.result = result + self.requestError = requestError + } + + func isEligible() async throws(AgeRangeServiceError) -> Bool { + eligibilityCallCount += 1 + if let eligibilityError { + throw eligibilityError + } + return eligible + } + + func requestAgeRange( + in viewController: UIViewController + ) async throws(AgeRangeServiceError) -> AgeRangeServiceResult { + requestCallCount += 1 + if let requestError { + throw requestError + } + return result + } +} + +@MainActor +private final class AgeRequirementEnforcerSpy: AgeRequirementEnforcing { + let userState: AgeRequirementUserState + private let recorder: Recorder + private(set) var enforcementCount = 0 + + init(userState: AgeRequirementUserState, recorder: Recorder) { + self.userState = userState + self.recorder = recorder + } + + func enforceRestriction(userState: AgeRequirementUserState) async -> UIViewController? { + enforcementCount += 1 + recorder.values.append("enforce") + return nil + } +} + +@MainActor +private final class AgeRequirementAnalyticsTrackerSpy: AgeRequirementAnalyticsTracking { + struct Event: Equatable { + let outcome: AgeRequirementCheckOutcome + let userState: AgeRequirementUserState + } + + private let recorder: Recorder + private(set) var events: [Event] = [] + + init(recorder: Recorder) { + self.recorder = recorder + } + + func track(_ outcome: AgeRequirementCheckOutcome, userState: AgeRequirementUserState) { + events.append(.init(outcome: outcome, userState: userState)) + recorder.values.append("track") + } +} + +@MainActor +private final class Recorder { + var values: [String] = [] +} diff --git a/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementEnforcerTests.swift b/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementEnforcerTests.swift new file mode 100644 index 000000000000..8bc229b010c8 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Features/Misc/AgeRequirement/AgeRequirementEnforcerTests.swift @@ -0,0 +1,36 @@ +import CoreData +import Testing +import WordPressData + +@testable import WordPress + +@Suite("Age requirement enforcer") +@MainActor +struct AgeRequirementEnforcerTests { + @Test("Removes only sites without an account and their local drafts") + func removeSelfHostedSites() async throws { + let contextManager = ContextManager.forTesting() + let context = contextManager.mainContext + let wpComBlog = BlogBuilder(context).isHostedAtWPcom().withAnAccount().build() + let jetpackBlog = BlogBuilder(context).isNotHostedAtWPcom().withAnAccount().build() + let firstSelfHostedBlog = BlogBuilder(context).isNotHostedAtWPcom().build() + let secondSelfHostedBlog = BlogBuilder(context).isNotHostedAtWPcom().build() + let localDraft = PostBuilder(context, blog: secondSelfHostedBlog).drafted().build() + contextManager.saveContextAndWait(context) + + let enforcer = AgeRequirementEnforcer(coreDataStack: contextManager, windowManager: { nil }) + #expect(enforcer.userState.selfHostedSiteCount == 2) + + enforcer.removeSelfHostedSites() + + let remaining = try await contextManager.performQuery { context in + let blogs = try context.fetch(NSFetchRequest(entityName: Blog.entityName())) + let posts = try context.fetch(NSFetchRequest(entityName: Post.entityName())) + return (Set(blogs.map(\.objectID)), Set(posts.map(\.objectID))) + } + #expect(remaining.0 == Set([wpComBlog.objectID, jetpackBlog.objectID])) + #expect(!remaining.0.contains(firstSelfHostedBlog.objectID)) + #expect(!remaining.0.contains(secondSelfHostedBlog.objectID)) + #expect(!remaining.1.contains(localDraft.objectID)) + } +} diff --git a/WordPress/Classes/Services/AgeRequirement/AgeRangeService.swift b/WordPress/Classes/Services/AgeRequirement/AgeRangeService.swift new file mode 100644 index 000000000000..e90735d914db --- /dev/null +++ b/WordPress/Classes/Services/AgeRequirement/AgeRangeService.swift @@ -0,0 +1,61 @@ +import DeclaredAgeRange +import UIKit + +enum AgeRangeServiceResult: Equatable { + case sharing(lowerBound: Int?) + case declined +} + +enum AgeRangeServiceError: Error { + case unavailable + case unknown +} + +@MainActor +protocol AgeRangeServiceProtocol { + func isEligible() async throws(AgeRangeServiceError) -> Bool + func requestAgeRange( + in viewController: UIViewController + ) async throws(AgeRangeServiceError) -> AgeRangeServiceResult +} + +struct AppleAgeRangeService: AgeRangeServiceProtocol { + func isEligible() async throws(AgeRangeServiceError) -> Bool { + guard #available(iOS 26.2, *) else { + throw .unavailable + } + + do { + if #available(iOS 26.4, *) { + return try await AgeRangeService.shared.requiredRegulatoryFeatures.contains(.declaredAgeRangeRequired) + } + return try await AgeRangeService.shared.isEligibleForAgeFeatures + } catch { + throw error is AgeRangeService.Error ? .unavailable : .unknown + } + } + + func requestAgeRange( + in viewController: UIViewController + ) async throws(AgeRangeServiceError) -> AgeRangeServiceResult { + guard #available(iOS 26.2, *) else { + throw .unavailable + } + + let response: AgeRangeService.Response + do { + response = try await AgeRangeService.shared.requestAgeRange(ageGates: 13, in: viewController) + } catch { + throw error is AgeRangeService.Error ? .unavailable : .unknown + } + + switch response { + case .declinedSharing: + return .declined + case .sharing(let range): + return .sharing(lowerBound: range.lowerBound) + @unknown default: + throw .unknown + } + } +} diff --git a/WordPress/Classes/Services/AgeRequirement/AgeRequirementAnalyticsTracker.swift b/WordPress/Classes/Services/AgeRequirement/AgeRequirementAnalyticsTracker.swift new file mode 100644 index 000000000000..649ac08ac6fc --- /dev/null +++ b/WordPress/Classes/Services/AgeRequirement/AgeRequirementAnalyticsTracker.swift @@ -0,0 +1,41 @@ +import WordPressShared + +@MainActor +protocol AgeRequirementAnalyticsTracking { + func track(_ outcome: AgeRequirementCheckOutcome, userState: AgeRequirementUserState) +} + +@MainActor +struct AgeRequirementAnalyticsTracker: AgeRequirementAnalyticsTracking { + private let tracker: AnalyticsEventTracking.Type + + init(tracker: AnalyticsEventTracking.Type = WPAnalytics.self) { + self.tracker = tracker + } + + func track(_ outcome: AgeRequirementCheckOutcome, userState: AgeRequirementUserState) { + var properties: [String: String] = [ + "outcome": outcome.analyticsValue, + "logged_out": String(outcome == .restricted), + "wpcom_signed_in": String(userState.wpComSignedIn), + "self_hosted_site_count": String(userState.selfHostedSiteCount) + ] + if case .notChecked(let reason) = outcome { + properties["reason"] = reason.rawValue + } + tracker.track(AnalyticsEvent(name: "age_requirement_check", properties: properties)) + } +} + +private extension AgeRequirementCheckOutcome { + var analyticsValue: String { + switch self { + case .allowed: "allowed" + case .restricted: "restricted" + case .declined: "declined" + case .unavailable: "unavailable" + case .error: "error" + case .notChecked: "not_checked" + } + } +} diff --git a/WordPress/Classes/Services/AgeRequirement/AgeRequirementCoordinator.swift b/WordPress/Classes/Services/AgeRequirement/AgeRequirementCoordinator.swift new file mode 100644 index 000000000000..0e91967ee261 --- /dev/null +++ b/WordPress/Classes/Services/AgeRequirement/AgeRequirementCoordinator.swift @@ -0,0 +1,175 @@ +import UIKit +import WordPressShared +import WordPressUI + +enum AgeRequirementNotCheckedReason: String, Equatable { + case flagOff = "flag_off" + case osUnsupported = "os_unsupported" + case noAnchor = "no_anchor" +} + +enum AgeRequirementCheckOutcome: Equatable { + case allowed + case restricted + case declined + case unavailable + case error + case notChecked(AgeRequirementNotCheckedReason) +} + +struct AgeRequirementUserState: Equatable { + let wpComSignedIn: Bool + let selfHostedSiteCount: Int +} + +@MainActor +protocol AgeRequirementEnforcing: AnyObject { + var userState: AgeRequirementUserState { get } + /// Signs out and removes local sites. Returns the view controller to present the restriction alert from. + func enforceRestriction(userState: AgeRequirementUserState) async -> UIViewController? +} + +@MainActor +protocol AgeRequirementAccessControlling: AnyObject { + /// Returns `true` and presents the restriction alert from `viewController` when the process is age restricted. + @discardableResult func refuseSignInIfRestricted(from viewController: UIViewController) -> Bool +} + +@MainActor +final class AgeRequirementCoordinator: AgeRequirementAccessControlling { + static let shared = AgeRequirementCoordinator() + + private let service: AgeRangeServiceProtocol + private let enforcer: AgeRequirementEnforcing + private let analyticsTracker: AgeRequirementAnalyticsTracking + private let isFeatureEnabled: () -> Bool + private let isSupported: Bool + + private var didCheck = false + private(set) var isRestricted = false + + convenience init() { + self.init( + service: AppleAgeRangeService(), + enforcer: AgeRequirementEnforcer(), + analyticsTracker: AgeRequirementAnalyticsTracker(), + isFeatureEnabled: { RemoteFeatureFlag.ageRequirementCompliance.enabled() }, + isSupported: AgeRequirementCoordinator.isSupportedOS + ) + } + + init( + service: AgeRangeServiceProtocol, + enforcer: AgeRequirementEnforcing, + analyticsTracker: AgeRequirementAnalyticsTracking, + isFeatureEnabled: @escaping () -> Bool, + isSupported: Bool + ) { + self.service = service + self.enforcer = enforcer + self.analyticsTracker = analyticsTracker + self.isFeatureEnabled = isFeatureEnabled + self.isSupported = isSupported + } + + func checkIfNeeded(anchor: UIViewController?) async { + guard !didCheck else { + return + } + didCheck = true + + let userState = enforcer.userState + + guard isFeatureEnabled() else { + track(.notChecked(.flagOff), userState: userState) + return + } + guard isSupported else { + track(.notChecked(.osUnsupported), userState: userState) + return + } + + do { + guard try await service.isEligible() else { + return + } + guard let anchor, anchor.viewIfLoaded?.window != nil else { + track(.notChecked(.noAnchor), userState: userState) + return + } + + switch try await service.requestAgeRange(in: anchor) { + case .sharing(let lowerBound) where (lowerBound ?? 0) >= 13: + track(.allowed, userState: userState) + case .sharing: + isRestricted = true + let presenter = await enforcer.enforceRestriction(userState: userState) + let hadAccountOrSites = userState.wpComSignedIn || userState.selfHostedSiteCount > 0 + presentRestrictionAlert( + from: presenter, + message: hadAccountOrSites ? Strings.signedOutMessage : Strings.signedOutUserMessage + ) + track(.restricted, userState: userState) + case .declined: + track(.declined, userState: userState) + } + } catch .unavailable { + track(.unavailable, userState: userState) + } catch { + track(.error, userState: userState) + } + } + + @discardableResult + func refuseSignInIfRestricted(from viewController: UIViewController) -> Bool { + guard isRestricted else { + return false + } + presentRestrictionAlert(from: viewController, message: Strings.signedOutUserMessage) + return true + } + + private func track(_ outcome: AgeRequirementCheckOutcome, userState: AgeRequirementUserState) { + analyticsTracker.track(outcome, userState: userState) + } + + private func presentRestrictionAlert(from viewController: UIViewController?, message: String) { + guard let presenter = viewController?.topmostPresentedViewController else { + return + } + if let alert = presenter as? UIAlertController, alert.title == Strings.title { + return + } + + let alert = UIAlertController(title: Strings.title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: SharedStrings.Button.ok, style: .default)) + presenter.present(alert, animated: true) + } + + private static var isSupportedOS: Bool { + if #available(iOS 26.2, *) { + return true + } + return false + } +} + +private enum Strings { + static let title = NSLocalizedString( + "ageRequirement.restrictionAlert.title", + value: "Age requirement not met", + comment: "Title of an alert explaining that the person does not meet the app's minimum age requirement" + ) + static let signedOutMessage = NSLocalizedString( + "ageRequirement.restrictionAlert.signedOutMessage", + value: + "This app requires users to be 13 or older. Your Apple Account reports an age under 13, so you've been signed out and your sites have been removed from this device.", + comment: "Message shown after age restriction enforcement signs a person out and removes their sites" + ) + static let signedOutUserMessage = NSLocalizedString( + "ageRequirement.restrictionAlert.signedOutUserMessage", + value: + "This app requires users to be 13 or older. Your Apple Account reports an age under 13, so you can't sign in.", + comment: "Message shown when an age-restricted person tries to sign in" + ) +} diff --git a/WordPress/Classes/Services/AgeRequirement/AgeRequirementEnforcer.swift b/WordPress/Classes/Services/AgeRequirement/AgeRequirementEnforcer.swift new file mode 100644 index 000000000000..ba7cd74f9386 --- /dev/null +++ b/WordPress/Classes/Services/AgeRequirement/AgeRequirementEnforcer.swift @@ -0,0 +1,59 @@ +import UIKit +import WordPressData + +@MainActor +final class AgeRequirementEnforcer: AgeRequirementEnforcing { + private let coreDataStack: CoreDataStackSwift + private let windowManager: () -> WindowManager? + + init( + coreDataStack: CoreDataStackSwift = ContextManager.shared, + windowManager: @escaping () -> WindowManager? = { WordPressAppDelegate.shared?.windowManager } + ) { + self.coreDataStack = coreDataStack + self.windowManager = windowManager + } + + var userState: AgeRequirementUserState { + let context = coreDataStack.mainContext + return AgeRequirementUserState( + wpComSignedIn: (try? WPAccount.lookupDefaultWordPressComAccount(in: context)) != nil, + selfHostedSiteCount: BlogQuery().hostedByWPCom(false).count(in: context) + ) + } + + func enforceRestriction(userState: AgeRequirementUserState) async -> UIViewController? { + guard let windowManager = windowManager() else { + clearAccountAndSites(userState: userState) + return nil + } + + // Start the sign-in transition before signing out. Logging out posts the account-changed notification, whose + // handler shows the sign-in UI only when it is not already showing, so this order avoids a second root + // transition. The new root is installed synchronously, so the data work runs during the transition animation. + await withCheckedContinuation { continuation in + windowManager.showSignInUI { + continuation.resume() + } + clearAccountAndSites(userState: userState) + } + + return windowManager.topmostPresentedViewController + } + + private func clearAccountAndSites(userState: AgeRequirementUserState) { + removeSelfHostedSites() + if userState.wpComSignedIn { + AccountHelper.logOutDefaultWordPressComAccount() + } else { + AccountHelper.deleteAccountData() + } + } + + /// Removes sites without an account. Sites attached to the WordPress.com account (including Jetpack-connected + /// ones) are removed by signing out. + func removeSelfHostedSites() { + let blogService = BlogService(coreDataStack: coreDataStack) + Blog.selfHosted(in: coreDataStack.mainContext).forEach(blogService.remove(_:)) + } +} diff --git a/WordPress/Classes/System/ReaderWindowManager.swift b/WordPress/Classes/System/ReaderWindowManager.swift index 23576c63e663..2f72b6cf0307 100644 --- a/WordPress/Classes/System/ReaderWindowManager.swift +++ b/WordPress/Classes/System/ReaderWindowManager.swift @@ -18,10 +18,12 @@ class ReaderWindowManager: WindowManager { } override func showSignInUI(completion: Completion? = nil) { - let welcomeVC = UIHostingController(rootView: ReaderWelcomeView { [weak self] in - self?.continueWithDotComTapped() - }) - show(welcomeVC) + let welcomeVC = UIHostingController( + rootView: ReaderWelcomeView { [weak self] in + self?.continueWithDotComTapped() + } + ) + show(welcomeVC, completion: completion) } private func continueWithDotComTapped() { @@ -29,7 +31,8 @@ class ReaderWindowManager: WindowManager { return wpAssertionFailure("missing top view controller") } Task { @MainActor [weak self] in - let accountID = await WordPressDotComAuthenticator().signIn(from: presentingViewController, context: .default) + let accountID = await WordPressDotComAuthenticator() + .signIn(from: presentingViewController, context: .default) if accountID != nil { self?.showAppUI() } diff --git a/WordPress/Classes/System/WindowManager.swift b/WordPress/Classes/System/WindowManager.swift index aa1b80c39645..dfbd0f88d29d 100644 --- a/WordPress/Classes/System/WindowManager.swift +++ b/WordPress/Classes/System/WindowManager.swift @@ -27,6 +27,10 @@ class WindowManager: NSObject { window.rootViewController } + var topmostPresentedViewController: UIViewController? { + window.topmostPresentedViewController + } + init(window: UIWindow) { self.window = window } @@ -85,12 +89,19 @@ class WindowManager: NSObject { /// Shows the specified VC as the root VC for the managed window. Takes care of animating the transition whenever the existing /// root VC isn't `nil` (this is because a `nil` VC means we're showing the initial VC on a call to this method). + /// Once the new root is on screen, runs the one-shot age requirement check after `completion`. /// func show(_ viewController: UIViewController, animated: Bool = true, completion: Completion? = nil) { + let completion = { [weak self] in + completion?() + self?.runAgeRequirementCheckIfNeeded() + } + // When the App is launched, the root VC will be `nil`. // When this is the case we'll simply show the VC without any type of animation. guard window.rootViewController != nil, animated else { window.rootViewController = viewController + completion() return } @@ -102,11 +113,18 @@ class WindowManager: NSObject { options: .transitionCrossDissolve, animations: nil, completion: { _ in - completion?() + completion() } ) } + private func runAgeRequirementCheckIfNeeded() { + let anchor = topmostPresentedViewController + Task { @MainActor in + await AgeRequirementCoordinator.shared.checkIfNeeded(anchor: anchor) + } + } + // MARK: Temporary Overlaying Window /// Creates a window with the passed root view and displays it on top of the app's UI. From d4579fccba3105f44f4d72c11d3f54779fb22aa7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 2 Sep 2026 20:43:27 +1200 Subject: [PATCH 3/3] Refuse sign-in while age restricted Gate both the WordPress.com and self-hosted sign-in paths on the age restriction. Refuse at entry, and re-check right before persisting the account, since the restriction can arrive while a sign-in flow is in progress. A refused sign-in shows the restriction alert and returns without persisting or posting the sign-in completion notification. --- .../AgeRequirementAccessControllerFake.swift | 19 +++ ...SiteAuthenticatorAgeRequirementTests.swift | 27 ++++ .../WordPressDotComAuthenticatorTests.swift | 142 +++++++++++++++--- .../Classes/Login/LoginWithUrlView.swift | 2 + .../Login/SelfHostedSiteAuthenticator.swift | 20 ++- .../Login/WordPressDotComAuthenticator.swift | 19 +++ 6 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 Tests/KeystoneTests/Helpers/AgeRequirementAccessControllerFake.swift create mode 100644 Tests/KeystoneTests/Tests/Login/SelfHostedSiteAuthenticatorAgeRequirementTests.swift diff --git a/Tests/KeystoneTests/Helpers/AgeRequirementAccessControllerFake.swift b/Tests/KeystoneTests/Helpers/AgeRequirementAccessControllerFake.swift new file mode 100644 index 000000000000..a05c4aa30704 --- /dev/null +++ b/Tests/KeystoneTests/Helpers/AgeRequirementAccessControllerFake.swift @@ -0,0 +1,19 @@ +import UIKit + +@testable import WordPress + +/// Answers `refuseSignInIfRestricted(from:)` from a queue of scripted responses, then `false`. +@MainActor +final class AgeRequirementAccessControllerFake: AgeRequirementAccessControlling { + private var refusalResponses: [Bool] + private(set) var refusalCount = 0 + + init(refusalResponses: [Bool]) { + self.refusalResponses = refusalResponses + } + + func refuseSignInIfRestricted(from viewController: UIViewController) -> Bool { + refusalCount += 1 + return refusalResponses.isEmpty ? false : refusalResponses.removeFirst() + } +} diff --git a/Tests/KeystoneTests/Tests/Login/SelfHostedSiteAuthenticatorAgeRequirementTests.swift b/Tests/KeystoneTests/Tests/Login/SelfHostedSiteAuthenticatorAgeRequirementTests.swift new file mode 100644 index 000000000000..6c2b5a7cb9e9 --- /dev/null +++ b/Tests/KeystoneTests/Tests/Login/SelfHostedSiteAuthenticatorAgeRequirementTests.swift @@ -0,0 +1,27 @@ +import Testing +import UIKit + +@testable import WordPress + +@Suite("Self-hosted sign-in age requirement") +@MainActor +struct SelfHostedSiteAuthenticatorAgeRequirementTests { + @Test("A restricted sign-in stops before site discovery") + func refusesAtEntry() async { + let accessController = AgeRequirementAccessControllerFake(refusalResponses: [true]) + let authenticator = SelfHostedSiteAuthenticator(ageRequirementController: accessController) + + do { + _ = try await authenticator.signIn( + site: "https://example.com", + from: UIViewController(), + context: .default + ) + Issue.record("Expected age-restricted sign-in to stop") + } catch SelfHostedSiteAuthenticator.SignInError.ageRestricted { + #expect(accessController.refusalCount == 1) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} diff --git a/Tests/KeystoneTests/Tests/Login/WordPressDotComAuthenticatorTests.swift b/Tests/KeystoneTests/Tests/Login/WordPressDotComAuthenticatorTests.swift index b669c109927b..e3f015f2ff97 100644 --- a/Tests/KeystoneTests/Tests/Login/WordPressDotComAuthenticatorTests.swift +++ b/Tests/KeystoneTests/Tests/Login/WordPressDotComAuthenticatorTests.swift @@ -17,16 +17,25 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { func testAuthenticateSuccess() async { stubTokenExchange() - let authenticator = WordPressDotComAuthenticator(authenticator: fakeAuthenticator(callback: ["code": "random"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + authenticator: fakeAuthenticator(callback: ["code": "random"]), + redirectURIScheme: "testapp" + ) do { - let _ = try await authenticator.authenticate(from: UIViewController(), prefersEphemeralWebBrowserSession: false) + let _ = try await authenticator.authenticate( + from: UIViewController(), + prefersEphemeralWebBrowserSession: false + ) } catch { XCTFail("Unexpected error: \(error)") } } func testAuthenticateWithInvalidCallbackURL() async { - let authenticator = WordPressDotComAuthenticator(authenticator: fakeAuthenticator(callback: ["empty": "yes"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + authenticator: fakeAuthenticator(callback: ["empty": "yes"]), + redirectURIScheme: "testapp" + ) do { let _ = try await authenticator.authenticate(from: .init(), prefersEphemeralWebBrowserSession: false) XCTFail("Unexpected successful result") @@ -38,9 +47,16 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { } func testAuthenticateWithAccessDenied() async { - let authenticator = WordPressDotComAuthenticator(authenticator: fakeAuthenticator(callback: ["error": "access_denied"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + authenticator: fakeAuthenticator(callback: ["error": "access_denied"]), + redirectURIScheme: "testapp" + ) do { - let _ = try await authenticator.authenticate(from: .init(), prefersEphemeralWebBrowserSession: false, recoverDenyAccess: false) + let _ = try await authenticator.authenticate( + from: .init(), + prefersEphemeralWebBrowserSession: false, + recoverDenyAccess: false + ) XCTFail("Unexpected successful result") } catch .loginDenied { // Do nothing @@ -59,7 +75,11 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { try XCTAssertNil(WPAccount.lookupDefaultWordPressComAccount(in: mainContext)) // When signing in with a WP.com account - let authenticator = WordPressDotComAuthenticator(coreDataStack: contextManager, authenticator: fakeAuthenticator(callback: ["code": "random"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + redirectURIScheme: "testapp" + ) let accountID = await authenticator.signIn(from: UIViewController(), context: .default) XCTAssertNotNil(accountID) @@ -85,7 +105,10 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { // - the app posts a notification. Task.detached { @MainActor in try await Task.sleep(for: .milliseconds(100)) - let handled = WordPressDotComAuthenticator.handleAppOpeningURL(URL(string: "testapp://oauth2-callback?code=random")!, appURLScheme: "testapp") + let handled = WordPressDotComAuthenticator.handleAppOpeningURL( + URL(string: "testapp://oauth2-callback?code=random")!, + appURLScheme: "testapp" + ) XCTAssertTrue(handled) } @@ -104,14 +127,22 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { stubGetSites() // Given the app is already signed in with a WP.com account. - let account = AccountBuilder(mainContext).with(email: "test@example.com").with(username: "default_account").with(authToken: "token").build() + let account = AccountBuilder(mainContext).with(email: "test@example.com").with(username: "default_account") + .with(authToken: "token").build() try mainContext.save() AccountService(coreDataStack: contextManager).setDefaultWordPressComAccount(account) // When signing in with another WP.com account. - let authenticator = WordPressDotComAuthenticator(coreDataStack: contextManager, authenticator: fakeAuthenticator(callback: ["code": "random"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + redirectURIScheme: "testapp" + ) do { - let _ = try await authenticator.attemptSignIn(from: UIViewController(), context: .jetpackSite(accountEmail: nil)) + let _ = try await authenticator.attemptSignIn( + from: UIViewController(), + context: .jetpackSite(accountEmail: nil) + ) XCTFail("Unexpected successful result") } catch .alreadySignedIn { // Do nothing @@ -125,7 +156,11 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { stubGetAccountDetails() stubGetSitesError() - let authenticator = WordPressDotComAuthenticator(coreDataStack: contextManager, authenticator: fakeAuthenticator(callback: ["code": "random"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + redirectURIScheme: "testapp" + ) do { let _ = try await authenticator.attemptSignIn(from: UIViewController(), context: .default) XCTFail("Unexpected successful result") @@ -138,7 +173,8 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { func testReAuthenticationWithUnexpectedAccount() async throws { // Given the app is already signed in with a WP.com account. - let account = AccountBuilder(mainContext).with(email: "test@example.com").with(username: "default_account").with(authToken: "token").build() + let account = AccountBuilder(mainContext).with(email: "test@example.com").with(username: "default_account") + .with(authToken: "token").build() try mainContext.save() AccountService(coreDataStack: contextManager).setDefaultWordPressComAccount(account) @@ -147,9 +183,16 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { stubGetAccountDetails() // Then `mismatchedEmail` error should be returned - let authenticator = WordPressDotComAuthenticator(coreDataStack: contextManager, authenticator: fakeAuthenticator(callback: ["code": "random"]), redirectURIScheme: "testapp") + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + redirectURIScheme: "testapp" + ) do { - let _ = try await authenticator.attemptSignIn(from: UIViewController(), context: .reauthentication(accountEmail: account.email)) + let _ = try await authenticator.attemptSignIn( + from: UIViewController(), + context: .reauthentication(accountEmail: account.email) + ) XCTFail("Unexpected successful result") } catch let .mismatchedEmail(expectedEmail) { XCTAssertEqual(expectedEmail, "test@example.com") @@ -157,10 +200,55 @@ class WordPressDotComAuthenticatorTests: CoreDataTestCase { XCTFail("Unexpected error: \(error)") } } + + @MainActor + func testAgeRestrictedSignInStopsBeforeAuthentication() async throws { + let accessController = AgeRequirementAccessControllerFake(refusalResponses: [true]) + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + ageRequirementController: accessController, + redirectURIScheme: "testapp" + ) + + let accountID = await authenticator.signIn(from: UIViewController(), context: .default) + + XCTAssertNil(accountID) + XCTAssertEqual(accessController.refusalCount, 1) + try XCTAssertNil(WPAccount.lookupDefaultWordPressComAccount(in: mainContext)) + } + + @MainActor + func testRestrictionDuringSignInPreventsPersistenceAndCompletionNotification() async throws { + stubTokenExchange() + stubGetAccountDetails() + stubGetSites() + let accessController = AgeRequirementAccessControllerFake(refusalResponses: [false, true]) + let authenticator = WordPressDotComAuthenticator( + coreDataStack: contextManager, + authenticator: fakeAuthenticator(callback: ["code": "random"]), + ageRequirementController: accessController, + redirectURIScheme: "testapp" + ) + let completion = expectation( + forNotification: .init(WordPressAuthenticationManager.WPSigninDidFinishNotification), + object: nil + ) + completion.isInverted = true + + let accountID = await authenticator.signIn(from: UIViewController(), context: .default) + + XCTAssertNil(accountID) + XCTAssertEqual(accessController.refusalCount, 2) + try XCTAssertNil(WPAccount.lookupDefaultWordPressComAccount(in: mainContext)) + await fulfillment(of: [completion], timeout: 0.1) + } } private extension WordPressDotComAuthenticatorTests { - func fakeAuthenticator(callback: [String: String]) -> (URL) throws(WordPressDotComAuthenticator.AuthenticationError) -> URL { + func fakeAuthenticator( + callback: [String: String] + ) -> (URL) throws(WordPressDotComAuthenticator.AuthenticationError) -> URL { var url = URL(string: "x-wordpress-app://oauth2-callback")! url.append(queryItems: callback.map { URLQueryItem(name: $0, value: $1) }) @@ -173,7 +261,11 @@ private extension WordPressDotComAuthenticatorTests { func stubTokenExchange() { stub(condition: isPath("/oauth2/token")) { _ in - HTTPStubsResponse(data: #"{"access_token": "token"}"#.data(using: .utf8)!, statusCode: 200, headers: ["Content-Type": "application/json"]) + HTTPStubsResponse( + data: #"{"access_token": "token"}"#.data(using: .utf8)!, + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) } } @@ -215,21 +307,33 @@ private extension WordPressDotComAuthenticatorTests { "is_new_reader": false } """# - return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 200, headers: ["Content-Type": "application/json"]) + return HTTPStubsResponse( + data: json.data(using: .utf8)!, + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) } } func stubGetSites() { stub(condition: isPath("/rest/v1.2/me/sites")) { _ in let json = #"{"sites":[]}"# - return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 200, headers: ["Content-Type": "application/json"]) + return HTTPStubsResponse( + data: json.data(using: .utf8)!, + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) } } func stubGetSitesError() { stub(condition: isPath("/rest/v1.2/me/sites")) { _ in let json = #"{"error_code":"internal_server_error"}"# - return HTTPStubsResponse(data: json.data(using: .utf8)!, statusCode: 500, headers: ["Content-Type": "application/json"]) + return HTTPStubsResponse( + data: json.data(using: .utf8)!, + statusCode: 500, + headers: ["Content-Type": "application/json"] + ) } } } diff --git a/WordPress/Classes/Login/LoginWithUrlView.swift b/WordPress/Classes/Login/LoginWithUrlView.swift index 2b6623f3ff4a..8c2b96843844 100644 --- a/WordPress/Classes/Login/LoginWithUrlView.swift +++ b/WordPress/Classes/Login/LoginWithUrlView.swift @@ -140,6 +140,8 @@ struct LoginWithUrlView: View { .signIn(details: details, from: presenter, context: .default) dismiss() self.loginCompleted(blog) + } catch SelfHostedSiteAuthenticator.SignInError.ageRestricted { + return } catch { errorMessage = error.localizedDescription } diff --git a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift index 2fbc6010f3b6..fbc6a3bce122 100644 --- a/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift +++ b/WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift @@ -73,6 +73,7 @@ struct SelfHostedSiteAuthenticator { case savingSiteFailure case mismatchedUser(expectedUsername: String) case cancelled + case ageRestricted var errorDescription: String? { switch self { @@ -111,6 +112,8 @@ struct SelfHostedSiteAuthenticator { value: "Login has been cancelled", comment: "Error message when user cancels login" ) + case .ageRestricted: + return nil case let .xmlrpcDisabled(underlying): if let reason = underlying as? WordPressOrgXMLRPCValidatorError { return reason.localizedDescription @@ -128,10 +131,12 @@ struct SelfHostedSiteAuthenticator { } private let internalClient: WordPressLoginClient + private let ageRequirementController: AgeRequirementAccessControlling? - init() { + init(ageRequirementController: AgeRequirementAccessControlling? = nil) { let session = URLSession(configuration: .ephemeral) self.internalClient = WordPressLoginClient(urlSession: session) + self.ageRequirementController = ageRequirementController } private func trackSuccess(url: String) { @@ -163,6 +168,7 @@ struct SelfHostedSiteAuthenticator { from viewController: UIViewController, context: SignInContext ) async throws(SignInError) -> TaggedManagedObjectID { + try refuseIfAgeRestricted(from: viewController) let details: AutoDiscoveryAttemptSuccess do { details = try await internalClient.details(ofSite: site) @@ -186,6 +192,7 @@ struct SelfHostedSiteAuthenticator { from viewController: UIViewController, context: SignInContext ) async throws(SignInError) -> TaggedManagedObjectID { + try refuseIfAgeRestricted(from: viewController) do { let credentials: WpApiApplicationPasswordDetails if let parsed = parseCredentialsFromLaunchArguments(), @@ -196,6 +203,9 @@ struct SelfHostedSiteAuthenticator { credentials = try await authenticate(details: details, from: viewController) } + // Re-check after authentication: the restriction can arrive while the sign-in flow is in progress. + try refuseIfAgeRestricted(from: viewController) + let apiRootURL = details.apiRootUrl.asURL() let result = try await handle( credentials: credentials, @@ -478,6 +488,14 @@ struct SelfHostedSiteAuthenticator { return blog } + @MainActor + private func refuseIfAgeRestricted(from viewController: UIViewController) throws(SignInError) { + let controller = ageRequirementController ?? AgeRequirementCoordinator.shared + guard !controller.refuseSignInIfRestricted(from: viewController) else { + throw .ageRestricted + } + } + private func fetchJetpackSite(apiRootURL: URL, credentials: WpApiApplicationPasswordDetails) async -> RemoteBlog? { // This endpoint proxies to WP.com public api `site/` endpoint. When the site is connected to WP.com, // we can use this endpoint to get a full response of `RemoteBlog`, including the "options". diff --git a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift index fba48eead683..82026c54e35c 100644 --- a/WordPress/Classes/Login/WordPressDotComAuthenticator.swift +++ b/WordPress/Classes/Login/WordPressDotComAuthenticator.swift @@ -51,6 +51,7 @@ struct WordPressDotComAuthenticator { case mismatchedEmail(expectedEmail: String) case alreadySignedIn(signedInAccountEmail: String) case loadingSites(Error) + case ageRestricted } private static let callbackNotification = Foundation.Notification.Name( @@ -74,14 +75,21 @@ struct WordPressDotComAuthenticator { private let coreDataStack: CoreDataStackSwift private let showProgressHUD: Bool private let authenticator: ((URL) throws(AuthenticationError) -> URL)? + private let ageRequirementController: AgeRequirementAccessControlling? private let clientId: String private let clientSecret: String + @MainActor + private var ageRequirement: AgeRequirementAccessControlling { + ageRequirementController ?? AgeRequirementCoordinator.shared + } + init( coreDataStack: CoreDataStackSwift = ContextManager.shared, showProgressHUD: Bool = true, authenticator: ((URL) throws(AuthenticationError) -> URL)? = nil, + ageRequirementController: AgeRequirementAccessControlling? = nil, redirectURIScheme: String = BuildSettings.current.appURLScheme, clientId: String = BuildSettings.current.secrets.oauth.client, clientSecret: String = BuildSettings.current.secrets.oauth.secret @@ -89,6 +97,7 @@ struct WordPressDotComAuthenticator { self.coreDataStack = coreDataStack self.showProgressHUD = showProgressHUD self.authenticator = authenticator + self.ageRequirementController = ageRequirementController self.redirectURIScheme = redirectURIScheme self.clientId = clientId self.clientSecret = clientSecret @@ -103,6 +112,9 @@ struct WordPressDotComAuthenticator { from viewController: UIViewController, context: SignInContext ) async -> TaggedManagedObjectID? { + guard !ageRequirement.refuseSignInIfRestricted(from: viewController) else { + return nil + } WPAnalytics.track(.wpcomWebSignIn, properties: ["stage": "start"]) do { let account = try await attemptSignIn(from: viewController, context: context) @@ -181,6 +193,11 @@ struct WordPressDotComAuthenticator { throw .alreadySignedIn(signedInAccountEmail: defaultAccountEmail) } + // Re-check right before persisting: the restriction can arrive while the sign-in flow is in progress. + guard !ageRequirement.refuseSignInIfRestricted(from: viewController) else { + throw .ageRestricted + } + // Save the signed-in account details (and sites) into Core Data. let accountID: TaggedManagedObjectID do { @@ -482,6 +499,8 @@ private extension WordPressDotComAuthenticator.SignInError { return Strings.alreadySignedIn(signedInAccountEmail) case .loadingSites: return Strings.loadingSitesError + case .ageRestricted: + return nil } } }