From 0837fa52e5159ad378d0cba8d982a6dfecbcb6a1 Mon Sep 17 00:00:00 2001 From: ykisana Date: Mon, 19 Oct 2026 16:59:08 -0600 Subject: [PATCH] fix: rework schedule logic --- Normal.xcodeproj/project.pbxproj | 5 + Normal/App/AppContainer.swift | 4 +- Normal/App/AppServices.swift | 11 +- Normal/App/NormalApp.swift | 4 +- Normal/App/UITestDeviceActivityCenter.swift | 24 +- Normal/App/UITestSupport.swift | 15 + Normal/Intents/AppBlockIntentPerformer.swift | 9 +- Normal/Models/BlockSchedule.swift | 17 +- Normal/Resources/Localizable.xcstrings | 16 + Normal/Services/BlockActions.swift | 19 +- Normal/Services/DeviceActivityProviding.swift | 43 +- Normal/Services/ScheduleService.swift | 114 ++-- Normal/Services/ScreenTimeProviding.swift | 17 +- Normal/Services/ScreenTimeService.swift | 100 +++- Normal/Services/ShieldStoring.swift | 153 ++--- Normal/Services/TimedUnblockService.swift | 90 ++- Normal/Views/AppSelect/AppSelectView.swift | 12 +- .../TimedUnlock/GroupTimedUnblockSheet.swift | 2 +- Normal/Views/Home/HomeView.swift | 7 +- Normal/Views/Schedules/ScheduleCardView.swift | 12 +- .../Views/Schedules/ScheduleFormSheet.swift | 33 +- Normal/Views/Settings/CallbackLogView.swift | 31 ++ .../Views/Settings/GeneralSettingsView.swift | 5 + NormalMonitor/NormalMonitor.swift | 150 ++--- .../AppBlockIntentPerformerTests.swift | 15 +- NormalTests/Services/BlockActionsTests.swift | 40 +- .../Services/BlockingMigrationTests.swift | 398 +++++++++++++ .../Services/BlockingScenarioTests.swift | 525 ++++++++++++++++++ .../Services/SafetyInvariantTests.swift | 41 +- .../Services/ScheduleServiceTests.swift | 373 +++++++------ .../Services/ScreenTimeServiceTests.swift | 60 +- .../Services/TimedUnblockServiceTests.swift | 132 +---- NormalTests/Shared/DTOBackCompatTests.swift | 42 ++ .../Shared/ScheduleActiveDayTests.swift | 23 - NormalTests/Shared/SharedStoreTests.swift | 97 +--- NormalTests/Shared/ShieldExclusionTests.swift | 140 ----- .../Shared/ShieldReconcilerTests.swift | 121 ++++ NormalTests/Shared/ShieldResolverTests.swift | 427 ++++++++++++++ NormalTests/Shared/WebFilterTests.swift | 15 - NormalTests/TestSupport/BlockingHarness.swift | 202 +++++++ .../FakeDeviceActivityCenter.swift | 19 + .../TestSupport/FakeScreenTimeService.swift | 69 ++- NormalTests/TestSupport/FakeSharedStore.swift | 21 +- .../TestSupport/StatefulScreenTimeSpy.swift | 20 +- NormalTests/TestSupport/TokenFixtures.swift | 12 +- Shared/CallbackLog.swift | 34 ++ Shared/CustomDomainFilter.swift | 22 - Shared/ManagedSettingsUtil.swift | 34 -- Shared/SharedConstants.swift | 1 + Shared/SharedDTO.swift | 93 ++-- Shared/SharedStore.swift | 29 +- Shared/SharedStoreProviding.swift | 34 +- Shared/ShieldReconciler.swift | 104 ++++ Shared/ShieldResolver.swift | 305 ++++++++++ 54 files changed, 3058 insertions(+), 1283 deletions(-) create mode 100644 Normal/Views/Settings/CallbackLogView.swift create mode 100644 NormalTests/Services/BlockingMigrationTests.swift create mode 100644 NormalTests/Services/BlockingScenarioTests.swift delete mode 100644 NormalTests/Shared/ShieldExclusionTests.swift create mode 100644 NormalTests/Shared/ShieldReconcilerTests.swift create mode 100644 NormalTests/Shared/ShieldResolverTests.swift create mode 100644 NormalTests/TestSupport/BlockingHarness.swift create mode 100644 Shared/CallbackLog.swift create mode 100644 Shared/ShieldReconciler.swift create mode 100644 Shared/ShieldResolver.swift diff --git a/Normal.xcodeproj/project.pbxproj b/Normal.xcodeproj/project.pbxproj index f3ad6f1..2db84bf 100644 --- a/Normal.xcodeproj/project.pbxproj +++ b/Normal.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ 2F4718A12F556F340013F94D /* Exceptions for "Shared" folder in "NormalMonitor" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + CallbackLog.swift, CustomDomainFilter.swift, DailyLimitEnforcement.swift, DataExtension.swift, @@ -90,6 +91,8 @@ SharedDTO.swift, SharedStore.swift, SharedStoreProviding.swift, + ShieldReconciler.swift, + ShieldResolver.swift, UsageDTO.swift, ); target = 2F4718642F5569C90013F94D /* NormalMonitor */; @@ -171,10 +174,12 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( DataExtension.swift, + DomainNormalizer.swift, SharedConstants.swift, SharedDTO.swift, SharedStore.swift, SharedStoreProviding.swift, + ShieldResolver.swift, UsageDTO.swift, ); target = 2FDA0000000000000000B003 /* NormalShield */; diff --git a/Normal/App/AppContainer.swift b/Normal/App/AppContainer.swift index 32ed3d2..ef30153 100644 --- a/Normal/App/AppContainer.swift +++ b/Normal/App/AppContainer.swift @@ -42,9 +42,7 @@ struct AppContainer: View { .task { mirrorCustomDomainsEnabled() } .onChange(of: allSettings.first?.enableCustomDomains ?? false) { _, enabled in scheduleService.mirrorCustomDomainsEnabled(enabled) - if !enabled { - screenTimeService.clearCustomDomainFilter() - } + screenTimeService.reconcile() } } diff --git a/Normal/App/AppServices.swift b/Normal/App/AppServices.swift index 3128176..30438aa 100644 --- a/Normal/App/AppServices.swift +++ b/Normal/App/AppServices.swift @@ -10,18 +10,17 @@ final class AppServices { let usageLimit: UsageLimitService init() { - let screenTime = ScreenTimeService() - self.screenTime = screenTime - if UITestSupport.isActive { let center = UITestDeviceActivityCenter() let store = SharedStore( defaults: UserDefaults(suiteName: "uitest-\(UUID().uuidString)") ) + let screenTime = ScreenTimeService(sharedStore: store, activityCenter: center) + self.screenTime = screenTime timedUnblock = TimedUnblockService( activityCenter: center, sharedStore: store, - onExpiration: { screenTime.notifyUpdate() } + onExpiration: { screenTime.reconcile() } ) schedule = ScheduleService(activityCenter: center, sharedStore: store) appReview = AppReviewService( @@ -34,7 +33,9 @@ final class AppServices { ledger: InMemoryUsageLimitLedger() ) } else { - timedUnblock = TimedUnblockService(onExpiration: { screenTime.notifyUpdate() }) + let screenTime = ScreenTimeService() + self.screenTime = screenTime + timedUnblock = TimedUnblockService(onExpiration: { screenTime.reconcile() }) schedule = ScheduleService() appReview = AppReviewService() emergencyUnblock = EmergencyUnblockService(ledger: KeychainEmergencyUnblockLedger()) diff --git a/Normal/App/NormalApp.swift b/Normal/App/NormalApp.swift index de65e65..ded6d75 100644 --- a/Normal/App/NormalApp.swift +++ b/Normal/App/NormalApp.swift @@ -36,7 +36,7 @@ struct NormalApp: App { } if UITestSupport.isActive { - context.insert(SelectedApps(selection: FamilyActivitySelection())) + context.insert(SelectedApps(selection: UITestSupport.mainSelection)) if !UITestSupport.noKeys { context.insert(Key(name: "Test Key", type: .qr, rawValue: UITestSupport.stubScanValue)) } @@ -44,7 +44,7 @@ struct NormalApp: App { let group = AppGroup( id: UITestSupport.seedGroupID, name: "Test Group", - selection: FamilyActivitySelection() + selection: UITestSupport.groupSelection ) context.insert(group) context.insert(Key(name: "Group Key", type: .qr, rawValue: "GROUP-VALUE", groupID: group.id)) diff --git a/Normal/App/UITestDeviceActivityCenter.swift b/Normal/App/UITestDeviceActivityCenter.swift index a309bea..1f27dfc 100644 --- a/Normal/App/UITestDeviceActivityCenter.swift +++ b/Normal/App/UITestDeviceActivityCenter.swift @@ -2,11 +2,27 @@ import DeviceActivity import Foundation final class UITestDeviceActivityCenter: DeviceActivityProviding { + private var schedules: [String: DeviceActivitySchedule] = [:] + + var monitoredActivityNames: Set { + Set(schedules.keys) + } + func startMonitoring( - _: DeviceActivityName, - during _: DeviceActivitySchedule, + _ activityName: DeviceActivityName, + during schedule: DeviceActivitySchedule, events _: [DeviceActivityEvent.Name: DeviceActivityEvent] - ) throws {} + ) throws { + schedules[activityName.rawValue] = schedule + } + + func stopMonitoring(_ activityNames: [DeviceActivityName]) { + for name in activityNames { + schedules.removeValue(forKey: name.rawValue) + } + } - func stopMonitoring(_: [DeviceActivityName]) {} + func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? { + schedules[activityName.rawValue] + } } diff --git a/Normal/App/UITestSupport.swift b/Normal/App/UITestSupport.swift index d01d47e..7dc5d07 100644 --- a/Normal/App/UITestSupport.swift +++ b/Normal/App/UITestSupport.swift @@ -1,4 +1,6 @@ +import FamilyControls import Foundation +import ManagedSettings enum UITestSupport { private static let arguments = ProcessInfo.processInfo.arguments @@ -32,6 +34,19 @@ enum UITestSupport { static let stubScanValue = "UITEST-SCAN-VALUE" + static let mainSelection = selection(apps: [1, 2, 3]) + + static let groupSelection = selection(apps: [1]) + + private static func selection(apps: [UInt8]) -> FamilyActivitySelection { + var selection = FamilyActivitySelection() + selection.applicationTokens = Set(apps.compactMap { id in + let json = #"{"data":"\#(Data([id]).base64EncodedString())"}"# + return try? JSONDecoder().decode(ApplicationToken.self, from: Data(json.utf8)) + }) + return selection + } + private static func value(after flag: String) -> String? { guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { return nil } return arguments[index + 1] diff --git a/Normal/Intents/AppBlockIntentPerformer.swift b/Normal/Intents/AppBlockIntentPerformer.swift index 229143f..359d7ae 100644 --- a/Normal/Intents/AppBlockIntentPerformer.swift +++ b/Normal/Intents/AppBlockIntentPerformer.swift @@ -6,7 +6,6 @@ final class AppBlockIntentPerformer: BlockIntentPerforming { private let container: ModelContainer private let screenTimeService: any ScreenTimeProviding private let timedUnblockService: TimedUnblockService - private let scheduleService: ScheduleService private let syncsExternalSurfaces: Bool private let logger = Logger(subsystem: "com.normalengineering.normal", category: "Intents") @@ -14,13 +13,11 @@ final class AppBlockIntentPerformer: BlockIntentPerforming { container: ModelContainer, screenTimeService: any ScreenTimeProviding, timedUnblockService: TimedUnblockService, - scheduleService: ScheduleService, syncsExternalSurfaces: Bool = true ) { self.container = container self.screenTimeService = screenTimeService self.timedUnblockService = timedUnblockService - self.scheduleService = scheduleService self.syncsExternalSurfaces = syncsExternalSurfaces } @@ -28,8 +25,7 @@ final class AppBlockIntentPerformer: BlockIntentPerforming { self.init( container: container, screenTimeService: services.screenTime, - timedUnblockService: services.timedUnblock, - scheduleService: services.schedule + timedUnblockService: services.timedUnblock ) } @@ -55,8 +51,7 @@ final class AppBlockIntentPerformer: BlockIntentPerforming { mainSelection: mainSelection, settings: settings, screenTimeService: screenTimeService, - timedUnblockService: timedUnblockService, - scheduleService: scheduleService + timedUnblockService: timedUnblockService ) syncExternalSurfaces(settings: settings) return BlockOutcome(name: String(localized: "All Apps"), wasAlreadyBlocked: wasAlreadyBlocked) diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index 4978b79..1a5cfc0 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -115,7 +115,18 @@ final class BlockSchedule: Identifiable { ) } - func toDTO() -> ScheduleDTO? { + func hasSameRules(as dto: ScheduleDTO) -> Bool { + startHour == dto.startHour + && startMinute == dto.startMinute + && effectiveDurationMinutes == dto.durationMinutes + && weekdays == dto.weekdays + && shouldBlock == dto.shouldBlock + && isTimed == dto.isTimed + && customDomains == dto.customDomains + && (try? FamilyActivitySelection.fromData(dto.selectionData)) == selection + } + + func toDTO(activatedAt: Date? = nil) -> ScheduleDTO? { guard let data = try? selection.toData() else { return nil } return ScheduleDTO( id: id, @@ -127,7 +138,9 @@ final class BlockSchedule: Identifiable { weekdays: weekdays, shouldBlock: shouldBlock, isTimed: isTimed, - customDomains: customDomains + customDomains: customDomains, + isEnabled: isEnabled, + activatedAt: activatedAt ) } } diff --git a/Normal/Resources/Localizable.xcstrings b/Normal/Resources/Localizable.xcstrings index fefb7ec..19d9eda 100644 --- a/Normal/Resources/Localizable.xcstrings +++ b/Normal/Resources/Localizable.xcstrings @@ -459,6 +459,10 @@ "comment" : "Description of the Block Group intent.", "isCommentAutoGenerated" : true }, + "Callback Log (%lld)" : { + "comment" : "The title of the callback log view. The argument is the number of lines in the log.", + "isCommentAutoGenerated" : true + }, "Camera Access Needed" : { "comment" : "A label describing the need for camera access.", "isCommentAutoGenerated" : true @@ -506,6 +510,10 @@ "comment" : "A description for the \"No App Selection\" ContentUnavailableView in the GroupsView.", "isCommentAutoGenerated" : true }, + "Clear" : { + "comment" : "A button that clears the callback log.", + "isCommentAutoGenerated" : true + }, "Close" : { "comment" : "A button to close the settings view.", "isCommentAutoGenerated" : true @@ -524,6 +532,10 @@ }, "Continue" : { + }, + "Copy" : { + "comment" : "A button that copies the callback log to the clipboard.", + "isCommentAutoGenerated" : true }, "Create a schedule to automatically block or unblock apps at set times." : { "comment" : "A footer message in the Schedules view that instructs the user on how to create schedules.", @@ -1272,6 +1284,10 @@ "comment" : "The text of a button that saves a newly created app group.", "isCommentAutoGenerated" : true }, + "Saved, but iOS only tracks 20 schedules and timers at once, so this schedule may start or end up to 30 minutes late. Turn off another schedule to make it exact." : { + "comment" : "Error description for a schedule registration error.", + "isCommentAutoGenerated" : true + }, "Scan QR Code / Barcode" : { "comment" : "The title of the view.", "isCommentAutoGenerated" : true diff --git a/Normal/Services/BlockActions.swift b/Normal/Services/BlockActions.swift index 4812e4e..03d8a0a 100644 --- a/Normal/Services/BlockActions.swift +++ b/Normal/Services/BlockActions.swift @@ -5,16 +5,14 @@ enum BlockActions { mainSelection: SelectedApps, settings: Settings, screenTimeService: any ScreenTimeProviding, - timedUnblockService: TimedUnblockService, - scheduleService: ScheduleService + timedUnblockService: TimedUnblockService ) { - screenTimeService.applyShieldOnAll( + timedUnblockService.clearAll() + screenTimeService.blockAll( selection: mainSelection.selection, customDomains: settings.enableCustomDomains ? mainSelection.customDomains : [], blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete ) - timedUnblockService.clearAll() - scheduleService.setScheduleOverride(false) } static func blockGroup( @@ -25,15 +23,9 @@ enum BlockActions { ) { let customDomains = settings.enableCustomDomains ? group.customDomains : [] if timedUnblockService.isGroupUnblockActive(groupId: group.id) { - timedUnblockService.cancelGroup( - groupId: group.id, - selection: group.selection, - customDomains: customDomains, - screenTimeService: screenTimeService - ) - } else { - screenTimeService.addToShields(selection: group.selection, customDomains: customDomains) + timedUnblockService.cancelGroup(groupId: group.id) } + screenTimeService.block(selection: group.selection, customDomains: customDomains) } static func emergencyUnblock( @@ -45,7 +37,6 @@ enum BlockActions { ) { timedUnblockService.discardAll() scheduleService.disableAll(schedules, screenTimeService: screenTimeService) - scheduleService.setScheduleOverride(false) usageLimitService.overrideToday() screenTimeService.removeAllRestrictions() } diff --git a/Normal/Services/DeviceActivityProviding.swift b/Normal/Services/DeviceActivityProviding.swift index ee17696..dce4bd2 100644 --- a/Normal/Services/DeviceActivityProviding.swift +++ b/Normal/Services/DeviceActivityProviding.swift @@ -9,6 +9,47 @@ protocol DeviceActivityProviding { ) throws func stopMonitoring(_ activityNames: [DeviceActivityName]) + + var monitoredActivityNames: Set { get } + + func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? +} + +extension DeviceActivityCenter: DeviceActivityProviding { + var monitoredActivityNames: Set { + Set(activities.map(\.rawValue)) + } + + func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? { + schedule(for: activityName) + } } -extension DeviceActivityCenter: DeviceActivityProviding {} +extension DeviceActivitySchedule { + func hasSameWindow(as other: DeviceActivitySchedule) -> Bool { + repeats == other.repeats + && Self.time(intervalStart) == Self.time(other.intervalStart) + && Self.time(intervalEnd) == Self.time(other.intervalEnd) + } + + private static func time(_ components: DateComponents) -> [Int?] { + [components.year, components.month, components.day, components.hour, components.minute, components.second ?? 0] + } +} + +extension DeviceActivityProviding { + func syncHeartbeat(sharedStore: any SharedStoreProviding, at date: Date) { + let name = DeviceActivityName(SharedConstants.reblockHeartbeatActivityName) + let monitored = monitoredActivityNames + let needed = sharedStore.loadSchedules().contains { $0.isEffectivelyEnabled(monitoredActivities: monitored) } + || sharedStore.loadTimedUnblocks().contains { $0.endDate > date } + let heartbeat = DeviceActivityScheduleFactory.heartbeat() + let current = registeredSchedule(for: name) + if needed { + guard current?.hasSameWindow(as: heartbeat) != true else { return } + try? startMonitoring(name, during: heartbeat, events: [:]) + } else if current != nil || monitored.contains(name.rawValue) { + stopMonitoring([name]) + } + } +} diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 5677ec5..7b76ed8 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -8,30 +8,38 @@ import Observation final class ScheduleService { private let activityCenter: any DeviceActivityProviding private let sharedStore: any SharedStoreProviding + private let clock: () -> Date init( activityCenter: any DeviceActivityProviding = DeviceActivityCenter(), - sharedStore: any SharedStoreProviding = SharedStore() + sharedStore: any SharedStoreProviding = SharedStore(), + clock: @escaping () -> Date = { .now } ) { self.activityCenter = activityCenter self.sharedStore = sharedStore + self.clock = clock } - func sync( - _ schedule: BlockSchedule, - screenTimeService: any ScreenTimeProviding - ) throws { + func sync(_ schedule: BlockSchedule) throws { let activityName = activityName(for: schedule) activityCenter.stopMonitoring([activityName]) guard schedule.isEnabled else { - liftBlockIfNeeded(schedule, screenTimeService: screenTimeService) + CallbackLog.record("app sync \"\(schedule.name)\": stopped (disabled)") return } let deviceSchedule = makeDeviceSchedule(for: schedule) - try activityCenter.startMonitoring(activityName, during: deviceSchedule, events: [:]) - applyIfActiveNow(schedule, screenTimeService: screenTimeService) + do { + try activityCenter.startMonitoring(activityName, during: deviceSchedule, events: [:]) + } catch DeviceActivityCenter.MonitoringError.excessiveActivities { + CallbackLog.record("app sync \"\(schedule.name)\": too many activities") + throw ScheduleRegistrationError.tooManyActivities + } catch { + CallbackLog.record("app sync \"\(schedule.name)\": startMonitoring failed: \(error)") + throw error + } + CallbackLog.record("app sync \"\(schedule.name)\": stopped + restarted") } func registerAll( @@ -39,9 +47,21 @@ final class ScheduleService { screenTimeService: any ScreenTimeProviding ) { syncAllToSharedStore(schedules) - for schedule in schedules where schedule.isEnabled { - try? sync(schedule, screenTimeService: screenTimeService) + let wanted = Set(schedules.filter(\.isEnabled).map { activityName(for: $0).rawValue }) + let stale = activityCenter.monitoredActivityNames.filter { $0.hasPrefix("schedule_") && !wanted.contains($0) } + if !stale.isEmpty { + activityCenter.stopMonitoring(stale.map { DeviceActivityName($0) }) + } + activityCenter.syncHeartbeat(sharedStore: sharedStore, at: clock()) + for schedule in schedules where schedule.isEnabled && !isRegistered(schedule) { + try? sync(schedule) } + screenTimeService.reconcile() + } + + private func isRegistered(_ schedule: BlockSchedule) -> Bool { + activityCenter.registeredSchedule(for: activityName(for: schedule))? + .hasSameWindow(as: makeDeviceSchedule(for: schedule)) == true } func mirrorCustomDomainsEnabled(_ enabled: Bool) { @@ -50,22 +70,22 @@ final class ScheduleService { func remove( _ schedule: BlockSchedule, + remaining: [BlockSchedule], screenTimeService: any ScreenTimeProviding ) { activityCenter.stopMonitoring([activityName(for: schedule)]) - liftBlockIfNeeded(schedule, screenTimeService: screenTimeService) + syncAllToSharedStore(remaining) + activityCenter.syncHeartbeat(sharedStore: sharedStore, at: clock()) + screenTimeService.reconcile() } func toggleEnabled( _ schedule: BlockSchedule, + allSchedules: [BlockSchedule], screenTimeService: any ScreenTimeProviding ) throws { schedule.isEnabled.toggle() - try sync(schedule, screenTimeService: screenTimeService) - } - - func setScheduleOverride(_ active: Bool) { - sharedStore.setScheduleOverrideActive(active) + try syncAndPersist(schedule, allSchedules: allSchedules, screenTimeService: screenTimeService) } func disableAll( @@ -76,13 +96,21 @@ final class ScheduleService { schedule.isEnabled = false } for schedule in schedules { - try? sync(schedule, screenTimeService: screenTimeService) + try? sync(schedule) } syncAllToSharedStore(schedules) + activityCenter.syncHeartbeat(sharedStore: sharedStore, at: clock()) + screenTimeService.reconcile() } - func syncAllToSharedStore(_ schedules: [BlockSchedule]) { - sharedStore.saveSchedules(schedules.compactMap { $0.toDTO() }) + func syncAllToSharedStore(_ schedules: [BlockSchedule], activating activatedID: UUID? = nil) { + var previous: [UUID: Date] = [:] + for dto in sharedStore.loadSchedules() { + previous[dto.id] = dto.activatedAt + } + sharedStore.saveSchedules(schedules.compactMap { schedule in + schedule.toDTO(activatedAt: schedule.id == activatedID ? clock() : previous[schedule.id]) + }) } func syncAndPersist( @@ -90,44 +118,20 @@ final class ScheduleService { allSchedules: [BlockSchedule], screenTimeService: any ScreenTimeProviding ) throws { - try sync(schedule, screenTimeService: screenTimeService) - syncAllToSharedStore(allSchedules) + let previous = sharedStore.loadSchedules().first { $0.id == schedule.id } + let wasEnabled = previous?.isEffectivelyEnabled(monitoredActivities: activityCenter.monitoredActivityNames) == true + let rulesChanged = previous.map { !schedule.hasSameRules(as: $0) } ?? true + let activates = schedule.isEnabled && (!wasEnabled || (schedule.isTimed && rulesChanged)) + syncAllToSharedStore(allSchedules, activating: activates ? schedule.id : nil) + screenTimeService.reconcile() + activityCenter.syncHeartbeat(sharedStore: sharedStore, at: clock()) + try sync(schedule) } private func activityName(for schedule: BlockSchedule) -> DeviceActivityName { DeviceActivityName(SharedConstants.scheduleActivityName(for: schedule.id)) } - private func applyIfActiveNow( - _ schedule: BlockSchedule, - screenTimeService: any ScreenTimeProviding - ) { - guard let windowStart = schedule.activeWindowStart(at: .now) else { return } - let domains = effectiveDomains(schedule) - if schedule.shouldBlock { - guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return } - let exclusion = ShieldExclusion.groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now) - screenTimeService.addToShields( - selection: exclusion.applied(to: schedule.selection), - customDomains: exclusion.applied(to: domains) - ) - } else { - screenTimeService.removeFromShields(selection: schedule.selection, customDomains: domains) - } - } - - private func liftBlockIfNeeded( - _ schedule: BlockSchedule, - screenTimeService: any ScreenTimeProviding - ) { - guard schedule.shouldBlock else { return } - screenTimeService.removeFromShields(selection: schedule.selection, customDomains: effectiveDomains(schedule)) - } - - private func effectiveDomains(_ schedule: BlockSchedule) -> [String] { - sharedStore.isCustomDomainsEnabled() ? schedule.customDomains : [] - } - private func makeDeviceSchedule(for schedule: BlockSchedule) -> DeviceActivitySchedule { var start = DateComponents() start.hour = schedule.startHour @@ -145,3 +149,11 @@ final class ScheduleService { return DeviceActivitySchedule(intervalStart: start, intervalEnd: end, repeats: true) } } + +enum ScheduleRegistrationError: LocalizedError { + case tooManyActivities + + var errorDescription: String? { + String(localized: "Saved, but iOS only tracks 20 schedules and timers at once, so this schedule may start or end up to 30 minutes late. Turn off another schedule to make it exact.") + } +} diff --git a/Normal/Services/ScreenTimeProviding.swift b/Normal/Services/ScreenTimeProviding.swift index 7516071..af4ecde 100644 --- a/Normal/Services/ScreenTimeProviding.swift +++ b/Normal/Services/ScreenTimeProviding.swift @@ -14,16 +14,23 @@ protocol ScreenTimeProviding: AnyObject { func ensureAuthorized() async -> Bool func enablePreventAppDelete() func disablePreventAppDelete() - func applyShieldOnAll( + func blockAll( selection: FamilyActivitySelection, customDomains: [String], blockAllPreventsAppDelete: Bool ) - func removeShieldOnAll(blockAllPreventsAppDelete: Bool) + func unblockAll(blockAllPreventsAppDelete: Bool) + func block(selection: FamilyActivitySelection, customDomains: [String]) + func unblock(selection: FamilyActivitySelection, customDomains: [String]) + func updateMainList( + from old: FamilyActivitySelection, + customDomains oldDomains: [String], + to new: FamilyActivitySelection, + customDomains newDomains: [String], + addedBlockedAsOf addedDate: Date + ) func removeAllRestrictions() - func addToShields(selection: FamilyActivitySelection, customDomains: [String]) - func removeFromShields(selection: FamilyActivitySelection, customDomains: [String]) - func clearCustomDomainFilter() + func reconcile() func activeShieldCount() -> Int func blockStatus(selection: FamilyActivitySelection?, customDomains: [String]?) -> BlockStatus func hasActiveCustomDomainFilter() -> Bool diff --git a/Normal/Services/ScreenTimeService.swift b/Normal/Services/ScreenTimeService.swift index 5cbfc21..da0ba3a 100644 --- a/Normal/Services/ScreenTimeService.swift +++ b/Normal/Services/ScreenTimeService.swift @@ -1,3 +1,4 @@ +import DeviceActivity import FamilyControls import Foundation import Observation @@ -10,16 +11,28 @@ final class ScreenTimeService: ScreenTimeProviding { var lastUpdate: Date = .now private let shield: ShieldStoring + private let sharedStore: any SharedStoreProviding + private let activityCenter: any DeviceActivityProviding + private let clock: () -> Date private let authCenter = AuthorizationCenter.shared private let defaults: UserDefaults private let logger = Logger(subsystem: "com.normalengineering.normal", category: "ScreenTime") private static let authorizedKey = "hasAuthorizedFamilyControls" - init(defaults: UserDefaults = .standard, shield: ShieldStoring? = nil) { + init( + defaults: UserDefaults = .standard, + shield: ShieldStoring? = nil, + sharedStore: any SharedStoreProviding = SharedStore(), + activityCenter: any DeviceActivityProviding = DeviceActivityCenter(), + clock: @escaping () -> Date = { .now } + ) { self.defaults = defaults self.shield = shield ?? (UITestSupport.isActive ? InMemoryShieldStore() : ManagedSettingsShieldStore()) + self.sharedStore = sharedStore + self.activityCenter = activityCenter + self.clock = clock } var isAuthorizedNow: Bool { @@ -72,52 +85,89 @@ final class ScreenTimeService: ScreenTimeProviding { } func enablePreventAppDelete() { - shield.denyAppRemoval = true - notifyUpdate() + reconcile { $0.setAppDeletionBlocked(true, at: clock()) } } func disablePreventAppDelete() { - shield.denyAppRemoval = false - notifyUpdate() + reconcile { $0.setAppDeletionBlocked(false, at: clock()) } } - func applyShieldOnAll( + func blockAll( selection: FamilyActivitySelection, customDomains: [String] = [], blockAllPreventsAppDelete: Bool ) { - shield.replace(with: selection, customDomains: customDomains) - if blockAllPreventsAppDelete { - shield.denyAppRemoval = true + reconcile { state in + let now = clock() + state.blockAll(selection, customDomains: customDomains, at: now) + if blockAllPreventsAppDelete { + state.setAppDeletionBlocked(true, at: now) + } } - notifyUpdate() } - func removeShieldOnAll(blockAllPreventsAppDelete: Bool) { - shield.clear() - if blockAllPreventsAppDelete { - shield.denyAppRemoval = false + func unblockAll(blockAllPreventsAppDelete: Bool) { + reconcile { state in + let now = clock() + state.unblockAll(at: now) + if blockAllPreventsAppDelete { + state.setAppDeletionBlocked(false, at: now) + } } - notifyUpdate() } - func removeAllRestrictions() { - shield.clearAllSettings() - notifyUpdate() + func block(selection: FamilyActivitySelection, customDomains: [String] = []) { + reconcile { $0.block(selection, customDomains: customDomains, at: clock()) } } - func addToShields(selection: FamilyActivitySelection, customDomains: [String] = []) { - shield.union(with: selection, customDomains: customDomains) - notifyUpdate() + func unblock(selection: FamilyActivitySelection, customDomains: [String] = []) { + reconcile { $0.unblock(selection, customDomains: customDomains, at: clock()) } } - func removeFromShields(selection: FamilyActivitySelection, customDomains: [String] = []) { - shield.subtract(with: selection, customDomains: customDomains) + func updateMainList( + from old: FamilyActivitySelection, + customDomains oldDomains: [String], + to new: FamilyActivitySelection, + customDomains newDomains: [String], + addedBlockedAsOf addedDate: Date + ) { + reconcile { state in + state.unblock( + old.subtracting(new), + customDomains: oldDomains.filter { !newDomains.contains($0) }, + at: clock() + ) + state.block( + new.subtracting(old), + customDomains: newDomains.filter { !oldDomains.contains($0) }, + at: addedDate + ) + } + } + + func removeAllRestrictions() { + reconcile { state in + let now = clock() + state.unblockAll(at: now) + state.setAppDeletionBlocked(false, at: now) + } + shield.clearAllSettings() notifyUpdate() } - func clearCustomDomainFilter() { - shield.clearCustomDomainFilter() + func reconcile() { + reconcile { _ in } + } + + private func reconcile(_ update: (inout ManualBlockState) -> Void) { + let plan = ShieldReconciler.reconcile( + sharedStore: sharedStore, + shields: shield, + monitoredActivities: activityCenter.monitoredActivityNames, + at: clock(), + update: update + ) + CallbackLog.record("app reconcile: \(plan.map { "\($0.applications.count) apps" } ?? "skipped, data protected")") notifyUpdate() } diff --git a/Normal/Services/ShieldStoring.swift b/Normal/Services/ShieldStoring.swift index 50d53ec..58eeec9 100644 --- a/Normal/Services/ShieldStoring.swift +++ b/Normal/Services/ShieldStoring.swift @@ -1,150 +1,101 @@ import FamilyControls import ManagedSettings -protocol ShieldStoring: AnyObject { - var denyAppRemoval: Bool { get set } - func replace(with selection: FamilyActivitySelection, customDomains: [String]) - func clear() +protocol ShieldStoring: ShieldApplying { func clearAllSettings() - func union(with selection: FamilyActivitySelection, customDomains: [String]) - func subtract(with selection: FamilyActivitySelection, customDomains: [String]) - func clearCustomDomainFilter() - func shieldedCount() -> Int - func status(for selection: FamilyActivitySelection?, customDomains: [String]?) -> BlockStatus - func isShielded(_ token: SelectedTokenKind) -> Bool - func hasActiveCustomDomainFilter() -> Bool } -final class ManagedSettingsShieldStore: ShieldStoring { - private let store = ManagedSettingsStore() - +extension ShieldStoring { var denyAppRemoval: Bool { - get { store.application.denyAppRemoval ?? false } - set { store.application.denyAppRemoval = newValue } - } - - func replace(with selection: FamilyActivitySelection, customDomains: [String]) { - store.replaceShields(with: selection) - store.replaceFilterDomains(customDomains) + currentShields().denyAppRemoval ?? false } - func clear() { - store.clearShields() - store.clearFilterDomains() - } - - func clearAllSettings() { - store.clearAllSettings() + func shieldedCount() -> Int { + currentShields().shieldedCount } - func union(with selection: FamilyActivitySelection, customDomains: [String]) { - store.unionShields(with: selection) - store.unionFilterDomains(customDomains) + func status(for selection: FamilyActivitySelection?, customDomains: [String]?) -> BlockStatus { + currentShields().status(for: selection, customDomains: customDomains) } - func subtract(with selection: FamilyActivitySelection, customDomains: [String]) { - store.subtractShields(with: selection) - store.subtractFilterDomains(customDomains) + func isShielded(_ token: SelectedTokenKind) -> Bool { + currentShields().isShielded(token) } - func clearCustomDomainFilter() { - store.clearFilterDomains() + func hasActiveCustomDomainFilter() -> Bool { + !currentShields().customDomains.isEmpty } +} - func shieldedCount() -> Int { - (store.shield.applications?.count ?? 0) - + (store.shield.webDomains?.count ?? 0) - + store.shield.applicationCategories.tokenSet.count - + store.filterDomains().count +extension ShieldPlan { + var shieldedCount: Int { + applications.count + webDomains.count + categories.count + customDomains.count } - func status(for selection: FamilyActivitySelection?, customDomains: [String]?) -> BlockStatus { + func status(for selection: FamilyActivitySelection?, customDomains domains: [String]?) -> BlockStatus { guard let selection else { return .none } + let targetDomains = Set((domains ?? []).compactMap(DomainNormalizer.normalize)) - let currentApps = store.shield.applications ?? [] - let currentWeb = store.shield.webDomains ?? [] - let currentCats = store.shield.applicationCategories.tokenSet - let currentDomains = store.filterDomains() - let targetDomains = Set( - (customDomains ?? []).compactMap(DomainNormalizer.normalize).map { WebDomain(domain: $0) } - ) - - let appsDisjoint = selection.applicationTokens.isDisjoint(with: currentApps) - let webDisjoint = selection.webDomainTokens.isDisjoint(with: currentWeb) - let catsDisjoint = selection.categoryTokens.isDisjoint(with: currentCats) - let domainsDisjoint = targetDomains.isDisjoint(with: currentDomains) - if appsDisjoint && webDisjoint && catsDisjoint && domainsDisjoint { + let disjoint = selection.applicationTokens.isDisjoint(with: applications) + && selection.webDomainTokens.isDisjoint(with: webDomains) + && selection.categoryTokens.isDisjoint(with: categories) + && targetDomains.isDisjoint(with: customDomains) + if disjoint { return .none } - let appsSubset = selection.applicationTokens.isSubset(of: currentApps) - let webSubset = selection.webDomainTokens.isSubset(of: currentWeb) - let catsSubset = selection.categoryTokens.isSubset(of: currentCats) - let domainsSubset = targetDomains.isSubset(of: currentDomains) - if appsSubset && webSubset && catsSubset && domainsSubset { - return .all - } - - return .some + let subset = selection.applicationTokens.isSubset(of: applications) + && selection.webDomainTokens.isSubset(of: webDomains) + && selection.categoryTokens.isSubset(of: categories) + && targetDomains.isSubset(of: customDomains) + return subset ? .all : .some } func isShielded(_ token: SelectedTokenKind) -> Bool { switch token { - case let .application(t): (store.shield.applications ?? []).contains(t) - case let .webDomain(t): (store.shield.webDomains ?? []).contains(t) - case let .category(t): store.shield.applicationCategories.tokenSet.contains(t) + case let .application(t): applications.contains(t) + case let .webDomain(t): webDomains.contains(t) + case let .category(t): categories.contains(t) } } - - func hasActiveCustomDomainFilter() -> Bool { - !store.filterDomains().isEmpty - } } -final class InMemoryShieldStore: ShieldStoring { - private var shielded: Bool - var denyAppRemoval: Bool - - init() { - shielded = UITestSupport.startBlocked - denyAppRemoval = UITestSupport.startBlocked - } +final class ManagedSettingsShieldStore: ShieldStoring { + private let store = ManagedSettingsStore() - func replace(with _: FamilyActivitySelection, customDomains _: [String]) { - shielded = true + func currentShields() -> ShieldPlan { + store.currentShields() } - func clear() { - shielded = false + func apply(_ plan: ShieldPlan) { + store.apply(plan) } func clearAllSettings() { - shielded = false - denyAppRemoval = false - } - - func union(with _: FamilyActivitySelection, customDomains _: [String]) { - shielded = true + store.clearAllSettings() } +} - func subtract(with _: FamilyActivitySelection, customDomains _: [String]) { - shielded = false - } +final class InMemoryShieldStore: ShieldStoring { + private var plan = ShieldPlan() - func clearCustomDomainFilter() {} - func shieldedCount() -> Int { - shielded ? 1 : 0 + init(startBlocked: Bool = UITestSupport.startBlocked) { + guard startBlocked else { return } + plan.formUnion(UITestSupport.mainSelection, customDomains: []) + plan.denyAppRemoval = true } - func status(for _: FamilyActivitySelection?, customDomains _: [String]?) -> BlockStatus { - shielded ? .all : .none + func currentShields() -> ShieldPlan { + plan } - func isShielded(_: SelectedTokenKind) -> Bool { - shielded + func apply(_ plan: ShieldPlan) { + let denyAppRemoval = plan.denyAppRemoval ?? self.plan.denyAppRemoval + self.plan = plan + self.plan.denyAppRemoval = denyAppRemoval } - func hasActiveCustomDomainFilter() -> Bool { - shielded + func clearAllSettings() { + plan = ShieldPlan() } } diff --git a/Normal/Services/TimedUnblockService.swift b/Normal/Services/TimedUnblockService.swift index e1a2f1a..9a01598 100644 --- a/Normal/Services/TimedUnblockService.swift +++ b/Normal/Services/TimedUnblockService.swift @@ -9,6 +9,7 @@ final class TimedUnblockService { private let activityCenter: any DeviceActivityProviding private let sharedStore: any SharedStoreProviding private let onExpiration: @MainActor @Sendable () -> Void + private let clock: () -> Date private(set) var activeUnblocks: [String: Date] = [:] private var expirationTasks: [String: Task] = [:] @@ -18,10 +19,12 @@ final class TimedUnblockService { init( activityCenter: any DeviceActivityProviding = DeviceActivityCenter(), sharedStore: any SharedStoreProviding = SharedStore(), + clock: @escaping () -> Date = { .now }, onExpiration: @escaping @MainActor @Sendable () -> Void ) { self.activityCenter = activityCenter self.sharedStore = sharedStore + self.clock = clock self.onExpiration = onExpiration restoreState() } @@ -60,7 +63,7 @@ final class TimedUnblockService { let activityName = SharedConstants.mainTimedUnblockActivityName cancelMonitoring(activityName: activityName) - let start = Date.now + let start = clock() let endDate = start.addingTimeInterval(duration.timeInterval) try scheduleActivity(name: activityName, start: start, endDate: endDate) @@ -76,8 +79,8 @@ final class TimedUnblockService { ) cancelAllGroupUnblocks() - screenTimeService.removeShieldOnAll(blockAllPreventsAppDelete: blockAllPreventsAppDelete) - sharedStore.setScheduleOverrideActive(false) + syncHeartbeat() + screenTimeService.reconcile() } func startGroup( @@ -91,7 +94,7 @@ final class TimedUnblockService { let activityName = SharedConstants.groupTimedUnblockActivityName(for: groupId) cancelMonitoring(activityName: activityName) - let start = Date.now + let start = clock() let endDate = start.addingTimeInterval(duration.timeInterval) try scheduleActivity(name: activityName, start: start, endDate: endDate) @@ -105,7 +108,8 @@ final class TimedUnblockService { isGroupUnblock: true ) - screenTimeService.removeFromShields(selection: selection, customDomains: customDomains) + syncHeartbeat() + screenTimeService.reconcile() } func cancelMain( @@ -115,25 +119,20 @@ final class TimedUnblockService { blockAllPreventsAppDelete: Bool = false ) { cancelMonitoring(activityName: SharedConstants.mainTimedUnblockActivityName) - screenTimeService.applyShieldOnAll( + cancelAllGroupUnblocks() + forget(id: Self.mainID) + syncHeartbeat() + screenTimeService.blockAll( selection: selection, customDomains: customDomains, blockAllPreventsAppDelete: blockAllPreventsAppDelete ) - cancelAllGroupUnblocks() - forget(id: Self.mainID) } - func cancelGroup( - groupId: UUID, - selection: FamilyActivitySelection, - customDomains: [String] = [], - screenTimeService: any ScreenTimeProviding - ) { - let id = groupId.uuidString + func cancelGroup(groupId: UUID) { cancelMonitoring(activityName: SharedConstants.groupTimedUnblockActivityName(for: groupId)) - screenTimeService.addToShields(selection: selection, customDomains: customDomains) - forget(id: id) + forget(id: groupId.uuidString) + syncHeartbeat() } func clearAll() { @@ -145,6 +144,7 @@ final class TimedUnblockService { } forget(id: key) } + syncHeartbeat() } func discardAll() { @@ -161,12 +161,26 @@ final class TimedUnblockService { } activeUnblocks.removeAll() activityCenter.stopMonitoring(activityNames.map { DeviceActivityName($0) }) + syncHeartbeat() } - func updateMainSelection(_ selection: FamilyActivitySelection, customDomains: [String] = []) { + func updateMainSelection( + _ selection: FamilyActivitySelection, + customDomains: [String] = [], + screenTimeService: any ScreenTimeProviding + ) { guard let endDate = activeUnblocks[Self.mainID] else { return } let activityName = SharedConstants.mainTimedUnblockActivityName let existing = sharedStore.findTimedUnblock(activityName: activityName) + let previous = existing.flatMap { try? FamilyActivitySelection.fromData($0.selectionData) } + ?? FamilyActivitySelection() + screenTimeService.updateMainList( + from: previous, + customDomains: existing?.customDomains ?? [], + to: selection, + customDomains: customDomains, + addedBlockedAsOf: (existing?.startDate ?? clock()).addingTimeInterval(-1) + ) try? persist( id: Self.mainID, selection: selection, @@ -273,23 +287,10 @@ final class TimedUnblockService { during: schedule, events: [:] ) - startHeartbeat() - } - - private func startHeartbeat() { - try? activityCenter.startMonitoring( - DeviceActivityName(SharedConstants.reblockHeartbeatActivityName), - during: DeviceActivityScheduleFactory.heartbeat(), - events: [:] - ) } private func syncHeartbeat() { - if sharedStore.loadTimedUnblocks().isEmpty { - activityCenter.stopMonitoring([DeviceActivityName(SharedConstants.reblockHeartbeatActivityName)]) - } else { - startHeartbeat() - } + activityCenter.syncHeartbeat(sharedStore: sharedStore, at: clock()) } func refresh(screenTimeService: any ScreenTimeProviding) { @@ -302,43 +303,24 @@ final class TimedUnblockService { } private func restoreState() { - for unblock in sharedStore.loadTimedUnblocks() where unblock.endDate > .now { + for unblock in sharedStore.loadTimedUnblocks() where unblock.endDate > clock() { activeUnblocks[unblock.id] = unblock.endDate scheduleExpiration(id: unblock.id, at: unblock.endDate) } } func reconcile(screenTimeService: any ScreenTimeProviding) { + screenTimeService.reconcile() for unblock in sharedStore.loadTimedUnblocks() { - if unblock.endDate > .now { + if unblock.endDate > clock() { activeUnblocks[unblock.id] = unblock.endDate scheduleExpiration(id: unblock.id, at: unblock.endDate) } else { - reapplyShield(for: unblock, screenTimeService: screenTimeService) sharedStore.removeTimedUnblock(id: unblock.id) } } syncHeartbeat() } - - private func reapplyShield( - for unblock: TimedUnblockDTO, - screenTimeService: any ScreenTimeProviding - ) { - guard let selection = try? FamilyActivitySelection.fromData(unblock.selectionData) - else { return } - - if unblock.isGroupUnblock { - guard !sharedStore.isMainTimedUnblockActive() else { return } - screenTimeService.addToShields(selection: selection, customDomains: unblock.customDomains) - } else { - screenTimeService.applyShieldOnAll( - selection: selection, - customDomains: unblock.customDomains, - blockAllPreventsAppDelete: unblock.blockAllPreventsAppDelete ?? false - ) - } - } } extension TimedUnblockService { diff --git a/Normal/Views/AppSelect/AppSelectView.swift b/Normal/Views/AppSelect/AppSelectView.swift index 2715c9d..66a0117 100644 --- a/Normal/Views/AppSelect/AppSelectView.swift +++ b/Normal/Views/AppSelect/AppSelectView.swift @@ -124,7 +124,11 @@ struct AppSelectView: View { } else { modelContext.insert(SelectedApps(selection: new, customDomains: customDomains)) } - timedUnblockService.updateMainSelection(new, customDomains: effectiveCustomDomains) + timedUnblockService.updateMainSelection( + new, + customDomains: effectiveCustomDomains, + screenTimeService: screenTimeService + ) } private func persistCustomDomains() { @@ -134,6 +138,10 @@ struct AppSelectView: View { } else { modelContext.insert(SelectedApps(selection: selection, customDomains: customDomains)) } - timedUnblockService.updateMainSelection(selection, customDomains: effectiveCustomDomains) + timedUnblockService.updateMainSelection( + selection, + customDomains: effectiveCustomDomains, + screenTimeService: screenTimeService + ) } } diff --git a/Normal/Views/BlockUnblockViews/TimedUnlock/GroupTimedUnblockSheet.swift b/Normal/Views/BlockUnblockViews/TimedUnlock/GroupTimedUnblockSheet.swift index 26bd23b..25f9037 100644 --- a/Normal/Views/BlockUnblockViews/TimedUnlock/GroupTimedUnblockSheet.swift +++ b/Normal/Views/BlockUnblockViews/TimedUnlock/GroupTimedUnblockSheet.swift @@ -27,7 +27,7 @@ struct GroupTimedUnblockSheet: View { ) }, onPermanentUnblock: { - screenTimeService.removeFromShields(selection: group.selection, customDomains: customDomains) + screenTimeService.unblock(selection: group.selection, customDomains: customDomains) } ) } diff --git a/Normal/Views/Home/HomeView.swift b/Normal/Views/Home/HomeView.swift index 143af2f..fa2af8c 100644 --- a/Normal/Views/Home/HomeView.swift +++ b/Normal/Views/Home/HomeView.swift @@ -5,7 +5,6 @@ import SwiftUI struct HomeView: View { @Environment(ScreenTimeService.self) private var screenTimeService @Environment(TimedUnblockService.self) private var timedUnblockService - @Environment(ScheduleService.self) private var scheduleService @Environment(AppReviewService.self) private var appReviewService @Environment(\.requestReview) private var requestReview @@ -79,10 +78,9 @@ struct HomeView: View { recordUnblockForReview() }, onPermanentUnblock: { - screenTimeService.removeShieldOnAll( + screenTimeService.unblockAll( blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete ) - scheduleService.setScheduleOverride(true) recordUnblockForReview() } ) @@ -94,8 +92,7 @@ struct HomeView: View { mainSelection: selection, settings: settings, screenTimeService: screenTimeService, - timedUnblockService: timedUnblockService, - scheduleService: scheduleService + timedUnblockService: timedUnblockService ) } diff --git a/Normal/Views/Schedules/ScheduleCardView.swift b/Normal/Views/Schedules/ScheduleCardView.swift index 7f50003..5158134 100644 --- a/Normal/Views/Schedules/ScheduleCardView.swift +++ b/Normal/Views/Schedules/ScheduleCardView.swift @@ -241,8 +241,11 @@ struct ScheduleCardView: View { private func toggleEnabled() { do { - try scheduleService.toggleEnabled(schedule, screenTimeService: screenTimeService) - scheduleService.syncAllToSharedStore(allSchedules) + try scheduleService.toggleEnabled( + schedule, + allSchedules: allSchedules, + screenTimeService: screenTimeService + ) } catch { self.error = error } @@ -259,9 +262,8 @@ struct ScheduleCardView: View { } private func deleteSchedule() { - scheduleService.remove(schedule, screenTimeService: screenTimeService) - modelContext.delete(schedule) let remaining = allSchedules.filter { $0.id != schedule.id } - scheduleService.syncAllToSharedStore(remaining) + scheduleService.remove(schedule, remaining: remaining, screenTimeService: screenTimeService) + modelContext.delete(schedule) } } diff --git a/Normal/Views/Schedules/ScheduleFormSheet.swift b/Normal/Views/Schedules/ScheduleFormSheet.swift index 79598f9..1cf3337 100644 --- a/Normal/Views/Schedules/ScheduleFormSheet.swift +++ b/Normal/Views/Schedules/ScheduleFormSheet.swift @@ -26,6 +26,7 @@ struct ScheduleFormSheet: View { @State private var isShowingAppSelect = false @State private var error: Error? @State private var showDeleteConfirmation = false + @State private var inserted: BlockSchedule? private var isNew: Bool { existing == nil @@ -275,10 +276,9 @@ struct ScheduleFormSheet: View { private func deleteSchedule() { guard let existing else { return } - scheduleService.remove(existing, screenTimeService: screenTimeService) - modelContext.delete(existing) let remaining = allSchedules.filter { $0.id != existing.id } - scheduleService.syncAllToSharedStore(remaining) + scheduleService.remove(existing, remaining: remaining, screenTimeService: screenTimeService) + modelContext.delete(existing) dismiss() } @@ -289,18 +289,18 @@ struct ScheduleFormSheet: View { let durationMinutes = computedDurationMinutes let schedule: BlockSchedule - if let existing { - existing.name = trimmed - existing.selection = selection - existing.startHour = components.hour ?? 0 - existing.startMinute = components.minute ?? 0 - existing.durationMinutes = durationMinutes - existing.weekdays = selectedWeekdays - existing.shouldBlock = shouldBlock - existing.isTimed = isTimed - existing.isEnabled = isEnabled - existing.customDomains = storedCustomDomains - schedule = existing + if let target = existing ?? inserted { + target.name = trimmed + target.selection = selection + target.startHour = components.hour ?? 0 + target.startMinute = components.minute ?? 0 + target.durationMinutes = durationMinutes + target.weekdays = selectedWeekdays + target.shouldBlock = shouldBlock + target.isTimed = isTimed + target.isEnabled = isEnabled + target.customDomains = storedCustomDomains + schedule = target } else { let nextIndex = SortIndexing.nextIndex(after: allSchedules, sortIndex: \.sortIndex) schedule = BlockSchedule( @@ -317,12 +317,13 @@ struct ScheduleFormSheet: View { customDomains: storedCustomDomains ) modelContext.insert(schedule) + inserted = schedule } do { try scheduleService.syncAndPersist( schedule, - allSchedules: isNew ? allSchedules + [schedule] : allSchedules, + allSchedules: allSchedules.contains { $0.id == schedule.id } ? allSchedules : allSchedules + [schedule], screenTimeService: screenTimeService ) dismiss() diff --git a/Normal/Views/Settings/CallbackLogView.swift b/Normal/Views/Settings/CallbackLogView.swift new file mode 100644 index 0000000..855c221 --- /dev/null +++ b/Normal/Views/Settings/CallbackLogView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import UIKit + +struct CallbackLogView: View { + @State private var lines: [String] = [] + + var body: some View { + ScrollView { + Text(lines.reversed().joined(separator: "\n")) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle("Callback Log (\(lines.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button("Copy") { + UIPasteboard.general.string = lines.joined(separator: "\n") + } + Button("Clear", role: .destructive) { + CallbackLog.clear() + lines = [] + } + } + } + .refreshable { lines = CallbackLog.read() } + .onAppear { lines = CallbackLog.read() } + } +} diff --git a/Normal/Views/Settings/GeneralSettingsView.swift b/Normal/Views/Settings/GeneralSettingsView.swift index 36a6d6a..55b104a 100644 --- a/Normal/Views/Settings/GeneralSettingsView.swift +++ b/Normal/Views/Settings/GeneralSettingsView.swift @@ -17,6 +17,11 @@ struct GeneralSettingsView: View { customDomainsSection appearanceSection aboutSection + #if DEBUG + Section("Debug") { + NavigationLink("Callback Log") { CallbackLogView() } + } + #endif } } diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index f421a1f..8f50b89 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -9,50 +9,57 @@ final class NormalMonitor: DeviceActivityMonitor { private let limitStore = ManagedSettingsStore(named: .dailyLimits) private static let thresholdGraceSeconds: TimeInterval = 10 - private static let scheduleStartToleranceSeconds: TimeInterval = 60 override func intervalDidStart(for activity: DeviceActivityName) { - reblockExpiredUnblocks() - let name = activity.rawValue - if name == SharedConstants.usageLimitsActivityName { + if activity.rawValue == SharedConstants.usageLimitsActivityName { sharedStore.resetUsageDayIfStale() sharedStore.saveUsageLimitsIntervalStart(.now) limitStore.syncDailyLimits(from: sharedStore) - } else if name.hasPrefix("schedule_") { - handleScheduleIntervalStart(activityName: name) } + reconcile("intervalDidStart", activity: activity.rawValue) } override func intervalDidEnd(for activity: DeviceActivityName) { - reblockExpiredUnblocks() - let name = activity.rawValue - if name.hasPrefix("timedUnblock_") { - handleTimedUnblockExpired(activityName: name) - } else if name.hasPrefix("schedule_") { - handleScheduleIntervalEnd(activityName: name) - } + reconcile("intervalDidEnd", activity: activity.rawValue) } override func eventDidReachThreshold( _ event: DeviceActivityEvent.Name, activity _: DeviceActivityName ) { - reblockExpiredUnblocks() - guard let limit = credibleUsageLimit(eventName: event.rawValue, minutes: \.minutesPerDay) else { return } - sharedStore.recordUsageState(.reached, for: limit.id) - limitStore.syncDailyLimits(from: sharedStore) + if let limit = credibleUsageLimit(eventName: event.rawValue, minutes: \.minutesPerDay) { + sharedStore.recordUsageState(.reached, for: limit.id) + limitStore.syncDailyLimits(from: sharedStore) + } + reconcile("eventDidReachThreshold", activity: event.rawValue) } override func eventWillReachThresholdWarning( _ event: DeviceActivityEvent.Name, activity _: DeviceActivityName ) { - reblockExpiredUnblocks() - guard let limit = credibleUsageLimit( + if let limit = credibleUsageLimit( eventName: event.rawValue, minutes: { $0.minutesPerDay - SharedConstants.usageLimitWarningMinutes } + ) { + sharedStore.recordUsageState(.warning, for: limit.id) + } + reconcile("eventWillReachThresholdWarning", activity: event.rawValue) + } + + private func reconcile(_ callback: String, activity: String) { + let now = Date.now + let before = store.currentShields() + guard let plan = ShieldReconciler.reconcile( + sharedStore: sharedStore, + shields: store, + monitoredActivities: Set(DeviceActivityCenter().activities.map(\.rawValue)), + at: now ) else { return } - sharedStore.recordUsageState(.warning, for: limit.id) + CallbackLog.record( + "monitor \(callback) \(label(for: activity)): " + + "[shields \(summary(before)) -> \(summary(plan))]" + ) } private func credibleUsageLimit( @@ -73,104 +80,13 @@ final class NormalMonitor: DeviceActivityMonitor { return limit } - private func reblockExpiredUnblocks() { - let now = Date.now - for dto in sharedStore.loadTimedUnblocks() where dto.endDate <= now { - reblock(dto) - } - } - - private func handleTimedUnblockExpired(activityName: String) { - guard let dto = sharedStore.findTimedUnblock(activityName: activityName) else { return } - reblock(dto) - } - - private func reblock(_ dto: TimedUnblockDTO) { - guard let selection = try? FamilyActivitySelection.fromData(dto.selectionData) else { return } - - let domains = gatedDomains(dto.customDomains) - let allowing = activeSchedules(shouldBlock: false) - if dto.isGroupUnblock { - guard !sharedStore.isMainTimedUnblockActive() else { - sharedStore.removeTimedUnblock(id: dto.id) - return - } - store.unionShields(with: allowing.applied(to: selection)) - store.unionFilterDomains(allowing.applied(to: domains)) - } else { - store.replaceShields(with: allowing.applied(to: selection)) - store.replaceFilterDomains(allowing.applied(to: domains)) - if dto.blockAllPreventsAppDelete == true { - store.application.denyAppRemoval = true - } - } - sharedStore.removeTimedUnblock(id: dto.id) - } - - private func handleScheduleIntervalStart(activityName: String) { - guard let schedule = findSchedule(activityName: activityName), - let windowStart = schedule.activeWindowStart( - at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds) - ), - let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) - else { return } - - guard sharedStore.resolveScheduleStart(windowStart: windowStart) == .apply else { return } - - let domains = gatedDomains(schedule.customDomains) - if schedule.shouldBlock { - let exclusion = activeGroupUnblockExclusion() - store.unionShields(with: exclusion.applied(to: selection)) - store.unionFilterDomains(exclusion.applied(to: domains)) - } else { - store.subtractShields(with: selection) - store.subtractFilterDomains(domains) - } - } - - private func handleScheduleIntervalEnd(activityName: String) { - guard let schedule = findSchedule(activityName: activityName), - schedule.isTimed, - schedule.endApplies(on: .now), - let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) - else { return } - - let domains = gatedDomains(schedule.customDomains) - let stillActive = activeSchedules(shouldBlock: schedule.shouldBlock, excluding: schedule.id) - if schedule.shouldBlock { - store.subtractShields(with: stillActive.applied(to: selection)) - store.subtractFilterDomains(stillActive.applied(to: domains)) - } else { - guard !sharedStore.isUnblockAllInEffect() else { return } - let exclusion = activeGroupUnblockExclusion().union(stillActive) - store.unionShields(with: exclusion.applied(to: selection)) - store.unionFilterDomains(exclusion.applied(to: domains)) - } - } - - private func activeGroupUnblockExclusion() -> ShieldExclusion { - .groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now) - } - - private func activeSchedules(shouldBlock: Bool, excluding excludedID: UUID? = nil) -> ShieldExclusion { - let monitored = Set(DeviceActivityCenter().activities.map(\.rawValue)) - return .activeSchedules( - sharedStore.loadSchedules(), - shouldBlock: shouldBlock, - at: .now, - excluding: excludedID - ) { schedule in - monitored.contains(SharedConstants.scheduleActivityName(for: schedule.id)) - } - } - - private func gatedDomains(_ domains: [String]) -> [String] { - sharedStore.isCustomDomainsEnabled() ? domains : [] + private func summary(_ plan: ShieldPlan) -> String { + "\(plan.applications.count) apps, \(plan.categories.count) cats" } - private func findSchedule(activityName: String) -> ScheduleDTO? { - sharedStore.loadSchedules().first { dto in - SharedConstants.scheduleActivityName(for: dto.id) == activityName - } + private func label(for activity: String) -> String { + sharedStore.loadSchedules() + .first { SharedConstants.scheduleActivityName(for: $0.id) == activity } + .map { "\"\($0.name)\"" } ?? activity } } diff --git a/NormalTests/Services/AppBlockIntentPerformerTests.swift b/NormalTests/Services/AppBlockIntentPerformerTests.swift index 82d2b8f..e9361c8 100644 --- a/NormalTests/Services/AppBlockIntentPerformerTests.swift +++ b/NormalTests/Services/AppBlockIntentPerformerTests.swift @@ -20,7 +20,6 @@ struct AppBlockIntentPerformerTests { container: container, screenTimeService: screenTime, timedUnblockService: timedUnblock, - scheduleService: ScheduleService(activityCenter: activity, sharedStore: store), syncsExternalSurfaces: false ) } @@ -55,8 +54,8 @@ struct AppBlockIntentPerformerTests { let outcome = try performer.blockAll() - #expect(screenTime.applyShieldOnAllCalled) - #expect(screenTime.applyShieldOnAllCustomDomains == ["example.com"]) + #expect(screenTime.blockAllCalled) + #expect(screenTime.blockAllCustomDomains == ["example.com"]) #expect(!outcome.wasAlreadyBlocked) } @@ -90,7 +89,7 @@ struct AppBlockIntentPerformerTests { #expect(throws: BlockIntentError.noAppsSelected) { try performer.blockAll() } - #expect(!screenTime.applyShieldOnAllCalled) + #expect(!screenTime.blockAllCalled) } @Test func blockAllRequiresCompletedSetup() throws { @@ -108,7 +107,7 @@ struct AppBlockIntentPerformerTests { #expect(throws: BlockIntentError.screenTimeNotAuthorized) { try performer.blockAll() } - #expect(!screenTime.applyShieldOnAllCalled) + #expect(!screenTime.blockAllCalled) } @Test func blockAllRequiresGlobalKey() throws { @@ -133,8 +132,8 @@ struct AppBlockIntentPerformerTests { let outcome = try performer.blockGroup(id: group.id) #expect(outcome == BlockOutcome(name: "Social", wasAlreadyBlocked: false)) - #expect(screenTime.addToShieldsCalled) - #expect(screenTime.addToShieldsCustomDomains == ["x.com"]) + #expect(screenTime.blockCalled) + #expect(screenTime.blockCustomDomains == ["x.com"]) } @Test func blockGroupThrowsForDeletedGroup() throws { @@ -143,7 +142,7 @@ struct AppBlockIntentPerformerTests { #expect(throws: BlockIntentError.groupNotFound) { try performer.blockGroup(id: UUID()) } - #expect(!screenTime.addToShieldsCalled) + #expect(!screenTime.blockCalled) } @Test func blockGroupEndsTimedUnblock() throws { diff --git a/NormalTests/Services/BlockActionsTests.swift b/NormalTests/Services/BlockActionsTests.swift index e65b465..471e80e 100644 --- a/NormalTests/Services/BlockActionsTests.swift +++ b/NormalTests/Services/BlockActionsTests.swift @@ -34,13 +34,12 @@ struct BlockActionsTests { mainSelection: main, settings: makeSettings(customDomains: false, preventsAppDelete: false), screenTimeService: screenTime, - timedUnblockService: makeTimedUnblock(), - scheduleService: makeSchedule() + timedUnblockService: makeTimedUnblock() ) - #expect(screenTime.applyShieldOnAllCalled) - #expect(screenTime.applyShieldOnAllCustomDomains == []) - #expect(screenTime.applyShieldOnAllBlockAllPreventsAppDelete == false) + #expect(screenTime.blockAllCalled) + #expect(screenTime.blockAllCustomDomains == []) + #expect(screenTime.blockAllPreventsAppDelete == false) } @Test func blockAllIncludesDomainsAndPreventsDeleteWhenEnabled() { @@ -50,15 +49,14 @@ struct BlockActionsTests { mainSelection: main, settings: makeSettings(customDomains: true, preventsAppDelete: true), screenTimeService: screenTime, - timedUnblockService: makeTimedUnblock(), - scheduleService: makeSchedule() + timedUnblockService: makeTimedUnblock() ) - #expect(screenTime.applyShieldOnAllCustomDomains == ["example.com"]) - #expect(screenTime.applyShieldOnAllBlockAllPreventsAppDelete == true) + #expect(screenTime.blockAllCustomDomains == ["example.com"]) + #expect(screenTime.blockAllPreventsAppDelete == true) } - @Test func blockAllEndsTimedUnblocksAndClearsScheduleOverride() throws { + @Test func blockAllEndsTimedUnblocks() throws { let timedUnblock = makeTimedUnblock() let groupID = UUID() try timedUnblock.startGroup( @@ -67,19 +65,15 @@ struct BlockActionsTests { selection: FamilyActivitySelection(), screenTimeService: screenTime ) - store.scheduleOverrideActive = true - BlockActions.blockAll( mainSelection: SelectedApps(selection: FamilyActivitySelection()), settings: makeSettings(), screenTimeService: screenTime, - timedUnblockService: timedUnblock, - scheduleService: makeSchedule() + timedUnblockService: timedUnblock ) #expect(timedUnblock.activeUnblocks.isEmpty) #expect(store.timedUnblocks.isEmpty) - #expect(!store.scheduleOverrideActive) } // MARK: - blockGroup @@ -94,8 +88,8 @@ struct BlockActionsTests { timedUnblockService: makeTimedUnblock() ) - #expect(screenTime.addToShieldsCalled) - #expect(screenTime.addToShieldsCustomDomains == ["x.com"]) + #expect(screenTime.blockCalled) + #expect(screenTime.blockCustomDomains == ["x.com"]) #expect(activity.stopCalls.isEmpty) } @@ -117,7 +111,7 @@ struct BlockActionsTests { ) #expect(!timedUnblock.isGroupUnblockActive(groupId: group.id)) - #expect(screenTime.addToShieldsCalled) + #expect(screenTime.blockCalled) let stopped = activity.stopCalls.flatMap { $0 }.map(\.rawValue) #expect(stopped.contains(SharedConstants.groupTimedUnblockActivityName(for: group.id))) } @@ -175,11 +169,11 @@ struct BlockActionsTests { let foreground = FakeScreenTimeService() timedUnblock.reconcile(screenTimeService: foreground) - #expect(foreground.applyShieldOnAllCalled == false) - #expect(foreground.addToShieldsCalled == false) + #expect(foreground.blockAllCalled == false) + #expect(foreground.blockCalled == false) } - @Test func emergencyUnblockDisablesSchedulesAndClearsTheOverride() { + @Test func emergencyUnblockDisablesSchedules() { let schedule = BlockSchedule( name: "Work", selection: FamilyActivitySelection(), @@ -190,12 +184,10 @@ struct BlockActionsTests { isTimed: true, isEnabled: true ) - store.scheduleOverrideActive = true - emergencyUnblock(schedules: [schedule]) #expect(schedule.isEnabled == false) - #expect(store.scheduleOverrideActive == false) + #expect(store.schedules.first?.isEnabled == false) } @Test func emergencyUnblockLiftsSpentMaxDailyLimits() { diff --git a/NormalTests/Services/BlockingMigrationTests.swift b/NormalTests/Services/BlockingMigrationTests.swift new file mode 100644 index 0000000..6ffd227 --- /dev/null +++ b/NormalTests/Services/BlockingMigrationTests.swift @@ -0,0 +1,398 @@ +import FamilyControls +import Foundation +import ManagedSettings +@testable import Normal +import Testing + +@MainActor +struct BlockingMigrationTests { + private func legacyShields(_ h: BlockingHarness, apps: [UInt8], domains: [String] = [], deny: Bool = false) { + var plan = ShieldPlan() + plan.applications = Set(apps.map(TokenFixtures.app)) + plan.customDomains = Set(domains) + plan.denyAppRemoval = deny + h.shields.apply(plan) + } + + private func legacySchedule( + _ h: BlockingHarness, + apps: [UInt8], + from start: (Int, Int), + minutes: Int, + block: Bool, + timed: Bool = true, + monitored: Bool = true + ) throws -> ScheduleDTO { + let dto = try ScheduleDTO( + id: UUID(), + name: "Legacy", + selectionData: TokenFixtures.selection(apps: apps).toData(), + startHour: start.0, + startMinute: start.1, + durationMinutes: minutes, + weekdays: Set(1 ... 7), + shouldBlock: block, + isTimed: timed + ) + h.store.schedules.append(dto) + if monitored { + h.activity.monitoredActivityNames.insert(SharedConstants.scheduleActivityName(for: dto.id)) + } + return dto + } + + private func legacyTimedUnblock( + _ h: BlockingHarness, + apps: [UInt8], + group: Bool, + endDate: Date, + preventsAppDelete: Bool? = nil + ) throws { + try h.store.timedUnblocks.append(TimedUnblockDTO( + id: group ? UUID().uuidString : TimedUnblockService.mainID, + selectionData: TokenFixtures.selection(apps: apps).toData(), + endDate: endDate, + activityName: group ? "timedUnblock_group" : SharedConstants.mainTimedUnblockActivityName, + isGroupUnblock: group, + blockAllPreventsAppDelete: preventsAppDelete + )) + } + + @Test func existingShieldsCarryOverUnchangedWhenNoWindowIsOpen() { + let h = BlockingHarness() + legacyShields(h, apps: [1, 2], deny: true) + + h.wake(at: h.at(8)) + + #expect(h.blocked == h.apps(1, 2)) + #expect(h.appDeletionBlocked) + #expect(h.store.manualBlockState != nil) + } + + @Test func migrationRunsOnceAndThenRepairsStaleWrites() { + let h = BlockingHarness() + legacyShields(h, apps: [1, 2]) + h.wake(at: h.at(8)) + + legacyShields(h, apps: [1]) + h.wake(at: h.at(8, 5)) + + #expect(h.blocked == h.apps(1, 2)) + } + + @Test func stuckUnblockScheduleAppIsRepaired() throws { + let h = BlockingHarness() + legacyShields(h, apps: [2, 3]) + _ = try legacySchedule(h, apps: [1], from: (12, 0), minutes: 180, block: false) + + h.wake(at: h.at(17, 25)) + + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func unblockWindowOpenAtMigrationStaysOpenThenReblocks() throws { + let h = BlockingHarness() + legacyShields(h, apps: [2, 3]) + _ = try legacySchedule(h, apps: [1], from: (12, 0), minutes: 180, block: false) + + h.wake(at: h.at(13)) + #expect(h.blocked == h.apps(2, 3)) + h.wake(at: h.at(15)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func blockWindowOpenAtMigrationFailsClosed() throws { + let h = BlockingHarness() + legacyShields(h, apps: [1]) + _ = try legacySchedule(h, apps: [1], from: (9, 0), minutes: 480, block: true) + + h.wake(at: h.at(10)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(17, 30)) + #expect(h.blocked == h.apps(1)) + + h.unblockGroupPermanently(h.group([1])) + #expect(h.blocked.isEmpty) + } + + @Test func permanentUnblockAllOverrideIsHonoured() throws { + let h = BlockingHarness() + _ = try legacySchedule(h, apps: [1], from: (9, 0), minutes: 480, block: true) + _ = try legacySchedule(h, apps: [2], from: (12, 0), minutes: 60, block: false) + h.store.scheduleOverrideActive = true + h.store.scheduleOverrideSince = h.at(10) + + h.wake(at: h.at(11)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(13, 30)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(9, 30, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func legacyOverrideWithoutTimestampSuppressesTheRunningWindow() throws { + let h = BlockingHarness() + _ = try legacySchedule(h, apps: [1], from: (9, 0), minutes: 480, block: true) + h.store.scheduleOverrideActive = true + + h.wake(at: h.at(11)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(9, 30, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func mainTimedUnblockWithoutStartDateExpiresIntoTheMainList() throws { + let h = BlockingHarness() + legacyShields(h, apps: [], deny: false) + try legacyTimedUnblock(h, apps: [1, 2, 3], group: false, endDate: h.at(10, 30), preventsAppDelete: true) + + h.wake(at: h.at(10)) + #expect(h.blocked.isEmpty) + #expect(!h.appDeletionBlocked) + + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 3)) + #expect(h.appDeletionBlocked) + } + + @Test func groupTimedUnblockAtMigrationReblocksAtExpiry() throws { + let h = BlockingHarness() + legacyShields(h, apps: [2, 3]) + try legacyTimedUnblock(h, apps: [1], group: true, endDate: h.at(10, 30)) + + h.wake(at: h.at(10)) + #expect(h.blocked == h.apps(2, 3)) + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func schedulesWithoutEnabledFlagFallBackToMonitoredActivities() throws { + let h = BlockingHarness() + legacyShields(h, apps: [3]) + _ = try legacySchedule(h, apps: [1], from: (12, 0), minutes: 60, block: false, monitored: true) + _ = try legacySchedule(h, apps: [2], from: (12, 0), minutes: 60, block: false, monitored: false) + + h.wake(at: h.at(8)) + + #expect(h.blocked == h.apps(1, 3)) + } + + @Test func firstAppLaunchMigratesBeforeReRegisteringSchedules() throws { + let h = BlockingHarness() + legacyShields(h, apps: [2, 3]) + let mail = BlockSchedule( + name: "Mail", selection: TokenFixtures.selection(apps: [1]), + startHour: 12, startMinute: 0, durationMinutes: 180, weekdays: Set(1 ... 7), + shouldBlock: false, isTimed: true, isEnabled: true + ) + h.store.schedules = [try #require(mail.toDTO())].map { + ScheduleDTO( + id: $0.id, name: $0.name, selectionData: $0.selectionData, + startHour: $0.startHour, startMinute: $0.startMinute, durationMinutes: $0.durationMinutes, + weekdays: $0.weekdays, shouldBlock: $0.shouldBlock, isTimed: $0.isTimed + ) + } + h.activity.monitoredActivityNames.insert(SharedConstants.scheduleActivityName(for: mail.id)) + + h.clock.now = h.at(13) + h.timedUnblock.reconcile(screenTimeService: h.screenTime) + h.scheduleService.registerAll([mail], screenTimeService: h.screenTime) + + #expect(h.blocked == h.apps(2, 3)) + h.wake(at: h.at(15)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func legacyCustomDomainFilterCarriesOver() { + let h = BlockingHarness(customDomains: ["b.com"]) + legacyShields(h, apps: [1], domains: ["b.com"]) + + h.wake(at: h.at(8)) + + #expect(h.blocked == h.apps(1)) + #expect(h.blockedDomains == ["b.com"]) + } + + @Test func manualStatePersistsThroughTheRealSharedStore() throws { + let defaults = try #require(UserDefaults(suiteName: "manual-\(UUID().uuidString)")) + let store = SharedStore(defaults: defaults) + var state = ManualBlockState() + state.blockAll(TokenFixtures.selection(apps: [1, 2]), customDomains: ["A.com"], at: Date(timeIntervalSince1970: 100)) + state.setAppDeletionBlocked(true, at: Date(timeIntervalSince1970: 100)) + + store.saveManualBlockState(state) + + #expect(store.loadManualBlockState() == state) + #expect(store.loadManualBlockState()?.customDomains.blockedTokens == ["a.com"]) + } + + @Test func unreadableManualStateIsTreatedAsMissing() throws { + let defaults = try #require(UserDefaults(suiteName: "manual-\(UUID().uuidString)")) + defaults.set(Data("garbage".utf8), forKey: SharedConstants.DefaultsKey.manualBlockState) + + #expect(SharedStore(defaults: defaults).loadManualBlockState() == nil) + } + + @Test func legacyUntimedUnblockScheduleDoesNotOverrideALaterBlock() throws { + let h = BlockingHarness() + legacyShields(h, apps: [1, 2]) + _ = try legacySchedule(h, apps: [1], from: (6, 0), minutes: 15, block: false, timed: false) + + h.wake(at: h.at(8)) + #expect(h.blocked == h.apps(1, 2)) + + h.wake(at: h.at(6, 1, dayOffset: 1)) + #expect(h.blocked == h.apps(2)) + } + + @Test func legacyUntimedBlockScheduleDoesNotReblockALaterUnblock() throws { + let h = BlockingHarness() + legacyShields(h, apps: []) + _ = try legacySchedule(h, apps: [1], from: (6, 0), minutes: 15, block: true, timed: false) + + h.wake(at: h.at(8)) + #expect(h.blocked.isEmpty) + + h.wake(at: h.at(6, 1, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func appsBlockedAfterALegacyOverrideBeatOlderWindows() throws { + let h = BlockingHarness() + legacyShields(h, apps: [1]) + _ = try legacySchedule(h, apps: [1], from: (9, 0), minutes: 180, block: false) + h.store.scheduleOverrideActive = true + h.store.scheduleOverrideSince = h.at(10) + + h.wake(at: h.at(11)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(9, 30, dayOffset: 1)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(12, 0, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func expiredTimedUnblockThatNeverReblockedIsRestoredOnFirstLaunch() throws { + let h = BlockingHarness() + legacyShields(h, apps: [], deny: false) + try legacyTimedUnblock(h, apps: [1, 2, 3], group: false, endDate: h.at(7), preventsAppDelete: true) + + h.openApp(at: h.at(8)) + + #expect(h.blocked == h.apps(1, 2, 3)) + #expect(h.appDeletionBlocked) + #expect(h.store.timedUnblocks.isEmpty) + } + + @Test func manualRecordDoesNotDependOnDictionaryEncoding() throws { + var state = ManualBlockState() + state.blockAll(TokenFixtures.selection(apps: [1, 2]), customDomains: ["a.com"], at: Date(timeIntervalSince1970: 50)) + let plist = try PropertyListSerialization.propertyList( + from: PropertyListEncoder().encode(state), format: nil + ) as? [String: Any] + let applications = plist?["applications"] as? [String: Any] + + #expect(applications?["entries"] is [[String: Any]]) + } + + @Test func nestedUnblockInsideABlockScheduleIsNotRepairedIntoAPermanentBlock() throws { + let h = BlockingHarness() + legacyShields(h, apps: []) + _ = try legacySchedule(h, apps: [1], from: (9, 0), minutes: 480, block: true) + _ = try legacySchedule(h, apps: [1], from: (12, 0), minutes: 60, block: false) + + h.wake(at: h.at(8)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(10)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(12, 30)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(13, 30)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(18)) + #expect(h.blocked.isEmpty) + } + + @Test func unreadableScheduleSelectionIsSkippedDuringMigration() { + let h = BlockingHarness() + legacyShields(h, apps: [2]) + h.store.schedules.append(ScheduleDTO( + id: UUID(), name: "Corrupt", selectionData: Data("garbage".utf8), + startHour: 12, startMinute: 0, durationMinutes: 60, weekdays: Set(1 ... 7), + shouldBlock: false, isTimed: true, isEnabled: true + )) + + h.wake(at: h.at(12, 30)) + + #expect(h.blocked == h.apps(2)) + } + + @Test func legacyCategoriesCarryOver() { + let h = BlockingHarness() + var plan = ShieldPlan() + plan.categories = [TokenFixtures.category(1)] + plan.webDomains = [TokenFixtures.webDomain(2)] + h.shields.apply(plan) + + h.wake(at: h.at(8)) + + #expect(h.shields.currentShields().categories == [TokenFixtures.category(1)]) + #expect(h.shields.currentShields().webDomains == [TokenFixtures.webDomain(2)]) + } + + private struct Version15ScheduleDTO: Codable { + let id: UUID + let name: String + let selectionData: Data + let startHour: Int + let startMinute: Int + let durationMinutes: Int + let weekdays: Set + let shouldBlock: Bool + let isTimed: Bool + let encodedCustomDomains: [String]? + } + + private func version15Device(overrideFlag: Bool) throws -> (ScreenTimeService, InMemoryShieldStore, SharedStore) { + let defaults = try #require(UserDefaults(suiteName: "v15-\(UUID().uuidString)")) + let mail = try Version15ScheduleDTO( + id: UUID(), name: "Mail", selectionData: TokenFixtures.selection(apps: [1]).toData(), + startHour: 12, startMinute: 0, durationMinutes: 180, weekdays: Set(1 ... 7), + shouldBlock: false, isTimed: true, encodedCustomDomains: nil + ) + defaults.set(try PropertyListEncoder().encode([mail]), forKey: SharedConstants.DefaultsKey.schedules) + if overrideFlag { + defaults.set(true, forKey: SharedConstants.DefaultsKey.scheduleOverride) + } + let activity = FakeDeviceActivityCenter() + activity.monitoredActivityNames.insert(SharedConstants.scheduleActivityName(for: mail.id)) + let shields = InMemoryShieldStore(startBlocked: false) + var stuck = ShieldPlan() + stuck.applications = [TokenFixtures.app(2)] + shields.apply(stuck) + let store = SharedStore(defaults: defaults) + let afterWindow = Calendar.current.date(bySettingHour: 17, minute: 0, second: 0, of: .now.addingTimeInterval(86400))! + let service = ScreenTimeService( + defaults: defaults, shield: shields, sharedStore: store, activityCenter: activity, + clock: { afterWindow } + ) + return (service, shields, store) + } + + @Test func version15DataMigratesThroughTheRealSharedStore() throws { + let (service, shields, store) = try version15Device(overrideFlag: false) + + service.reconcile() + + #expect(shields.currentShields().applications == [TokenFixtures.app(1), TokenFixtures.app(2)]) + #expect(store.loadManualBlockState()?.migratedAt != nil) + } + + @Test func version15PermanentUnblockAllSkipsTheRepairThroughTheRealSharedStore() throws { + let (service, shields, _) = try version15Device(overrideFlag: true) + + service.reconcile() + + #expect(shields.currentShields().applications == [TokenFixtures.app(2)]) + } +} diff --git a/NormalTests/Services/BlockingScenarioTests.swift b/NormalTests/Services/BlockingScenarioTests.swift new file mode 100644 index 0000000..04001e2 --- /dev/null +++ b/NormalTests/Services/BlockingScenarioTests.swift @@ -0,0 +1,525 @@ +import FamilyControls +import Foundation +import ManagedSettings +@testable import Normal +import Testing + +@MainActor +struct BlockingScenarioTests { + @Test func blockAllShieldsTheMainListAndUnblockAllLiftsIt() { + let h = BlockingHarness() + + h.blockAll() + #expect(h.blocked == h.apps(1, 2, 3)) + + h.unblockAllPermanently() + #expect(h.blocked.isEmpty) + } + + @Test func groupsBlockAndUnblockOnlyTheirApps() { + let h = BlockingHarness() + let social = h.group([1, 2]) + + h.blockGroup(social) + #expect(h.blocked == h.apps(1, 2)) + + h.blockAll() + h.unblockGroupPermanently(h.group([2])) + #expect(h.blocked == h.apps(1, 3)) + } + + @Test func manualStateSurvivesRepeatedWakeUps() { + let h = BlockingHarness() + h.blockAll() + h.unblockGroupPermanently(h.group([3])) + + for hour in 9 ... 20 { + h.wake(at: h.at(hour)) + #expect(h.blocked == h.apps(1, 2)) + } + } + + @Test func unblockScheduleOpensDuringWindowAndReblocksAtEnd() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + + h.wake(at: h.at(11, 59)) + #expect(h.blocked == h.apps(1, 2, 3)) + h.wake(at: h.at(12, 2)) + #expect(h.blocked == h.apps(2, 3)) + h.wake(at: h.at(15, 0)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func regressionTwoSchedulesEndingTogetherBothReblock() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + try h.addSchedule([2], from: (12, 0), minutes: 180, block: false) + + h.wake(at: h.at(12, 2)) + #expect(h.blocked == h.apps(3)) + + h.wake(at: h.at(15, 0)) + h.wake(at: h.at(15, 0)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func regressionMissedEndIsRepairedByTheNextWakeUp() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + h.wake(at: h.at(12, 14)) + #expect(h.blocked == h.apps(2, 3)) + + h.passTime(to: h.at(15, 0)) + #expect(h.blocked == h.apps(2, 3)) + + h.openApp(at: h.at(17, 25)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func regressionMissedEndIsRepairedByAnUnrelatedCallback() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + try h.addSchedule([2], from: (12, 0), minutes: 180, block: false) + h.wake(at: h.at(12, 14)) + + h.passTime(to: h.at(15, 0)) + h.wake(at: h.at(15, 30)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func regressionOpeningTheAppMidWindowKeepsTheWindowState() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + let work = try h.addSchedule([2], from: (9, 0), minutes: 480, block: true) + h.unblockGroupPermanently(h.group([2])) + + h.openApp(at: h.at(13)) + h.openApp(at: h.at(13, 1)) + #expect(h.blocked == h.apps(2, 3)) + #expect(h.activity.monitoredActivityNames.contains(SharedConstants.scheduleActivityName(for: work.id))) + } + + @Test func openingTheAppMidWindowNoLongerRestartsSchedules() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + h.openApp(at: h.at(12, 30)) + h.activity.stopCalls.removeAll() + h.activity.startCalls.removeAll() + + h.openApp(at: h.at(13)) + h.openApp(at: h.at(14)) + + #expect(h.activity.stopCalls.isEmpty) + #expect(h.activity.startCalls.isEmpty) + #expect(h.blocked == h.apps(2, 3)) + } + + @Test func blockScheduleAddsAppsOnlyDuringItsWindow() throws { + let h = BlockingHarness() + try h.addSchedule([1], from: (9, 0), minutes: 60, block: true) + + h.wake(at: h.at(9, 30)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(10, 0)) + #expect(h.blocked.isEmpty) + } + + @Test func overlappingWindowsLatestStartWins() throws { + let h = BlockingHarness(startingAt: 4) + try h.addSchedule([1], from: (5, 0), minutes: 60, block: true) + try h.addSchedule([1], from: (5, 30), minutes: 60, block: false) + + h.wake(at: h.at(5, 15)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(5, 45)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(6, 15)) + #expect(h.blocked.isEmpty) + } + + @Test func nestedUnblockWindowIsAnExceptionInsideABlockWindow() throws { + let h = BlockingHarness() + try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + try h.addSchedule([1], from: (12, 0), minutes: 60, block: false) + + h.wake(at: h.at(10)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(12, 30)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(13, 30)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(17, 30)) + #expect(h.blocked.isEmpty) + } + + @Test func blockAllDuringAnUnblockWindowWinsUntilTheNextWindowStart() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + h.wake(at: h.at(12, 30)) + #expect(h.blocked == h.apps(2, 3)) + + h.passTime(to: h.at(13)) + h.blockAll() + #expect(h.blocked == h.apps(1, 2, 3)) + h.wake(at: h.at(15, 30)) + #expect(h.blocked == h.apps(1, 2, 3)) + h.wake(at: h.at(12, 30, dayOffset: 1)) + #expect(h.blocked == h.apps(2, 3)) + } + + @Test func permanentUnblockAllDuringABlockWindowHoldsUntilTheNextStart() throws { + let h = BlockingHarness() + try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + h.wake(at: h.at(9, 30)) + #expect(h.blocked == h.apps(1)) + + h.passTime(to: h.at(10)) + h.unblockAllPermanently() + h.wake(at: h.at(11)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(9, 30, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func creatingABlockScheduleMidWindowBlocksImmediately() throws { + let h = BlockingHarness() + h.passTime(to: h.at(10)) + h.unblockAllPermanently() + + h.passTime(to: h.at(13)) + try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + #expect(h.blocked == h.apps(1)) + } + + @Test func disablingAnUnblockScheduleMidWindowReblocks() throws { + let h = BlockingHarness() + h.blockAll() + let lunch = try h.addSchedule([1], from: (12, 0), minutes: 60, block: false) + h.wake(at: h.at(12, 15)) + #expect(h.blocked == h.apps(2, 3)) + + try h.toggle(lunch) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func deletingABlockScheduleMidWindowKeepsManualBlocks() throws { + let h = BlockingHarness() + h.blockGroup(h.group([1])) + let work = try h.addSchedule([1, 4], from: (9, 0), minutes: 480, block: true) + h.wake(at: h.at(10)) + #expect(h.blocked == h.apps(1, 4)) + + h.delete(work) + #expect(h.blocked == h.apps(1)) + } + + @Test func untimedScheduleHoldsUntilAManualAction() throws { + let h = BlockingHarness() + try h.addSchedule([1], from: (9, 0), minutes: 15, block: true, timed: false) + #expect(h.blocked.isEmpty) + + h.wake(at: h.at(9, 1)) + #expect(h.blocked == h.apps(1)) + h.wake(at: h.at(20)) + #expect(h.blocked == h.apps(1)) + + h.unblockGroupPermanently(h.group([1])) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(9, 1, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func editingAnUntimedScheduleAfterItFiredKeepsItsBlock() throws { + let h = BlockingHarness() + let permanent = try h.addSchedule([1], from: (9, 0), minutes: 15, block: true, timed: false) + h.wake(at: h.at(9, 1)) + + h.passTime(to: h.at(13)) + permanent.name = "Renamed" + try h.scheduleService.syncAndPersist(permanent, allSchedules: h.schedules, screenTimeService: h.screenTime) + #expect(h.blocked == h.apps(1)) + } + + @Test func weekdayFilterIsRespected() throws { + let h = BlockingHarness() + let weekday = Calendar.current.component(.weekday, from: h.day) + let otherDays = Set(1 ... 7).subtracting([weekday]) + try h.addSchedule([1], from: (9, 0), minutes: 60, block: true, weekdays: otherDays) + + h.wake(at: h.at(9, 30)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(9, 30, dayOffset: 1)) + #expect(h.blocked == h.apps(1)) + } + + @Test func timedUnblockAllExpiresBackToThePreviousState() throws { + let h = BlockingHarness() + h.blockAll() + h.unblockGroupPermanently(h.group([3])) + + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + #expect(h.blocked.isEmpty) + + h.wake(at: h.at(10, 29)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2)) + } + + @Test func blockingAGroupDuringTimedUnblockAllAppliesNowAndAfter() throws { + let h = BlockingHarness() + h.blockAll() + h.unblockGroupPermanently(h.group([3])) + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + + h.passTime(to: h.at(10, 10)) + h.blockGroup(h.group([3])) + #expect(h.blocked == h.apps(3)) + + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func blockAllNowEndsTheTimedUnblockAndBlocksEverything() throws { + let h = BlockingHarness() + h.blockGroup(h.group([1])) + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + + h.passTime(to: h.at(10, 5)) + h.blockAllNow() + #expect(h.blocked == h.apps(1, 2, 3)) + #expect(h.store.timedUnblocks.isEmpty) + h.wake(at: h.at(10, 45)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func timedUnblockAllSuppressesBlockWindowsUntilItExpires() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([4], from: (10, 20), minutes: 40, block: true) + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + + h.wake(at: h.at(10, 25)) + #expect(h.blocked.isEmpty) + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 3, 4)) + } + + @Test func groupTimedUnblockOpensTheGroupAndReblocksAtExpiry() throws { + let h = BlockingHarness() + h.blockAll() + let social = h.group([1, 2]) + h.passTime(to: h.at(10)) + try h.unblockGroupTimed(social, .fifteenMinutes) + #expect(h.blocked == h.apps(3)) + + h.wake(at: h.at(10, 15)) + #expect(h.blocked == h.apps(1, 2, 3)) + } + + @Test func blockingAGroupDuringItsTimedUnblockCancelsIt() throws { + let h = BlockingHarness() + h.blockAll() + let social = h.group([1, 2]) + h.passTime(to: h.at(10)) + try h.unblockGroupTimed(social, .oneHour) + + h.passTime(to: h.at(10, 5)) + h.blockGroup(social) + #expect(h.blocked == h.apps(1, 2, 3)) + #expect(!h.timedUnblock.isGroupUnblockActive(groupId: social.id)) + } + + @Test func groupTimedUnblockBeatsABlockWindow() throws { + let h = BlockingHarness() + let social = h.group([1]) + try h.addSchedule([1, 2], from: (10, 0), minutes: 60, block: true) + h.passTime(to: h.at(10, 5)) + try h.unblockGroupTimed(social, .fifteenMinutes) + #expect(h.blocked == h.apps(2)) + + h.wake(at: h.at(10, 20)) + #expect(h.blocked == h.apps(1, 2)) + } + + @Test func emergencyUnblockClearsEverythingAndKeepsSchedulesOff() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([4], from: (9, 0), minutes: 60, block: true) + h.passTime(to: h.at(8, 30)) + try h.unblockGroupTimed(h.group([1]), .oneHour) + + h.emergencyUnblock() + #expect(h.blocked.isEmpty) + #expect(h.schedules.allSatisfy { !$0.isEnabled }) + + h.wake(at: h.at(9, 30)) + #expect(h.blocked.isEmpty) + } + + @Test func preventAppDeleteFollowsBlockAllAndTimedUnblock() throws { + let h = BlockingHarness() + h.settings.blockAllPreventsAppDelete = true + + h.blockAll() + #expect(h.appDeletionBlocked) + + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.fifteenMinutes) + #expect(!h.appDeletionBlocked) + + h.wake(at: h.at(10, 15)) + #expect(h.appDeletionBlocked) + + h.unblockAllPermanently() + #expect(!h.appDeletionBlocked) + } + + @Test func appStaysDeletableWheneverNothingIsBlocked() throws { + let h = BlockingHarness() + h.settings.blockAllPreventsAppDelete = true + + h.blockAll() + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.fifteenMinutes) + #expect(h.blocked.isEmpty && !h.appDeletionBlocked) + + h.blockAllNow() + #expect(h.appDeletionBlocked) + + h.unblockAllPermanently() + #expect(h.blocked.isEmpty && !h.appDeletionBlocked) + } + + @Test func customDomainsFollowTheSameRulesAndTheFeatureToggle() throws { + let h = BlockingHarness(customDomains: ["a.com", "b.com"]) + h.blockAll() + try h.addSchedule([], domains: ["a.com"], from: (12, 0), minutes: 60, block: false) + #expect(h.blockedDomains == ["a.com", "b.com"]) + + h.wake(at: h.at(12, 30)) + #expect(h.blockedDomains == ["b.com"]) + + h.store.setCustomDomainsEnabled(false) + h.wake(at: h.at(12, 31)) + #expect(h.blockedDomains.isEmpty) + } + + @Test func everyActionAndWakeUpWritesTheSameResultTwice() throws { + let h = BlockingHarness() + h.blockAll() + try h.addSchedule([1], from: (12, 0), minutes: 180, block: false) + try h.addSchedule([4], from: (13, 0), minutes: 60, block: true) + + for minute in stride(from: 0, through: 24 * 60, by: 7) { + let time = h.at(0).addingTimeInterval(TimeInterval(minute * 60)) + h.wake(at: time) + let first = h.blocked + h.openApp(at: time) + #expect(h.blocked == first) + } + } + + @Test func appSelectEditsDuringTimedUnblockAllApplyAtExpiry() throws { + let h = BlockingHarness() + h.blockAll() + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + + h.passTime(to: h.at(10, 5)) + h.timedUnblock.updateMainSelection(TokenFixtures.selection(apps: [1, 2, 4]), screenTimeService: h.screenTime) + #expect(h.blocked.isEmpty) + + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 4)) + } + + @Test func appSelectEditsKeepAPartialStateDuringTimedUnblockAll() throws { + let h = BlockingHarness() + h.blockAll() + h.unblockGroupPermanently(h.group([3])) + h.passTime(to: h.at(10)) + try h.unblockAllTimed(.thirtyMinutes) + + h.passTime(to: h.at(10, 5)) + h.timedUnblock.updateMainSelection(TokenFixtures.selection(apps: [1, 2, 3, 4]), screenTimeService: h.screenTime) + + h.wake(at: h.at(10, 30)) + #expect(h.blocked == h.apps(1, 2, 4)) + } + + @Test func renamingARunningScheduleDoesNotCancelPermanentUnblockAll() throws { + let h = BlockingHarness() + let work = try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + h.wake(at: h.at(9, 30)) + h.passTime(to: h.at(10)) + h.unblockAllPermanently() + + h.passTime(to: h.at(11)) + work.name = "Renamed" + try h.scheduleService.syncAndPersist(work, allSchedules: h.schedules, screenTimeService: h.screenTime) + #expect(h.blocked.isEmpty) + } + + @Test func changingARunningScheduleAppliesItAgain() throws { + let h = BlockingHarness() + let work = try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + h.passTime(to: h.at(10)) + h.unblockAllPermanently() + + h.passTime(to: h.at(11)) + work.selection = TokenFixtures.selection(apps: [1, 2]) + try h.scheduleService.syncAndPersist(work, allSchedules: h.schedules, screenTimeService: h.screenTime) + #expect(h.blocked == h.apps(1, 2)) + } + + @Test func reEnablingAScheduleAfterEmergencyUnblockAppliesImmediately() throws { + let h = BlockingHarness() + let work = try h.addSchedule([1], from: (9, 0), minutes: 480, block: true) + h.wake(at: h.at(9, 30)) + let heartbeat = SharedConstants.reblockHeartbeatActivityName + + h.passTime(to: h.at(10)) + h.emergencyUnblock() + #expect(h.blocked.isEmpty) + #expect(!h.activity.monitoredActivityNames.contains(heartbeat)) + + h.passTime(to: h.at(11)) + try h.toggle(work) + #expect(h.blocked == h.apps(1)) + #expect(h.activity.monitoredActivityNames.contains(heartbeat)) + } + + @Test func categoriesAndWebDomainsFollowTheSameRules() throws { + let h = BlockingHarness() + h.screenTime.blockAll( + selection: TokenFixtures.selection(apps: [], categories: [1, 2], webDomains: [3]), + blockAllPreventsAppDelete: false + ) + let lunch = BlockSchedule( + name: "Lunch", selection: TokenFixtures.selection(apps: [], categories: [1], webDomains: [3]), + startHour: 12, startMinute: 0, durationMinutes: 60, weekdays: Set(1 ... 7), + shouldBlock: false, isTimed: true, isEnabled: true + ) + try h.scheduleService.syncAndPersist(lunch, allSchedules: [lunch], screenTimeService: h.screenTime) + + h.wake(at: h.at(12, 30)) + #expect(h.shields.currentShields().categories == [TokenFixtures.category(2)]) + #expect(h.shields.currentShields().webDomains.isEmpty) + h.wake(at: h.at(13)) + #expect(h.shields.currentShields().categories == [TokenFixtures.category(1), TokenFixtures.category(2)]) + #expect(h.shields.currentShields().webDomains == [TokenFixtures.webDomain(3)]) + } +} diff --git a/NormalTests/Services/SafetyInvariantTests.swift b/NormalTests/Services/SafetyInvariantTests.swift index f4bbb91..00d5ce9 100644 --- a/NormalTests/Services/SafetyInvariantTests.swift +++ b/NormalTests/Services/SafetyInvariantTests.swift @@ -38,49 +38,12 @@ struct SafetyInvariantTests { } } - private func makeTimedUnblock() -> TimedUnblockService { - TimedUnblockService( - activityCenter: FakeDeviceActivityCenter(), - sharedStore: FakeSharedStore(), - onExpiration: {} - ) - } - - @Test func appStaysDeletableWheneverNothingIsBlocked_consistentFlags() throws { - let spy = StatefulScreenTimeSpy() - let service = makeTimedUnblock() - let selection = FamilyActivitySelection() - - spy.applyShieldOnAll(selection: selection, blockAllPreventsAppDelete: true) - #expect(!spy.invariantViolated) - - try service.startMain( - duration: .fifteenMinutes, - selection: selection, - screenTimeService: spy, - blockAllPreventsAppDelete: true - ) - #expect(!spy.invariantViolated) - #expect(!spy.appDeleteDisabled) - - service.cancelMain( - selection: selection, - screenTimeService: spy, - blockAllPreventsAppDelete: true - ) - #expect(!spy.invariantViolated) - - spy.removeShieldOnAll(blockAllPreventsAppDelete: true) - #expect(!spy.invariantViolated) - #expect(!spy.appDeleteDisabled) - } - @Test func mismatchedPreventDeleteFlagsStrandTheEscapeHatch() { let spy = StatefulScreenTimeSpy() let selection = FamilyActivitySelection() - spy.applyShieldOnAll(selection: selection, blockAllPreventsAppDelete: true) - spy.removeShieldOnAll(blockAllPreventsAppDelete: false) + spy.blockAll(selection: selection, blockAllPreventsAppDelete: true) + spy.unblockAll(blockAllPreventsAppDelete: false) #expect(spy.invariantViolated) } diff --git a/NormalTests/Services/ScheduleServiceTests.swift b/NormalTests/Services/ScheduleServiceTests.swift index c921ecd..b93b691 100644 --- a/NormalTests/Services/ScheduleServiceTests.swift +++ b/NormalTests/Services/ScheduleServiceTests.swift @@ -6,14 +6,20 @@ import Testing @MainActor struct ScheduleServiceTests { - private func makeService() -> ( + private struct TestError: Error {} + + private nonisolated final class Clock { + var now = Date(timeIntervalSinceReferenceDate: 800_000_000) + } + + private func makeService(clock: Clock = Clock()) -> ( service: ScheduleService, activity: FakeDeviceActivityCenter, store: FakeSharedStore ) { let activity = FakeDeviceActivityCenter() let store = FakeSharedStore() - return (ScheduleService(activityCenter: activity, sharedStore: store), activity, store) + return (ScheduleService(activityCenter: activity, sharedStore: store, clock: { clock.now }), activity, store) } private func makeSchedule( @@ -34,76 +40,120 @@ struct ScheduleServiceTests { @Test func enabledScheduleStartsMonitoring() throws { let (service, activity, _) = makeService() - let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(isEnabled: true) - try service.sync(schedule, screenTimeService: screenTime) + try service.sync(makeSchedule(isEnabled: true)) #expect(activity.startCalls.count == 1) #expect(activity.stopCalls.count == 1) } - @Test func disabledBlockingScheduleRemovesFromShields() throws { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(shouldBlock: true, isEnabled: false) - - try service.sync(schedule, screenTimeService: screenTime) - - #expect(screenTime.removeFromShieldsCalled) - } - - @Test func disabledUnblockScheduleDoesNotBlock() throws { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(shouldBlock: false, isEnabled: false) + @Test func disabledScheduleOnlyStopsMonitoring() throws { + let (service, activity, _) = makeService() - try service.sync(schedule, screenTimeService: screenTime) + try service.sync(makeSchedule(isEnabled: false)) - #expect(!screenTime.addToShieldsCalled) + #expect(activity.startCalls.isEmpty) + #expect(activity.stopCalls.count == 1) } - @Test func removeBlockingScheduleRemovesFromShields() { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(shouldBlock: true) - - service.remove(schedule, screenTimeService: screenTime) + @Test func startMonitoringFailureIsThrown() { + let (service, activity, _) = makeService() + activity.startError = TestError() - #expect(screenTime.removeFromShieldsCalled) + #expect(throws: (any Error).self) { try service.sync(makeSchedule()) } } - @Test func removeUnblockingScheduleDoesNotBlock() { - let (service, _, _) = makeService() + @Test func removeStopsMonitoringPersistsRemainingAndReconciles() { + let (service, activity, store) = makeService() let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(shouldBlock: false) + let removed = makeSchedule() + let kept = makeSchedule() - service.remove(schedule, screenTimeService: screenTime) + service.remove(removed, remaining: [kept], screenTimeService: screenTime) - #expect(!screenTime.addToShieldsCalled) + #expect(activity.stopCalls.last == [DeviceActivityName(SharedConstants.scheduleActivityName(for: removed.id))]) + #expect(store.schedules.map(\.id) == [kept.id]) + #expect(screenTime.reconcileCallCount == 1) } - @Test func toggleEnabledFlipsState() throws { - let (service, _, _) = makeService() + @Test func toggleEnabledFlipsStatePersistsAndReconciles() throws { + let (service, _, store) = makeService() let screenTime = FakeScreenTimeService() let schedule = makeSchedule(isEnabled: true) - try service.toggleEnabled(schedule, screenTimeService: screenTime) + try service.toggleEnabled(schedule, allSchedules: [schedule], screenTimeService: screenTime) #expect(!schedule.isEnabled) + #expect(store.schedules.first?.isEnabled == false) - try service.toggleEnabled(schedule, screenTimeService: screenTime) + try service.toggleEnabled(schedule, allSchedules: [schedule], screenTimeService: screenTime) #expect(schedule.isEnabled) + #expect(store.schedules.first?.isEnabled == true) + #expect(screenTime.reconcileCallCount == 2) + } + + @Test func toggleEnabledPersistsEvenWhenMonitoringFails() { + let (service, activity, store) = makeService() + activity.startError = TestError() + let schedule = makeSchedule(isEnabled: false) + + #expect(throws: (any Error).self) { + try service.toggleEnabled(schedule, allSchedules: [schedule], screenTimeService: FakeScreenTimeService()) + } + #expect(store.schedules.first?.isEnabled == true) } @Test func syncAllToSharedStorePersistsDTOs() { let (service, _, store) = makeService() - let schedule = makeSchedule() - service.syncAllToSharedStore([schedule]) + service.syncAllToSharedStore([makeSchedule()]) #expect(store.schedules.count == 1) #expect(store.schedules.first?.startHour == 9) + #expect(store.schedules.first?.isEnabled == true) + } + + @Test func savingAnEnabledScheduleStampsActivationAndKeepsOthers() throws { + let clock = Clock() + let (service, _, store) = makeService(clock: clock) + let first = makeSchedule() + let second = makeSchedule() + let firstSaved = clock.now + try service.syncAndPersist(first, allSchedules: [first], screenTimeService: FakeScreenTimeService()) + + clock.now = clock.now.addingTimeInterval(3600) + try service.syncAndPersist(second, allSchedules: [first, second], screenTimeService: FakeScreenTimeService()) + service.syncAllToSharedStore([second, first]) + + let saved = Dictionary(uniqueKeysWithValues: store.schedules.map { ($0.id, $0.activatedAt) }) + #expect(saved[first.id] == firstSaved) + #expect(saved[second.id] == clock.now) + } + + @Test func resavingAnEnabledUntimedScheduleKeepsItsActivation() throws { + let clock = Clock() + let (service, _, store) = makeService(clock: clock) + let permanent = BlockSchedule( + name: "P", selection: FamilyActivitySelection(), + startHour: 9, startMinute: 0, durationMinutes: 15, weekdays: [1], + shouldBlock: true, isTimed: false, isEnabled: true + ) + let activated = clock.now + try service.syncAndPersist(permanent, allSchedules: [permanent], screenTimeService: FakeScreenTimeService()) + + clock.now = clock.now.addingTimeInterval(3600) + try service.syncAndPersist(permanent, allSchedules: [permanent], screenTimeService: FakeScreenTimeService()) + + #expect(store.schedules.first?.activatedAt == activated) } - @Test func registerAllReRegistersEnabledOnlyAndPersistsAll() { + @Test func savingADisabledScheduleDoesNotStampActivation() throws { + let (service, _, store) = makeService() + let schedule = makeSchedule(isEnabled: false) + + try service.syncAndPersist(schedule, allSchedules: [schedule], screenTimeService: FakeScreenTimeService()) + + #expect(store.schedules.first?.activatedAt == nil) + } + + @Test func registerAllReRegistersEnabledOnlyPersistsAllAndReconciles() { let (service, activity, store) = makeService() let screenTime = FakeScreenTimeService() let enabled = makeSchedule(isEnabled: true) @@ -111,155 +161,152 @@ struct ScheduleServiceTests { service.registerAll([enabled, disabled], screenTimeService: screenTime) - #expect(store.schedules.count == 2, "All schedules persisted to the shared store") - #expect(activity.startCalls.count == 1, "Only the enabled schedule is (re)registered") + #expect(store.schedules.count == 2) + #expect(activity.startCalls.map(\.name.rawValue).filter { $0.hasPrefix("schedule_") } + == [SharedConstants.scheduleActivityName(for: enabled.id)]) + #expect(screenTime.reconcileCallCount == 1) } - // MARK: - "Unblock all" overrides - - private func activeNowSchedule(shouldBlock: Bool = true) -> BlockSchedule { - let now = Date() - let calendar = Calendar.current - return BlockSchedule( - name: "ActiveNow", - selection: FamilyActivitySelection(), - startHour: calendar.component(.hour, from: now), - startMinute: 0, - durationMinutes: 120, - weekdays: [calendar.component(.weekday, from: now)], - shouldBlock: shouldBlock, - isTimed: true, - isEnabled: true - ) + private var heartbeatName: String { + SharedConstants.reblockHeartbeatActivityName } - private func activeMainUnblock() -> TimedUnblockDTO { - try! TimedUnblockDTO( - id: TimedUnblockService.mainID, - selectionData: FamilyActivitySelection().toData(), - endDate: .now.addingTimeInterval(.hours(1)), - activityName: SharedConstants.mainTimedUnblockActivityName, - isGroupUnblock: false - ) - } - - @Test func activeScheduleBlocksWhenNoUnblockInEffect() throws { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() + @Test func registerAllLeavesUnchangedRegistrationsAlone() throws { + let (service, activity, _) = makeService() + let schedule = makeSchedule() + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + activity.startCalls.removeAll() + activity.stopCalls.removeAll() - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) - #expect(screenTime.addToShieldsCalled, "An active block schedule applies when nothing overrides it") + #expect(activity.startCalls.isEmpty) + #expect(activity.stopCalls.isEmpty) } - @Test func activeScheduleSuppressedDuringTimedUnblock() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.timedUnblocks = [activeMainUnblock()] + @Test func registerAllReRegistersWhenTheWindowChanged() throws { + let (service, activity, _) = makeService() + let schedule = makeSchedule() + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + activity.startCalls.removeAll() - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + schedule.startHour = 10 + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) - #expect(!screenTime.addToShieldsCalled, "A live timed unblock-all must override schedule blocking") + #expect(activity.startCalls.map(\.name.rawValue) == [SharedConstants.scheduleActivityName(for: schedule.id)]) + #expect(activity.startCalls.first?.schedule.intervalStart.hour == 10) } - @Test func activeScheduleSuppressedDuringPermanentOverride() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.setScheduleOverrideActive(true) + @Test func registerAllRegistersSchedulesIOSDropped() throws { + let (service, activity, _) = makeService() + let schedule = makeSchedule() + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + activity.registeredSchedules.removeAll() + activity.monitoredActivityNames.removeAll() + activity.startCalls.removeAll() - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) - #expect(!screenTime.addToShieldsCalled, "A permanent unblock-all override must suppress schedule blocking") + #expect(activity.startCalls.map(\.name.rawValue).contains(SharedConstants.scheduleActivityName(for: schedule.id))) } - @Test func reapplyDoesNotConsumePermanentOverride() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.setScheduleOverrideActive(true) + @Test func registerAllStopsOrphanedScheduleActivities() { + let (service, activity, _) = makeService() + let orphan = SharedConstants.scheduleActivityName(for: UUID()) + activity.monitoredActivityNames.insert(orphan) + activity.monitoredActivityNames.insert(SharedConstants.usageLimitsActivityName) - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + service.registerAll([makeSchedule()], screenTimeService: FakeScreenTimeService()) - #expect(store.isScheduleOverrideActive(), - "Foreground re-application must not consume the override; only a fresh start does") + #expect(activity.stopCalls.contains([DeviceActivityName(orphan)])) + #expect(activity.monitoredActivityNames.contains(SharedConstants.usageLimitsActivityName)) } - @Test func overrideSetBeforeWindowStartedDoesNotSuppressIt() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.scheduleOverrideActive = true - store.scheduleOverrideSince = .now.addingTimeInterval(-.hours(3)) + @Test func heartbeatRunsWhileAnyScheduleIsEnabled() throws { + let (service, activity, _) = makeService() + let schedule = makeSchedule() - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + #expect(activity.registeredSchedules[heartbeatName] == DeviceActivityScheduleFactory.heartbeat()) - #expect(screenTime.addToShieldsCalled) + try service.toggleEnabled(schedule, allSchedules: [schedule], screenTimeService: FakeScreenTimeService()) + #expect(activity.registeredSchedules[heartbeatName] == nil) } - @Test func legacyOverrideWithoutTimestampSuppressesActiveSchedule() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.scheduleOverrideActive = true + @Test func heartbeatIsNotRestartedWhenAlreadyRunning() { + let (service, activity, _) = makeService() + let schedule = makeSchedule() + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) + activity.startCalls.removeAll() - try service.sync(activeNowSchedule(), screenTimeService: screenTime) + service.registerAll([schedule], screenTimeService: FakeScreenTimeService()) - #expect(!screenTime.addToShieldsCalled) - #expect(store.isScheduleOverrideActive()) + #expect(!activity.startCalls.map(\.name.rawValue).contains(heartbeatName)) } - @Test func activeScheduleLeavesActiveGroupUnblockAppsUnblocked() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.customDomainsEnabled = true + @Test func heartbeatStaysWhileATimedUnblockIsActiveEvenWithoutSchedules() throws { + let (service, activity, store) = makeService() store.timedUnblocks = try [TimedUnblockDTO( - id: UUID().uuidString, - selectionData: TokenFixtures.selection(apps: [2]).toData(), - endDate: .now.addingTimeInterval(.hours(1)), - activityName: "timedUnblock_group", - isGroupUnblock: true, - customDomains: ["b.com"] + id: TimedUnblockService.mainID, + selectionData: FamilyActivitySelection().toData(), + endDate: Date(timeIntervalSinceReferenceDate: 800_000_000).addingTimeInterval(3600), + activityName: SharedConstants.mainTimedUnblockActivityName, + isGroupUnblock: false )] - let schedule = activeNowSchedule() - schedule.selection = TokenFixtures.selection(apps: [1, 2]) - schedule.customDomains = ["a.com", "b.com"] - try service.sync(schedule, screenTimeService: screenTime) + service.registerAll([], screenTimeService: FakeScreenTimeService()) - #expect(screenTime.addToShieldsSelection?.applicationTokens == [TokenFixtures.app(1)]) - #expect(screenTime.addToShieldsCustomDomains == ["a.com"]) + #expect(activity.registeredSchedules[heartbeatName] != nil) } - @Test func setScheduleOverrideWritesFlag() { - let (service, _, store) = makeService() + @Test func heartbeatGetsTheLastFreeActivitySlotBeforeSchedules() { + let (service, activity, _) = makeService() + activity.activityLimit = 1 - service.setScheduleOverride(true) - #expect(store.isScheduleOverrideActive()) + service.registerAll([makeSchedule(), makeSchedule()], screenTimeService: FakeScreenTimeService()) - service.setScheduleOverride(false) - #expect(!store.isScheduleOverrideActive()) + #expect(activity.monitoredActivityNames == [heartbeatName]) } - @Test func disableAllTogglesEveryScheduleOff() { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() - let first = makeSchedule(isEnabled: true) - let second = makeSchedule(isEnabled: true) + @Test func tooManyActivitiesBecomesAReadableError() { + let (service, activity, _) = makeService() + activity.startError = DeviceActivityCenter.MonitoringError.excessiveActivities - service.disableAll([first, second], screenTimeService: screenTime) + #expect(throws: ScheduleRegistrationError.tooManyActivities) { try service.sync(makeSchedule()) } + #expect(ScheduleRegistrationError.tooManyActivities.errorDescription?.contains("20") == true) + } - #expect(!first.isEnabled) - #expect(!second.isEnabled) + @Test func sameWindowIgnoresAMissingSecondsComponent() { + let registered = DeviceActivitySchedule( + intervalStart: DateComponents(hour: 9, minute: 0), + intervalEnd: DateComponents(hour: 10, minute: 0), + repeats: true + ) + let desired = DeviceActivitySchedule( + intervalStart: DateComponents(hour: 9, minute: 0, second: 0), + intervalEnd: DateComponents(hour: 10, minute: 0, second: 0), + repeats: true + ) + #expect(registered.hasSameWindow(as: desired)) + #expect(!registered.hasSameWindow(as: DeviceActivitySchedule( + intervalStart: desired.intervalStart, intervalEnd: desired.intervalEnd, repeats: false + ))) } - @Test func disableAllStopsMonitoringAndLiftsBlocks() { + @Test func disableAllTogglesEverySchedulePersistsAndReconciles() { let (service, activity, store) = makeService() let screenTime = FakeScreenTimeService() - let schedule = makeSchedule(shouldBlock: true, isEnabled: true) + let first = makeSchedule(isEnabled: true) + let second = makeSchedule(isEnabled: true) - service.disableAll([schedule], screenTimeService: screenTime) + service.disableAll([first, second], screenTimeService: screenTime) - #expect(activity.startCalls.isEmpty, "Disabled schedules are not monitored") - #expect(!activity.stopCalls.isEmpty, "Monitoring is stopped for the disabled schedule") - #expect(screenTime.removeFromShieldsCalled, "A blocking schedule's shield is lifted when disabled") - #expect(store.schedules.count == 1, "The disabled schedule is still persisted to the shared store") + #expect(!first.isEnabled) + #expect(!second.isEnabled) + #expect(activity.startCalls.isEmpty) + #expect(store.schedules.allSatisfy { $0.isEnabled == false }) + #expect(screenTime.reconcileCallCount == 1) } // MARK: - isActive(at:) @@ -334,44 +381,6 @@ struct ScheduleServiceTests { "Inactive before the start") } - private func activeDomainSchedule() -> BlockSchedule { - let schedule = activeNowSchedule(shouldBlock: true) - schedule.customDomains = ["reddit.com"] - return schedule - } - - @Test func activeScheduleOmitsDomainsWhenFeatureDisabled() throws { - let (service, _, _) = makeService() // FakeSharedStore defaults customDomainsEnabled = false - let screenTime = FakeScreenTimeService() - - try service.sync(activeDomainSchedule(), screenTimeService: screenTime) - - #expect(screenTime.addToShieldsCalled) - #expect(screenTime.addToShieldsCustomDomains == [], "Domains must not enforce while the setting is off") - } - - @Test func activeScheduleAppliesDomainsWhenFeatureEnabled() throws { - let (service, _, store) = makeService() - store.setCustomDomainsEnabled(true) - let screenTime = FakeScreenTimeService() - - try service.sync(activeDomainSchedule(), screenTimeService: screenTime) - - #expect(screenTime.addToShieldsCustomDomains == ["reddit.com"]) - } - - @Test func disabledBlockingScheduleOmitsDomainsWhenFeatureDisabled() throws { - let (service, _, _) = makeService() - let screenTime = FakeScreenTimeService() - let schedule = activeDomainSchedule() - schedule.isEnabled = false - - try service.sync(schedule, screenTimeService: screenTime) - - #expect(screenTime.removeFromShieldsCalled) - #expect(screenTime.removeFromShieldsCustomDomains == []) - } - @Test func mirrorCustomDomainsEnabledWritesFlag() { let (service, _, store) = makeService() @@ -399,7 +408,7 @@ struct ScheduleServiceTests { isTimed: isTimed, isEnabled: true ) - try service.sync(schedule, screenTimeService: FakeScreenTimeService()) + try service.sync(schedule) return try #require(activity.startCalls.last?.schedule) } diff --git a/NormalTests/Services/ScreenTimeServiceTests.swift b/NormalTests/Services/ScreenTimeServiceTests.swift index c2f7dda..f23092b 100644 --- a/NormalTests/Services/ScreenTimeServiceTests.swift +++ b/NormalTests/Services/ScreenTimeServiceTests.swift @@ -1,37 +1,61 @@ import FamilyControls +import Foundation @testable import Normal import Testing @MainActor struct ScreenTimeServiceTests { + private func makeService() -> (ScreenTimeService, InMemoryShieldStore) { + let shields = InMemoryShieldStore(startBlocked: false) + let service = ScreenTimeService( + defaults: UserDefaults(suiteName: "screen-time-\(UUID().uuidString)")!, + shield: shields, + sharedStore: FakeSharedStore(), + activityCenter: FakeDeviceActivityCenter() + ) + return (service, shields) + } + @Test func blockAllWithSettingOnPreventsAppDelete() { - let s = FakeScreenTimeService() - s.applyShieldOnAll(selection: FamilyActivitySelection(), blockAllPreventsAppDelete: true) - #expect(s.applyShieldOnAllCalled) - #expect(s.enablePreventAppDeleteCalled) + let (service, shields) = makeService() + service.blockAll(selection: TokenFixtures.selection(apps: [1]), blockAllPreventsAppDelete: true) + #expect(shields.currentShields().applications == [TokenFixtures.app(1)]) + #expect(service.isAppDeleteDisabled) } @Test func blockAllWithSettingOffLeavesAppDeleteUntouched() { - let s = FakeScreenTimeService() - s.applyShieldOnAll(selection: FamilyActivitySelection(), blockAllPreventsAppDelete: false) - #expect(s.applyShieldOnAllCalled) - #expect(!s.enablePreventAppDeleteCalled) - #expect(!s.disablePreventAppDeleteCalled) + let (service, shields) = makeService() + service.enablePreventAppDelete() + service.blockAll(selection: TokenFixtures.selection(apps: [1]), blockAllPreventsAppDelete: false) + #expect(shields.currentShields().applications == [TokenFixtures.app(1)]) + #expect(service.isAppDeleteDisabled) } @Test func unblockAllWithSettingOnAllowsAppDelete() { - let s = FakeScreenTimeService() - s.removeShieldOnAll(blockAllPreventsAppDelete: true) - #expect(s.removeShieldOnAllCalled) - #expect(s.disablePreventAppDeleteCalled) + let (service, shields) = makeService() + service.blockAll(selection: TokenFixtures.selection(apps: [1]), blockAllPreventsAppDelete: true) + service.unblockAll(blockAllPreventsAppDelete: true) + #expect(shields.currentShields().applications.isEmpty) + #expect(!service.isAppDeleteDisabled) } @Test func unblockAllWithSettingOffLeavesAppDeleteUntouched() { - let s = FakeScreenTimeService() - s.removeShieldOnAll(blockAllPreventsAppDelete: false) - #expect(s.removeShieldOnAllCalled) - #expect(!s.disablePreventAppDeleteCalled) - #expect(!s.enablePreventAppDeleteCalled) + let (service, shields) = makeService() + service.blockAll(selection: TokenFixtures.selection(apps: [1]), blockAllPreventsAppDelete: true) + service.unblockAll(blockAllPreventsAppDelete: false) + #expect(shields.currentShields().applications.isEmpty) + #expect(service.isAppDeleteDisabled) + } + + @Test func blockStatusReflectsTheReconciledShield() { + let (service, _) = makeService() + service.blockAll(selection: TokenFixtures.selection(apps: [1, 2]), blockAllPreventsAppDelete: false) + service.unblock(selection: TokenFixtures.selection(apps: [2])) + + #expect(service.blockStatus(selection: TokenFixtures.selection(apps: [1])) == .all) + #expect(service.blockStatus(selection: TokenFixtures.selection(apps: [1, 2])) == .some) + #expect(service.blockStatus(selection: TokenFixtures.selection(apps: [2])) == .none) + #expect(service.activeShieldCount() == 1) } @Test func notifyUpdateIncrementsCount() { diff --git a/NormalTests/Services/TimedUnblockServiceTests.swift b/NormalTests/Services/TimedUnblockServiceTests.swift index fb78836..98f168c 100644 --- a/NormalTests/Services/TimedUnblockServiceTests.swift +++ b/NormalTests/Services/TimedUnblockServiceTests.swift @@ -21,7 +21,7 @@ struct TimedUnblockServiceTests { return (service, activity, store) } - @Test func startMainRemovesShieldAndPersists() throws { + @Test func startMainPersistsAndReconciles() throws { let (service, activity, store) = makeService() let screenTime = FakeScreenTimeService() @@ -31,7 +31,7 @@ struct TimedUnblockServiceTests { screenTimeService: screenTime ) - #expect(screenTime.removeShieldOnAllCalled) + #expect(screenTime.reconcileCallCount == 1) #expect(activity.startCalls.map(\.name.rawValue) == [ SharedConstants.mainTimedUnblockActivityName, SharedConstants.reblockHeartbeatActivityName, @@ -40,7 +40,7 @@ struct TimedUnblockServiceTests { #expect(service.isMainUnblockActive) } - @Test func startGroupRemovesFromShieldsAndPersists() throws { + @Test func startGroupPersistsAndReconciles() throws { let (service, activity, store) = makeService() let screenTime = FakeScreenTimeService() let groupId = UUID() @@ -52,7 +52,7 @@ struct TimedUnblockServiceTests { screenTimeService: screenTime ) - #expect(screenTime.removeFromShieldsCalled) + #expect(screenTime.reconcileCallCount == 1) #expect(activity.startCalls.map(\.name.rawValue) == [ SharedConstants.groupTimedUnblockActivityName(for: groupId), SharedConstants.reblockHeartbeatActivityName, @@ -83,7 +83,7 @@ struct TimedUnblockServiceTests { ) } - #expect(!screenTime.removeShieldOnAllCalled) + #expect(!screenTime.unblockAllCalled) #expect(!service.isMainUnblockActive) #expect(service.isGroupUnblockActive(groupId: groupId)) #expect(store.timedUnblocks.map(\.id) == [groupId.uuidString]) @@ -104,7 +104,7 @@ struct TimedUnblockServiceTests { ) } - #expect(!screenTime.removeFromShieldsCalled) + #expect(!screenTime.unblockCalled) #expect(!service.isGroupUnblockActive(groupId: groupId)) #expect(store.timedUnblocks.isEmpty) } @@ -137,7 +137,7 @@ struct TimedUnblockServiceTests { ) let mainStart = try #require(store.timedUnblocks.first?.startDate) - service.updateMainSelection(FamilyActivitySelection(), customDomains: ["x.com"]) + service.updateMainSelection(FamilyActivitySelection(), customDomains: ["x.com"], screenTimeService: FakeScreenTimeService()) let updated = try #require(store.timedUnblocks.first { $0.id == TimedUnblockService.mainID }) #expect(updated.startDate == mainStart) @@ -186,12 +186,12 @@ struct TimedUnblockServiceTests { blockAllPreventsAppDelete: false ) - #expect(screenTime.applyShieldOnAllCalled) + #expect(screenTime.blockAllCalled) #expect(store.timedUnblocks.isEmpty) #expect(!service.isMainUnblockActive) } - @Test func cancelGroupReinstatesShieldsAndForgets() throws { + @Test func cancelGroupForgetsTheUnblock() throws { let (service, _, store) = makeService() let screenTime = FakeScreenTimeService() let groupId = UUID() @@ -202,32 +202,12 @@ struct TimedUnblockServiceTests { selection: FamilyActivitySelection(), screenTimeService: screenTime ) - service.cancelGroup( - groupId: groupId, - selection: FamilyActivitySelection(), - screenTimeService: screenTime - ) + service.cancelGroup(groupId: groupId) - #expect(screenTime.addToShieldsCalled) #expect(store.timedUnblocks.isEmpty) #expect(!service.isGroupUnblockActive(groupId: groupId)) } - @Test func startMainClearsPermanentScheduleOverride() throws { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - store.setScheduleOverrideActive(true) - - try service.startMain( - duration: .fifteenMinutes, - selection: FamilyActivitySelection(), - screenTimeService: screenTime - ) - - #expect(!store.isScheduleOverrideActive(), - "A timed unblock supersedes a permanent override; its own window guard takes over") - } - @Test func startMainCancelsExistingGroupUnblocks() throws { let (service, _, store) = makeService() let screenTime = FakeScreenTimeService() @@ -329,7 +309,7 @@ struct TimedUnblockServiceTests { ) let originalEnd = store.timedUnblocks.first?.endDate - service.updateMainSelection(FamilyActivitySelection()) + service.updateMainSelection(FamilyActivitySelection(), screenTimeService: FakeScreenTimeService()) #expect(store.timedUnblocks.first?.endDate == originalEnd) } @@ -391,10 +371,13 @@ struct TimedUnblockServiceTests { @Test func reconcileStopsHeartbeatOnceNoUnblocksRemain() { let (service, activity, store) = makeService() store.timedUnblocks = [expiredMainDTO()] + let heartbeat = DeviceActivityName(SharedConstants.reblockHeartbeatActivityName) + try? activity.startMonitoring(heartbeat, during: DeviceActivityScheduleFactory.heartbeat(), events: [:]) + activity.startCalls.removeAll() service.reconcile(screenTimeService: FakeScreenTimeService()) - #expect(activity.stopCalls.last == [DeviceActivityName(SharedConstants.reblockHeartbeatActivityName)]) + #expect(activity.stopCalls.last == [heartbeat]) #expect(!activity.startCalls.map(\.name.rawValue).contains(SharedConstants.reblockHeartbeatActivityName)) } @@ -414,7 +397,7 @@ struct TimedUnblockServiceTests { #expect(activity.startCalls.last?.schedule == DeviceActivityScheduleFactory.heartbeat()) } - @Test func reconcileReblocksUnblockThatExpiredWhileAppWasDead() { + @Test func reconcileDropsUnblockThatExpiredWhileAppWasDeadAndReconciles() { let store = FakeSharedStore() store.timedUnblocks = [expiredMainDTO()] let screenTime = FakeScreenTimeService() @@ -427,27 +410,11 @@ struct TimedUnblockServiceTests { service.reconcile(screenTimeService: screenTime) - #expect(screenTime.applyShieldOnAllCalled) + #expect(screenTime.reconcileCallCount == 1) #expect(store.timedUnblocks.isEmpty) } - @Test func reconcilePropagatesPreventAppDeleteWhenReblocking() { - let store = FakeSharedStore() - store.timedUnblocks = [expiredMainDTO(blockAllPreventsAppDelete: true)] - let screenTime = FakeScreenTimeService() - - let service = TimedUnblockService( - activityCenter: FakeDeviceActivityCenter(), - sharedStore: store, - onExpiration: {} - ) - - service.reconcile(screenTimeService: screenTime) - - #expect(screenTime.applyShieldOnAllBlockAllPreventsAppDelete == true) - } - - @Test func reconcileReblocksExpiredGroupViaUnion() throws { + @Test func reconcileDropsExpiredGroupAndReconciles() throws { let store = FakeSharedStore() let groupId = UUID() store.timedUnblocks = try [TimedUnblockDTO( @@ -467,8 +434,7 @@ struct TimedUnblockServiceTests { service.reconcile(screenTimeService: screenTime) - #expect(screenTime.addToShieldsCalled) - #expect(!screenTime.applyShieldOnAllCalled) + #expect(screenTime.reconcileCallCount == 1) #expect(store.timedUnblocks.isEmpty) } @@ -501,7 +467,7 @@ struct TimedUnblockServiceTests { service.reconcile(screenTimeService: screenTime) - #expect(!screenTime.addToShieldsCalled) + #expect(!screenTime.blockCalled) #expect(service.isMainUnblockActive) #expect(store.timedUnblocks.map(\.id) == [TimedUnblockService.mainID]) } @@ -525,7 +491,7 @@ struct TimedUnblockServiceTests { service.reconcile(screenTimeService: screenTime) - #expect(!screenTime.applyShieldOnAllCalled) + #expect(!screenTime.blockAllCalled) #expect(service.isMainUnblockActive) #expect(store.timedUnblocks.count == 1) } @@ -578,11 +544,7 @@ struct TimedUnblockServiceTests { try startGroup(service, screenTime, groupA) try startGroup(service, screenTime, groupB) - service.cancelGroup( - groupId: groupA, - selection: FamilyActivitySelection(), - screenTimeService: screenTime - ) + service.cancelGroup(groupId: groupA) #expect(!service.isGroupUnblockActive(groupId: groupA)) #expect(service.isGroupUnblockActive(groupId: groupB)) @@ -666,11 +628,7 @@ struct TimedUnblockServiceTests { try startGroup(service, screenTime, groupB) let endBBefore = try #require(service.groupUnblockEndDate(groupId: groupB)) - service.cancelGroup( - groupId: groupA, - selection: FamilyActivitySelection(), - screenTimeService: screenTime - ) + service.cancelGroup(groupId: groupA) #expect(service.groupUnblockEndDate(groupId: groupB) == endBBefore) } @@ -738,33 +696,9 @@ struct TimedUnblockServiceTests { screenTimeService: screenTime ) - #expect(screenTime.removeShieldOnAllBlockAllPreventsAppDelete == true) #expect(store.timedUnblocks.first?.blockAllPreventsAppDelete == true) } - @Test func reapplyShieldUsesPersistedCustomDomainsForGroup() { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - let groupId = UUID() - - // Seed an already-expired group unblock carrying custom domains. - store.timedUnblocks = [ - TimedUnblockDTO( - id: groupId.uuidString, - selectionData: (try? FamilyActivitySelection().toData()) ?? Data(), - endDate: .now.addingTimeInterval(-60), - activityName: SharedConstants.groupTimedUnblockActivityName(for: groupId), - isGroupUnblock: true, - customDomains: ["x.com"] - ), - ] - - service.reconcile(screenTimeService: screenTime) - - #expect(screenTime.addToShieldsCalled) - #expect(screenTime.addToShieldsCustomDomains == ["x.com"]) - } - @Test func editingGroupDomainsDuringUnblockUpdatesReblockPayload() throws { let (service, _, store) = makeService() let screenTime = FakeScreenTimeService() @@ -788,26 +722,6 @@ struct TimedUnblockServiceTests { #expect(dto?.endDate == endDate, "Editing must not disturb the unblock's expiry") } - @Test func reapplyShieldUsesPersistedCustomDomainsForMain() { - let (service, _, store) = makeService() - let screenTime = FakeScreenTimeService() - - store.timedUnblocks = [ - TimedUnblockDTO( - id: TimedUnblockService.mainID, - selectionData: (try? FamilyActivitySelection().toData()) ?? Data(), - endDate: .now.addingTimeInterval(-60), - activityName: SharedConstants.mainTimedUnblockActivityName, - isGroupUnblock: false, - customDomains: ["reddit.com"] - ), - ] - - service.reconcile(screenTimeService: screenTime) - - #expect(screenTime.applyShieldOnAllCalled) - #expect(screenTime.applyShieldOnAllCustomDomains == ["reddit.com"]) - } } @MainActor diff --git a/NormalTests/Shared/DTOBackCompatTests.swift b/NormalTests/Shared/DTOBackCompatTests.swift index 5e81493..433cb6f 100644 --- a/NormalTests/Shared/DTOBackCompatTests.swift +++ b/NormalTests/Shared/DTOBackCompatTests.swift @@ -26,6 +26,48 @@ struct DTOBackCompatTests { let isTimed: Bool } + private struct Version15ScheduleDTO: Codable { + let id: UUID + let name: String + let selectionData: Data + let startHour: Int + let startMinute: Int + let durationMinutes: Int + let weekdays: Set + let shouldBlock: Bool + let isTimed: Bool + let encodedCustomDomains: [String]? + } + + @Test func scheduleDTODecodesVersion15BlobWithoutEnabledOrActivation() throws { + let legacy = Version15ScheduleDTO( + id: UUID(), name: "Mail", selectionData: Data(), + startHour: 12, startMinute: 0, durationMinutes: 180, weekdays: [1, 2], + shouldBlock: false, isTimed: true, encodedCustomDomains: ["a.com"] + ) + let blob = try PropertyListEncoder().encode([legacy]) + + let decoded = try #require(try PropertyListDecoder().decode([ScheduleDTO].self, from: blob).first) + #expect(decoded.isEnabled == nil) + #expect(decoded.activatedAt == nil) + #expect(decoded.customDomains == ["a.com"]) + #expect(decoded.isEffectivelyEnabled(monitoredActivities: [SharedConstants.scheduleActivityName(for: legacy.id)])) + #expect(!decoded.isEffectivelyEnabled(monitoredActivities: [])) + } + + @Test func scheduleDTORoundTripsEnabledAndActivation() throws { + let activated = Date(timeIntervalSince1970: 900) + let dto = ScheduleDTO( + id: UUID(), name: "S", selectionData: Data(), + startHour: 9, startMinute: 0, durationMinutes: 60, weekdays: [2], + shouldBlock: true, isTimed: true, isEnabled: false, activatedAt: activated + ) + let decoded = try PropertyListDecoder().decode(ScheduleDTO.self, from: PropertyListEncoder().encode(dto)) + #expect(decoded.isEnabled == false) + #expect(decoded.activatedAt == activated) + #expect(!decoded.isEffectivelyEnabled(monitoredActivities: [SharedConstants.scheduleActivityName(for: dto.id)])) + } + @Test func timedUnblockDTODecodesLegacyBlobWithoutCustomDomains() throws { let legacy = LegacyTimedUnblockDTO( id: "main", diff --git a/NormalTests/Shared/ScheduleActiveDayTests.swift b/NormalTests/Shared/ScheduleActiveDayTests.swift index bebbbee..e10e559 100644 --- a/NormalTests/Shared/ScheduleActiveDayTests.swift +++ b/NormalTests/Shared/ScheduleActiveDayTests.swift @@ -61,27 +61,4 @@ struct ScheduleActiveDayTests { #expect(!s.isActive(at: date(day: 5, hour: 23), calendar: cal)) } - @Test func nonWrappingEndDoesNotFireOnUnselectedDays() { - let s = schedule(weekdays: [2, 3, 4, 5, 6]) - #expect(!s.endApplies(on: date(day: 8), calendar: cal)) - #expect(!s.endApplies(on: date(day: 2), calendar: cal)) // Sunday - #expect(s.endApplies(on: date(day: 7), calendar: cal)) // Friday - #expect(s.endApplies(on: date(day: 3), calendar: cal)) // Monday - } - - @Test func wrappingScheduleDetected() { - #expect(schedule(weekdays: [6], startHour: 23, startMinute: 0, durationMinutes: 120).wrapsPastMidnight) - #expect(schedule(weekdays: [6], startHour: 0, startMinute: 0, durationMinutes: 1440).wrapsPastMidnight) - #expect(!schedule(weekdays: [6], startHour: 9, startMinute: 0, durationMinutes: 60).wrapsPastMidnight) - } - - @Test func wrappingFridayNightEndFiresSaturdayNotOtherDays() { - let s = schedule(weekdays: [6], startHour: 23, startMinute: 0, durationMinutes: 120) - - #expect(s.isActive(at: date(day: 7, hour: 23), calendar: cal)) - #expect(!s.isActive(at: date(day: 8, hour: 23), calendar: cal)) - #expect(s.endApplies(on: date(day: 8), calendar: cal)) - #expect(!s.endApplies(on: date(day: 7), calendar: cal)) - #expect(!s.endApplies(on: date(day: 2), calendar: cal)) - } } diff --git a/NormalTests/Shared/SharedStoreTests.swift b/NormalTests/Shared/SharedStoreTests.swift index 3df96f3..ba210fe 100644 --- a/NormalTests/Shared/SharedStoreTests.swift +++ b/NormalTests/Shared/SharedStoreTests.swift @@ -63,15 +63,6 @@ struct SharedStoreTests { #expect(found?.id == "main") } - @Test func mainActiveOnlyWhenFutureEndDate() { - let (store, _) = makeStore() - store.upsertTimedUnblock(makeDTO(id: "main", endDate: .now.addingTimeInterval(.hours(1)))) - #expect(store.isMainTimedUnblockActive()) - - store.upsertTimedUnblock(makeDTO(id: "main", endDate: .now.addingTimeInterval(-.minutes(1)))) - #expect(!store.isMainTimedUnblockActive()) - } - @Test func savesAndLoadsSchedules() throws { let (store, _) = makeStore() let dto = try ScheduleDTO( @@ -91,92 +82,16 @@ struct SharedStoreTests { #expect(loaded.first?.name == "Test") } - @Test func scheduleOverrideFlagRoundTrips() { - let (store, _) = makeStore() - #expect(!store.isScheduleOverrideActive(), "Defaults to off") - - store.setScheduleOverrideActive(true) - #expect(store.isScheduleOverrideActive()) - - store.setScheduleOverrideActive(false) - #expect(!store.isScheduleOverrideActive()) - } - - @Test func unblockAllInEffectReflectsTimedOrOverride() { - let (store, _) = makeStore() - #expect(!store.isUnblockAllInEffect()) - - store.setScheduleOverrideActive(true) - #expect(store.isUnblockAllInEffect(), "Permanent override counts") - - store.setScheduleOverrideActive(false) - store.upsertTimedUnblock(makeDTO(id: "main")) - #expect(store.isUnblockAllInEffect(), "Live timed unblock counts") - } - - @Test func resolveScheduleStartAppliesWhenNothingActive() { - let (store, _) = makeStore() - #expect(store.resolveScheduleStart(windowStart: .now) == .apply) - } - - @Test func resolveScheduleStartConsumesPermanentOverrideThenApplies() { - let (store, _) = makeStore() - store.setScheduleOverrideActive(true) - let nextWindow = Date.now.addingTimeInterval(.minutes(1)) - - #expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "The start proceeds (override ends here)") - #expect(!store.isScheduleOverrideActive(), "...and the one-shot override is consumed") - #expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "Subsequent starts apply normally") - } - - @Test func refiredStartOfWindowBegunBeforeOverrideSkipsWithoutConsuming() { - let (store, _) = makeStore() - let runningWindow = Date.now.addingTimeInterval(-.hours(1)) - store.setScheduleOverrideActive(true) - - #expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip) - #expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip) - #expect(store.isScheduleOverrideActive()) - #expect(store.suppressesSchedule(windowStart: runningWindow)) - } - - @Test func overrideDoesNotSuppressWindowStartedAfterIt() { - let (store, _) = makeStore() - store.setScheduleOverrideActive(true) - - #expect(!store.suppressesSchedule(windowStart: .now.addingTimeInterval(.minutes(1)))) - } - - @Test func legacyOverrideWithoutTimestampKeepsOneShotSemantics() { + @Test func readsScheduleOverrideAsWrittenByVersion15() { let (store, defaults) = makeStore() - defaults.set(true, forKey: SharedConstants.DefaultsKey.scheduleOverride) - let runningWindow = Date.now.addingTimeInterval(-.hours(1)) - - #expect(store.suppressesSchedule(windowStart: runningWindow)) - #expect(store.resolveScheduleStart(windowStart: runningWindow) == .apply) #expect(!store.isScheduleOverrideActive()) - } - - @Test func settingOverrideWritesLegacyFlagAndTimestamp() { - let (store, defaults) = makeStore() - - store.setScheduleOverrideActive(true) - #expect(defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride)) - #expect(store.loadScheduleOverrideSince() != nil) - - store.setScheduleOverrideActive(false) - #expect(!defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride)) #expect(store.loadScheduleOverrideSince() == nil) - } - @Test func resolveScheduleStartSkipsDuringTimedUnblockWithoutConsumingOverride() { - let (store, _) = makeStore() - store.upsertTimedUnblock(makeDTO(id: "main")) - store.setScheduleOverrideActive(true) + let since = Date(timeIntervalSinceReferenceDate: 800_000_000) + defaults.set(true, forKey: SharedConstants.DefaultsKey.scheduleOverride) + defaults.set(since.timeIntervalSinceReferenceDate, forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) - #expect(store.resolveScheduleStart(windowStart: .now.addingTimeInterval(.minutes(1))) == .skip, - "A live timed unblock wins for its whole window") - #expect(store.isScheduleOverrideActive(), - "A timed-unblock skip must not consume the permanent override") + #expect(store.isScheduleOverrideActive()) + #expect(store.loadScheduleOverrideSince() == since) } } diff --git a/NormalTests/Shared/ShieldExclusionTests.swift b/NormalTests/Shared/ShieldExclusionTests.swift deleted file mode 100644 index 148d69d..0000000 --- a/NormalTests/Shared/ShieldExclusionTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -import FamilyControls -import Foundation -@testable import Normal -import Testing - -struct ShieldExclusionTests { - private let cal: Calendar = { - var c = Calendar(identifier: .gregorian) - c.timeZone = TimeZone(identifier: "UTC")! - return c - }() - - // Wednesday, June 5 2024, 12:00 UTC. - private var noonWednesday: Date { - cal.date(from: DateComponents(year: 2024, month: 6, day: 5, hour: 12))! - } - - private func unblock( - id: String, - apps: [UInt8], - domains: [String] = [], - endDate: Date = .now.addingTimeInterval(.hours(1)) - ) throws -> TimedUnblockDTO { - try TimedUnblockDTO( - id: id, - selectionData: TokenFixtures.selection(apps: apps).toData(), - endDate: endDate, - activityName: "timedUnblock_\(id)", - isGroupUnblock: id != TimedUnblockService.mainID, - customDomains: domains - ) - } - - private func schedule( - apps: [UInt8], - domains: [String] = [], - startHour: Int = 10, - durationMinutes: Int = 240, - shouldBlock: Bool = true - ) throws -> ScheduleDTO { - try ScheduleDTO( - id: UUID(), - name: "S", - selectionData: TokenFixtures.selection(apps: apps).toData(), - startHour: startHour, - startMinute: 0, - durationMinutes: durationMinutes, - weekdays: [4], - shouldBlock: shouldBlock, - isTimed: true, - customDomains: domains - ) - } - - @Test func removesAppsAndDomainsOfActiveGroupUnblocks() throws { - let exclusion = try ShieldExclusion.groupUnblocks( - [unblock(id: "g1", apps: [2], domains: ["b.com"])], - at: .now - ) - - let remaining = exclusion.applied(to: TokenFixtures.selection(apps: [1, 2])) - #expect(remaining.applicationTokens == [TokenFixtures.app(1)]) - #expect(exclusion.applied(to: ["a.com", "b.com"]) == ["a.com"]) - } - - @Test func ignoresExpiredGroupUnblocksAndMainUnblock() throws { - let exclusion = try ShieldExclusion.groupUnblocks( - [ - unblock(id: "g1", apps: [1], endDate: .now.addingTimeInterval(-60)), - unblock(id: TimedUnblockService.mainID, apps: [2]), - ], - at: .now - ) - - let remaining = exclusion.applied(to: TokenFixtures.selection(apps: [1, 2])) - #expect(remaining.applicationTokens == [TokenFixtures.app(1), TokenFixtures.app(2)]) - } - - @Test func keepsAppsOfOtherActiveSchedulesOfSameKind() throws { - let ending = try schedule(apps: [1, 2], domains: ["a.com", "b.com"], startHour: 9, durationMinutes: 180) - let stillRunning = try schedule(apps: [2, 3], domains: ["b.com"]) - - let keep = ShieldExclusion.activeSchedules( - [ending, stillRunning], - shouldBlock: true, - at: noonWednesday, - excluding: ending.id, - calendar: cal - ) { _ in true } - - #expect(keep.applied(to: TokenFixtures.selection(apps: [1, 2])).applicationTokens == [TokenFixtures.app(1)]) - #expect(keep.applied(to: ["a.com", "b.com"]) == ["a.com"]) - } - - @Test func ignoresDisabledInactiveAndOtherKindSchedules() throws { - let ending = try schedule(apps: [1], startHour: 9, durationMinutes: 180) - let disabled = try schedule(apps: [1]) - let inactive = try schedule(apps: [1], startHour: 14, durationMinutes: 60) - let allowing = try schedule(apps: [1], shouldBlock: false) - - let keep = ShieldExclusion.activeSchedules( - [ending, disabled, inactive, allowing], - shouldBlock: true, - at: noonWednesday, - excluding: ending.id, - calendar: cal - ) { $0.id != disabled.id } - - #expect(keep.applied(to: TokenFixtures.selection(apps: [1])).applicationTokens == [TokenFixtures.app(1)]) - } - - @Test func unionCombinesBothExclusions() throws { - let groups = try ShieldExclusion.groupUnblocks([unblock(id: "g1", apps: [1])], at: .now) - let schedules = try ShieldExclusion.activeSchedules( - [schedule(apps: [2])], - shouldBlock: true, - at: noonWednesday, - excluding: UUID(), - calendar: cal - ) { _ in true } - - let remaining = groups.union(schedules).applied(to: TokenFixtures.selection(apps: [1, 2, 3])) - #expect(remaining.applicationTokens == [TokenFixtures.app(3)]) - } - - @Test func withoutExclusionIncludesEveryActiveScheduleOfKind() throws { - let lunch = try schedule(apps: [1], domains: ["a.com"], shouldBlock: false) - let work = try schedule(apps: [2]) - - let allowing = ShieldExclusion.activeSchedules( - [lunch, work], - shouldBlock: false, - at: noonWednesday, - calendar: cal - ) { _ in true } - - #expect(allowing.applied(to: TokenFixtures.selection(apps: [1, 2])).applicationTokens == [TokenFixtures.app(2)]) - #expect(allowing.applied(to: ["a.com", "b.com"]) == ["b.com"]) - } -} diff --git a/NormalTests/Shared/ShieldReconcilerTests.swift b/NormalTests/Shared/ShieldReconcilerTests.swift new file mode 100644 index 0000000..474930e --- /dev/null +++ b/NormalTests/Shared/ShieldReconcilerTests.swift @@ -0,0 +1,121 @@ +import FamilyControls +import Foundation +import ManagedSettings +@testable import Normal +import Testing + +struct ShieldReconcilerTests { + private final class RecordingShields: ShieldApplying, @unchecked Sendable { + private let lock = NSLock() + private var plan = ShieldPlan() + private var inFlight = 0 + private(set) var maxInFlight = 0 + private(set) var applyCount = 0 + var readDelay: TimeInterval = 0 + + init(_ plan: ShieldPlan = ShieldPlan()) { + self.plan = plan + } + + func currentShields() -> ShieldPlan { + lock.withLock { + inFlight += 1 + maxInFlight = max(maxInFlight, inFlight) + } + if readDelay > 0 { + Thread.sleep(forTimeInterval: readDelay) + } + return lock.withLock { plan } + } + + func apply(_ plan: ShieldPlan) { + lock.withLock { + self.plan = plan + applyCount += 1 + inFlight -= 1 + } + } + + var current: ShieldPlan { + lock.withLock { plan } + } + } + + private func blocking(_ apps: [UInt8]) -> ShieldPlan { + var plan = ShieldPlan() + plan.applications = Set(apps.map(TokenFixtures.app)) + return plan + } + + @Test func doesNotWriteWhenNothingChanged() { + let store = FakeSharedStore() + let shields = RecordingShields(blocking([1])) + + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) + + #expect(shields.applyCount == 0) + #expect(shields.current.applications == [TokenFixtures.app(1)]) + } + + @Test func leavesAppDeletionAloneWhenNoDecisionWasRecorded() { + var current = blocking([1]) + current.denyAppRemoval = nil + let shields = RecordingShields(current) + let store = FakeSharedStore() + store.manualBlockState = ManualBlockState() + + let plan = ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) + + #expect(plan?.denyAppRemoval == nil) + #expect(plan?.applications.isEmpty == true) + } + + @Test func savesTheManualRecordOnlyWhenItChanges() { + let store = FakeSharedStore() + let shields = RecordingShields(blocking([1])) + + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) + #expect(store.manualBlockStateSaveCount == 1) + + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) { + $0.block(TokenFixtures.selection(apps: [2]), customDomains: [], at: .now) + } + #expect(store.manualBlockStateSaveCount == 2) + } + + @Test func appliesUpdateBeforeComputing() { + let store = FakeSharedStore() + let shields = RecordingShields() + + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) { + $0.block(TokenFixtures.selection(apps: [4]), customDomains: [], at: .now) + } + + #expect(shields.current.applications == [TokenFixtures.app(4)]) + #expect(store.manualBlockState?.applications.blockedTokens == [TokenFixtures.app(4)]) + } + + @Test(.enabled(if: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: SharedConstants.appGroupID) != nil)) + func concurrentReconcilesNeverOverlap() async { + let store = FakeSharedStore() + store.manualBlockState = ManualBlockState() + let shields = RecordingShields() + shields.readDelay = 0.01 + + await withTaskGroup(of: Void.self) { group in + for id in UInt8(1) ... 8 { + group.addTask { + ShieldReconciler.reconcile(sharedStore: store, shields: shields, monitoredActivities: []) { + $0.block(TokenFixtures.selection(apps: [id]), customDomains: [], at: .now) + } + } + } + } + + #expect(shields.applyCount == 8) + #expect(shields.maxInFlight == 1) + #expect(shields.current.applications == Set((UInt8(1) ... 8).map(TokenFixtures.app))) + } +} diff --git a/NormalTests/Shared/ShieldResolverTests.swift b/NormalTests/Shared/ShieldResolverTests.swift new file mode 100644 index 0000000..197804f --- /dev/null +++ b/NormalTests/Shared/ShieldResolverTests.swift @@ -0,0 +1,427 @@ +import FamilyControls +import Foundation +import ManagedSettings +@testable import Normal +import Testing + +struct ShieldResolverTests { + private let cal: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "UTC")! + return c + }() + + private func at(day: Int = 5, _ hour: Int, _ minute: Int = 0) -> Date { + cal.date(from: DateComponents(year: 2024, month: 6, day: day, hour: hour, minute: minute))! + } + + private func schedule( + apps: [UInt8], + domains: [String] = [], + start: (Int, Int), + minutes: Int, + block: Bool, + timed: Bool = true, + weekdays: Set = Set(1 ... 7), + activatedAt: Date? = nil + ) throws -> ScheduleDTO { + try ScheduleDTO( + id: UUID(), + name: "S", + selectionData: TokenFixtures.selection(apps: apps).toData(), + startHour: start.0, + startMinute: start.1, + durationMinutes: minutes, + weekdays: weekdays, + shouldBlock: block, + isTimed: timed, + customDomains: domains, + activatedAt: activatedAt + ) + } + + private func timedUnblock( + apps: [UInt8], + group: Bool, + startDate: Date? = nil, + endDate: Date + ) throws -> TimedUnblockDTO { + try TimedUnblockDTO( + id: group ? UUID().uuidString : TimedUnblockService.mainID, + selectionData: TokenFixtures.selection(apps: apps).toData(), + endDate: endDate, + activityName: "timedUnblock", + isGroupUnblock: group, + startDate: startDate + ) + } + + private func manual(blocking apps: [UInt8], domains: [String] = [], at date: Date = .distantPast) -> ManualBlockState { + var state = ManualBlockState() + state.blockAll(TokenFixtures.selection(apps: apps), customDomains: domains, at: date) + return state + } + + private func blockedApps( + _ manual: ManualBlockState, + _ schedules: [ScheduleDTO] = [], + unblocks: [TimedUnblockDTO] = [], + at date: Date + ) -> Set { + ShieldResolver.plan( + manual: manual, + enabledSchedules: schedules, + timedUnblocks: unblocks, + customDomainsEnabled: true, + at: date, + calendar: cal + ).applications + } + + private func apps(_ ids: UInt8...) -> Set { + Set(ids.map(TokenFixtures.app)) + } + + @Test func manualBlockAllShieldsExactlyTheSelection() { + #expect(blockedApps(manual(blocking: [1, 2]), at: at(12)) == apps(1, 2)) + } + + @Test func unblockWindowLiftsManualBlockAndReblocksAtEnd() throws { + let mail = try schedule(apps: [1], start: (12, 0), minutes: 180, block: false) + let state = manual(blocking: [1, 2]) + + #expect(blockedApps(state, [mail], at: at(11, 59)) == apps(1, 2)) + #expect(blockedApps(state, [mail], at: at(13)) == apps(2)) + #expect(blockedApps(state, [mail], at: at(15)) == apps(1, 2)) + } + + @Test func simultaneousEndsBothReblock() throws { + let mail = try schedule(apps: [1], start: (12, 0), minutes: 180, block: false) + let beeper = try schedule(apps: [2], start: (12, 0), minutes: 180, block: false) + let state = manual(blocking: [1, 2, 3]) + + #expect(blockedApps(state, [mail, beeper], at: at(14)) == apps(3)) + #expect(blockedApps(state, [mail, beeper], at: at(15, 0)) == apps(1, 2, 3)) + } + + @Test func overlappingWindowsLatestStartWins() throws { + let block = try schedule(apps: [1], start: (5, 0), minutes: 60, block: true) + let allow = try schedule(apps: [1], start: (5, 30), minutes: 60, block: false) + let state = ManualBlockState() + + #expect(blockedApps(state, [block, allow], at: at(5, 15)) == apps(1)) + #expect(blockedApps(state, [block, allow], at: at(5, 45)).isEmpty) + #expect(blockedApps(state, [block, allow], at: at(6, 15)).isEmpty) + } + + @Test func nestedUnblockWindowIsAnException() throws { + let workday = try schedule(apps: [1], start: (9, 0), minutes: 480, block: true) + let lunch = try schedule(apps: [1], start: (12, 0), minutes: 60, block: false) + let state = ManualBlockState() + + #expect(blockedApps(state, [workday, lunch], at: at(10)) == apps(1)) + #expect(blockedApps(state, [workday, lunch], at: at(12, 30)).isEmpty) + #expect(blockedApps(state, [workday, lunch], at: at(13, 30)) == apps(1)) + #expect(blockedApps(state, [workday, lunch], at: at(17, 30)).isEmpty) + } + + @Test func identicalStartsBlockWins() throws { + let block = try schedule(apps: [1], start: (12, 0), minutes: 60, block: true) + let allow = try schedule(apps: [1], start: (12, 0), minutes: 120, block: false) + + #expect(blockedApps(ManualBlockState(), [allow, block], at: at(12, 30)) == apps(1)) + #expect(blockedApps(ManualBlockState(), [allow, block], at: at(13, 30)).isEmpty) + } + + @Test func schedulesOnlyAffectTheirOwnApps() throws { + let block = try schedule(apps: [1], start: (12, 0), minutes: 60, block: true) + let allow = try schedule(apps: [2], start: (12, 30), minutes: 60, block: false) + + #expect(blockedApps(manual(blocking: [2]), [block, allow], at: at(12, 45)) == apps(1)) + } + + @Test func manualActionAfterWindowStartWinsUntilNextStart() throws { + let mail = try schedule(apps: [1], start: (12, 0), minutes: 180, block: false) + var state = manual(blocking: [2]) + state.block(TokenFixtures.selection(apps: [1]), customDomains: [], at: at(13)) + + #expect(blockedApps(state, [mail], at: at(13, 30)) == apps(1, 2)) + #expect(blockedApps(state, [mail], at: at(15, 30)) == apps(1, 2)) + #expect(blockedApps(state, [mail], at: at(day: 6, 12, 30)) == apps(2)) + } + + @Test func permanentUnblockAllSuppressesRunningBlockWindowUntilNextStart() throws { + let workday = try schedule(apps: [1], start: (9, 0), minutes: 480, block: true) + var state = manual(blocking: [1, 2]) + state.unblockAll(at: at(10)) + + #expect(blockedApps(state, [workday], at: at(11)).isEmpty) + #expect(blockedApps(state, [workday], at: at(day: 6, 9, 30)) == apps(1)) + } + + @Test func untimedScheduleHoldsUntilSuperseded() throws { + let permanent = try schedule(apps: [1], start: (9, 0), minutes: 15, block: true, timed: false) + var state = ManualBlockState() + + #expect(blockedApps(state, [permanent], at: at(8)) == apps(1)) + #expect(blockedApps(state, [permanent], at: at(20)) == apps(1)) + + state.unblock(TokenFixtures.selection(apps: [1]), customDomains: [], at: at(21)) + #expect(blockedApps(state, [permanent], at: at(22)).isEmpty) + #expect(blockedApps(state, [permanent], at: at(day: 6, 9, 1)) == apps(1)) + } + + @Test func timedScheduleActivatedMidWindowBeatsEarlierManualAction() throws { + let workday = try schedule(apps: [1], start: (9, 0), minutes: 480, block: true, activatedAt: at(13)) + var state = ManualBlockState() + state.unblockAll(at: at(10)) + + #expect(blockedApps(state, [workday], at: at(13, 1)) == apps(1)) + } + + @Test func untimedScheduleIgnoresStartsBeforeItWasActivated() throws { + let permanent = try schedule(apps: [1], start: (9, 0), minutes: 15, block: true, timed: false, activatedAt: at(8)) + + #expect(blockedApps(ManualBlockState(), [permanent], at: at(8, 30)).isEmpty) + #expect(blockedApps(ManualBlockState(), [permanent], at: at(9, 1)) == apps(1)) + } + + @Test func untimedScheduleActivatedDuringItsStartWindowApplies() throws { + let permanent = try schedule(apps: [1], start: (9, 0), minutes: 15, block: true, timed: false, activatedAt: at(9, 5)) + + #expect(blockedApps(ManualBlockState(), [permanent], at: at(9, 6)) == apps(1)) + #expect(blockedApps(ManualBlockState(), [permanent], at: at(20)) == apps(1)) + } + + @Test func overnightWindowCountsFromPreviousDayStart() throws { + let night = try schedule(apps: [1], start: (22, 0), minutes: 480, block: true, weekdays: [4]) + + #expect(blockedApps(ManualBlockState(), [night], at: at(day: 6, 2)) == apps(1)) + #expect(blockedApps(ManualBlockState(), [night], at: at(day: 6, 7)).isEmpty) + } + + @Test func mainTimedUnblockLiftsEverything() throws { + let block = try schedule(apps: [3], start: (12, 0), minutes: 60, block: true) + let unblock = try timedUnblock(apps: [1, 2], group: false, endDate: at(12, 30)) + + #expect(blockedApps(manual(blocking: [1, 2]), [block], unblocks: [unblock], at: at(12, 15)).isEmpty) + #expect(blockedApps(manual(blocking: [1, 2]), [block], unblocks: [unblock], at: at(12, 30)) == apps(1, 2, 3)) + } + + @Test func timedUnblockAllRestoresManualStateIncludingBlocksMadeDuringIt() throws { + var state = manual(blocking: [1, 2, 3], at: at(8)) + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(9)) + let unblockAll = try timedUnblock(apps: [1, 2, 3], group: false, startDate: at(10), endDate: at(10, 30)) + let lateBlock = try schedule(apps: [4], start: (10, 20), minutes: 40, block: true) + + #expect(blockedApps(state, [lateBlock], unblocks: [unblockAll], at: at(10, 5)).isEmpty) + + state.block(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(10, 10)) + #expect(blockedApps(state, [lateBlock], unblocks: [unblockAll], at: at(10, 15)) == apps(3)) + #expect(blockedApps(state, [lateBlock], unblocks: [unblockAll], at: at(10, 25)) == apps(3)) + #expect(blockedApps(state, [lateBlock], unblocks: [unblockAll], at: at(10, 30)) == apps(1, 2, 3, 4)) + } + + @Test func timedUnblockAllWithoutLaterActionsRestoresPartialState() throws { + var state = manual(blocking: [1, 2, 3], at: at(8)) + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(9)) + let unblockAll = try timedUnblock(apps: [1, 2, 3], group: false, startDate: at(10), endDate: at(10, 30)) + + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 15)).isEmpty) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 30)) == apps(1, 2)) + } + + @Test func blockAllDuringTimedUnblockAllBlocksEverythingNowAndAfter() throws { + var state = manual(blocking: [1, 2, 3], at: at(8)) + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(9)) + let unblockAll = try timedUnblock(apps: [1, 2, 3], group: false, startDate: at(10), endDate: at(10, 30)) + + state.blockAll(TokenFixtures.selection(apps: [1, 2, 3]), customDomains: [], at: at(10, 10)) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 15)) == apps(1, 2, 3)) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 30)) == apps(1, 2, 3)) + } + + @Test func reblockThenTimedUnblockDuringTimedUnblockAll() throws { + var state = manual(blocking: [1, 2, 3], at: at(8)) + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(9)) + let unblockAll = try timedUnblock(apps: [1, 2, 3], group: false, startDate: at(10), endDate: at(10, 30)) + state.block(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(10, 5)) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 7)) == apps(3)) + + let unblockC = try timedUnblock(apps: [3], group: true, startDate: at(10, 10), endDate: at(10, 25)) + let unblocks = [unblockAll, unblockC] + #expect(blockedApps(state, unblocks: unblocks, at: at(10, 15)).isEmpty) + #expect(blockedApps(state, unblocks: unblocks, at: at(10, 25)) == apps(3)) + #expect(blockedApps(state, unblocks: unblocks, at: at(10, 30)) == apps(1, 2, 3)) + } + + @Test func reblockThenPermanentUnblockDuringTimedUnblockAll() throws { + var state = manual(blocking: [1, 2, 3], at: at(8)) + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(9)) + let unblockAll = try timedUnblock(apps: [1, 2, 3], group: false, startDate: at(10), endDate: at(10, 30)) + state.block(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(10, 5)) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 7)) == apps(3)) + + state.unblock(TokenFixtures.selection(apps: [3]), customDomains: [], at: at(10, 10)) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 15)).isEmpty) + #expect(blockedApps(state, unblocks: [unblockAll], at: at(10, 30)) == apps(1, 2)) + } + + @Test func groupTimedUnblockBeatsBlockWindowUntilExpiry() throws { + let block = try schedule(apps: [1, 2], start: (12, 0), minutes: 60, block: true) + let unblock = try timedUnblock(apps: [1], group: true, endDate: at(12, 30)) + + #expect(blockedApps(ManualBlockState(), [block], unblocks: [unblock], at: at(12, 15)) == apps(2)) + #expect(blockedApps(ManualBlockState(), [block], unblocks: [unblock], at: at(12, 45)) == apps(1, 2)) + } + + @Test func customDomainsResolveOnlyWhenEnabled() throws { + let allow = try schedule(apps: [], domains: ["a.com"], start: (12, 0), minutes: 60, block: false) + let state = manual(blocking: [], domains: ["a.com", "b.com"]) + + let enabled = ShieldResolver.plan( + manual: state, enabledSchedules: [allow], timedUnblocks: [], + customDomainsEnabled: true, at: at(12, 30), calendar: cal + ) + let disabled = ShieldResolver.plan( + manual: state, enabledSchedules: [allow], timedUnblocks: [], + customDomainsEnabled: false, at: at(12, 30), calendar: cal + ) + #expect(enabled.customDomains == ["b.com"]) + #expect(disabled.customDomains.isEmpty) + } + + @Test func manualStateRoundTripsThroughPropertyList() throws { + var state = manual(blocking: [1, 2], domains: ["a.com"], at: at(9)) + state.unblock(TokenFixtures.selection(apps: [2]), customDomains: [], at: at(10)) + + let data = try PropertyListEncoder().encode(state) + #expect(try PropertyListDecoder().decode(ManualBlockState.self, from: data) == state) + } + + @Test func migrationRepairsUnblockScheduleAppsAndKeepsRunningWindow() throws { + let mail = try schedule(apps: [1], start: (12, 0), minutes: 180, block: false) + var shielded = ShieldPlan() + shielded.applications = apps(2) + + let state = ShieldResolver.migratedManualState( + shielded: shielded, enabledSchedules: [mail], timedUnblocks: [], + scheduleOverrideActive: false, scheduleOverrideSince: nil, at: at(13) + ) + + #expect(blockedApps(state, [mail], at: at(13)) == apps(2)) + #expect(blockedApps(state, [mail], at: at(15)) == apps(1, 2)) + } + + @Test func migrationKeepsRunningBlockScheduleAppsBlockedAfterWindow() throws { + let block = try schedule(apps: [1], start: (12, 0), minutes: 60, block: true) + var shielded = ShieldPlan() + shielded.applications = apps(1) + + let state = ShieldResolver.migratedManualState( + shielded: shielded, enabledSchedules: [block], timedUnblocks: [], + scheduleOverrideActive: false, scheduleOverrideSince: nil, at: at(12, 30) + ) + + #expect(blockedApps(state, [block], at: at(13, 30)) == apps(1)) + } + + @Test func migrationRestoresAppsHeldByActiveTimedUnblocks() throws { + let main = try timedUnblock(apps: [1, 2], group: false, endDate: at(12, 30)) + let group = try timedUnblock(apps: [3], group: true, endDate: at(12, 30)) + + let state = ShieldResolver.migratedManualState( + shielded: ShieldPlan(), enabledSchedules: [], timedUnblocks: [main, group], + scheduleOverrideActive: false, scheduleOverrideSince: nil, at: at(12) + ) + + #expect(blockedApps(state, unblocks: [main, group], at: at(12, 15)).isEmpty) + #expect(blockedApps(state, unblocks: [main, group], at: at(12, 30)) == apps(1, 2, 3)) + } + + @Test func migrationOfPermanentUnblockAllSkipsRepairAndSuppressesOlderWindows() throws { + let mail = try schedule(apps: [1], start: (12, 0), minutes: 180, block: false) + let workday = try schedule(apps: [2], start: (9, 0), minutes: 480, block: true) + + let state = ShieldResolver.migratedManualState( + shielded: ShieldPlan(), enabledSchedules: [mail, workday], timedUnblocks: [], + scheduleOverrideActive: true, scheduleOverrideSince: at(10), at: at(11) + ) + + #expect(blockedApps(state, [mail, workday], at: at(11)).isEmpty) + #expect(blockedApps(state, [mail, workday], at: at(15, 30)).isEmpty) + #expect(blockedApps(state, [mail, workday], at: at(day: 6, 9, 30)) == apps(2)) + } + + private func newYork() -> Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "America/New_York")! + return c + } + + @Test func windowsEndOnTheWallClockAcrossDaylightSavingChanges() throws { + let ny = newYork() + let night = try schedule(apps: [1], start: (22, 0), minutes: 540, block: true) + func local(_ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + ny.date(from: DateComponents(year: 2024, month: month, day: day, hour: hour, minute: minute))! + } + func blocked(_ date: Date) -> Bool { + !ShieldResolver.plan( + manual: ManualBlockState(), enabledSchedules: [night], timedUnblocks: [], + customDomainsEnabled: false, at: date, calendar: ny + ).applications.isEmpty + } + + #expect(blocked(local(3, 10, 6, 45))) + #expect(!blocked(local(3, 10, 7, 15))) + #expect(blocked(local(11, 3, 6, 30))) + #expect(!blocked(local(11, 3, 7, 15))) + } + + @Test func scheduleOrderDoesNotMatter() throws { + let block = try schedule(apps: [1], start: (5, 0), minutes: 60, block: true) + let allow = try schedule(apps: [1], start: (5, 30), minutes: 60, block: false) + let sameStart = try schedule(apps: [2], start: (5, 30), minutes: 60, block: true) + let sameStartAllow = try schedule(apps: [2], start: (5, 30), minutes: 30, block: false) + + for schedules in [[block, allow, sameStart, sameStartAllow], [sameStartAllow, sameStart, allow, block]] { + #expect(blockedApps(ManualBlockState(), schedules, at: at(5, 15)) == apps(1)) + #expect(blockedApps(ManualBlockState(), schedules, at: at(5, 45)) == apps(2)) + } + } + + @Test func manualUnblockAtExactlyTheWindowStartLosesTheTie() throws { + let block = try schedule(apps: [1], start: (9, 0), minutes: 60, block: true) + var state = ManualBlockState() + state.unblock(TokenFixtures.selection(apps: [1]), customDomains: [], at: at(9)) + + #expect(blockedApps(state, [block], at: at(9, 30)) == apps(1)) + } + + @Test func overnightWindowCrossesIntoTheNextWeekday() throws { + let saturdayNight = try schedule(apps: [1], start: (22, 0), minutes: 480, block: true, weekdays: [7]) + + #expect(blockedApps(ManualBlockState(), [saturdayNight], at: at(day: 8, 23)) == apps(1)) + #expect(blockedApps(ManualBlockState(), [saturdayNight], at: at(day: 9, 2)) == apps(1)) + #expect(blockedApps(ManualBlockState(), [saturdayNight], at: at(day: 9, 6)).isEmpty) + #expect(blockedApps(ManualBlockState(), [saturdayNight], at: at(day: 9, 22, 30)).isEmpty) + } + + @Test func categoryAndWebDomainTokensResolveIndependently() throws { + var state = ManualBlockState() + state.blockAll(TokenFixtures.selection(apps: [1], categories: [1], webDomains: [1]), customDomains: [], at: .distantPast) + let allow = try ScheduleDTO( + id: UUID(), name: "S", selectionData: TokenFixtures.selection(apps: [], categories: [1]).toData(), + startHour: 12, startMinute: 0, durationMinutes: 60, weekdays: Set(1 ... 7), + shouldBlock: false, isTimed: true + ) + + let plan = ShieldResolver.plan( + manual: state, enabledSchedules: [allow], timedUnblocks: [], + customDomainsEnabled: false, at: at(12, 30), calendar: cal + ) + #expect(plan.applications == apps(1)) + #expect(plan.categories.isEmpty) + #expect(plan.webDomains == [TokenFixtures.webDomain(1)]) + } +} diff --git a/NormalTests/Shared/WebFilterTests.swift b/NormalTests/Shared/WebFilterTests.swift index 6a59eaa..97e21f1 100644 --- a/NormalTests/Shared/WebFilterTests.swift +++ b/NormalTests/Shared/WebFilterTests.swift @@ -12,21 +12,6 @@ struct WebFilterTests { #expect(result == domains("reddit.com")) } - @Test func unionAddsDomains() { - let current = domains("a.com") - #expect(WebFilter.union(current: current, adding: ["b.com"]) == domains("a.com", "b.com")) - } - - @Test func subtractRemovesDomains() { - let current = domains("a.com", "b.com") - #expect(WebFilter.subtract(current: current, removing: ["b.com"]) == domains("a.com")) - } - - @Test func subtractIsNotRefcounted() { - let current = WebFilter.union(current: domains("shared.com"), adding: ["shared.com"]) - #expect(WebFilter.subtract(current: current, removing: ["shared.com"]).isEmpty) - } - @Test func policyIsNilForEmptySet() { #expect(WebFilter.policy(for: []) == nil) } diff --git a/NormalTests/TestSupport/BlockingHarness.swift b/NormalTests/TestSupport/BlockingHarness.swift new file mode 100644 index 0000000..6e81097 --- /dev/null +++ b/NormalTests/TestSupport/BlockingHarness.swift @@ -0,0 +1,202 @@ +import FamilyControls +import Foundation +import ManagedSettings +@testable import Normal + +@MainActor +final class BlockingHarness { + final class Clock { + var now: Date + + init(_ now: Date) { + self.now = now + } + } + + let day: Date + let clock: Clock + let store = FakeSharedStore() + let activity = FakeDeviceActivityCenter() + let shields: InMemoryShieldStore + let screenTime: ScreenTimeService + let scheduleService: ScheduleService + let timedUnblock: TimedUnblockService + let usageLimits: UsageLimitService + let settings = Settings() + let main: SelectedApps + private(set) var schedules: [BlockSchedule] = [] + + init(mainApps: [UInt8] = [1, 2, 3], customDomains: [String] = [], startingAt hour: Int = 8) { + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: .now)! + day = Calendar.current.startOfDay(for: tomorrow) + let clock = Clock(Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day)!) + self.clock = clock + shields = InMemoryShieldStore(startBlocked: false) + screenTime = ScreenTimeService( + defaults: UserDefaults(suiteName: "harness-\(UUID().uuidString)")!, + shield: shields, + sharedStore: store, + activityCenter: activity, + clock: { clock.now } + ) + scheduleService = ScheduleService(activityCenter: activity, sharedStore: store, clock: { clock.now }) + timedUnblock = TimedUnblockService( + activityCenter: activity, + sharedStore: store, + clock: { clock.now }, + onExpiration: {} + ) + usageLimits = UsageLimitService( + activityCenter: activity, + sharedStore: store, + ledger: InMemoryUsageLimitLedger(), + limitShield: InMemoryDailyLimitShield() + ) + main = SelectedApps(selection: TokenFixtures.selection(apps: mainApps), customDomains: customDomains) + settings.blockAllPreventsAppDelete = false + if !customDomains.isEmpty { + settings.enableCustomDomains = true + store.setCustomDomainsEnabled(true) + } + } + + func at(_ hour: Int, _ minute: Int = 0, dayOffset: Int = 0) -> Date { + let target = Calendar.current.date(byAdding: .day, value: dayOffset, to: day)! + return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: target)! + } + + var blocked: Set { + shields.currentShields().applications + } + + var blockedDomains: Set { + shields.currentShields().customDomains + } + + var appDeletionBlocked: Bool { + shields.denyAppRemoval + } + + func apps(_ ids: UInt8...) -> Set { + Set(ids.map(TokenFixtures.app)) + } + + func passTime(to date: Date) { + clock.now = date + } + + func wake(at date: Date) { + clock.now = date + screenTime.reconcile() + } + + func openApp(at date: Date) { + clock.now = date + timedUnblock.reconcile(screenTimeService: screenTime) + scheduleService.registerAll(schedules, screenTimeService: screenTime) + } + + func blockAll() { + BlockActions.blockAll( + mainSelection: main, + settings: settings, + screenTimeService: screenTime, + timedUnblockService: timedUnblock + ) + } + + func unblockAllPermanently() { + screenTime.unblockAll(blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete) + } + + func blockGroup(_ group: AppGroup) { + BlockActions.blockGroup( + group, + settings: settings, + screenTimeService: screenTime, + timedUnblockService: timedUnblock + ) + } + + func unblockGroupPermanently(_ group: AppGroup) { + screenTime.unblock(selection: group.selection, customDomains: group.customDomains) + } + + func unblockAllTimed(_ duration: TimedUnblockDuration) throws { + try timedUnblock.startMain( + duration: duration, + mainSelection: main, + settings: settings, + screenTimeService: screenTime + ) + } + + func unblockGroupTimed(_ group: AppGroup, _ duration: TimedUnblockDuration) throws { + try timedUnblock.startGroup( + duration: duration, + groupId: group.id, + selection: group.selection, + customDomains: group.customDomains, + screenTimeService: screenTime + ) + } + + func blockAllNow() { + timedUnblock.cancelMain( + selection: main.selection, + customDomains: main.customDomains, + screenTimeService: screenTime, + blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete + ) + } + + func emergencyUnblock() { + BlockActions.emergencyUnblock( + schedules: schedules, + screenTimeService: screenTime, + timedUnblockService: timedUnblock, + scheduleService: scheduleService, + usageLimitService: usageLimits + ) + } + + func group(_ apps: [UInt8], domains: [String] = []) -> AppGroup { + AppGroup(name: "G", selection: TokenFixtures.selection(apps: apps), customDomains: domains) + } + + @discardableResult + func addSchedule( + _ apps: [UInt8], + domains: [String] = [], + from start: (Int, Int), + minutes: Int, + block: Bool, + timed: Bool = true, + weekdays: Set = Set(1 ... 7) + ) throws -> BlockSchedule { + let schedule = BlockSchedule( + name: "S\(schedules.count)", + selection: TokenFixtures.selection(apps: apps), + startHour: start.0, + startMinute: start.1, + durationMinutes: minutes, + weekdays: weekdays, + shouldBlock: block, + isTimed: timed, + isEnabled: true, + customDomains: domains + ) + schedules.append(schedule) + try scheduleService.syncAndPersist(schedule, allSchedules: schedules, screenTimeService: screenTime) + return schedule + } + + func toggle(_ schedule: BlockSchedule) throws { + try scheduleService.toggleEnabled(schedule, allSchedules: schedules, screenTimeService: screenTime) + } + + func delete(_ schedule: BlockSchedule) { + schedules.removeAll { $0.id == schedule.id } + scheduleService.remove(schedule, remaining: schedules, screenTimeService: screenTime) + } +} diff --git a/NormalTests/TestSupport/FakeDeviceActivityCenter.swift b/NormalTests/TestSupport/FakeDeviceActivityCenter.swift index 03978b3..b0c7d38 100644 --- a/NormalTests/TestSupport/FakeDeviceActivityCenter.swift +++ b/NormalTests/TestSupport/FakeDeviceActivityCenter.swift @@ -11,6 +11,9 @@ final class FakeDeviceActivityCenter: DeviceActivityProviding { var startCalls: [StartCall] = [] var stopCalls: [[DeviceActivityName]] = [] var startError: Error? + var activityLimit: Int? + var monitoredActivityNames: Set = [] + var registeredSchedules: [String: DeviceActivitySchedule] = [:] func startMonitoring( _ activityName: DeviceActivityName, @@ -20,10 +23,26 @@ final class FakeDeviceActivityCenter: DeviceActivityProviding { if let startError { throw startError } + if let activityLimit, + !monitoredActivityNames.contains(activityName.rawValue), + monitoredActivityNames.count >= activityLimit + { + throw DeviceActivityCenter.MonitoringError.excessiveActivities + } startCalls.append(StartCall(name: activityName, schedule: schedule, events: events)) + monitoredActivityNames.insert(activityName.rawValue) + registeredSchedules[activityName.rawValue] = schedule + } + + func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? { + registeredSchedules[activityName.rawValue] } func stopMonitoring(_ activityNames: [DeviceActivityName]) { stopCalls.append(activityNames) + monitoredActivityNames.subtract(activityNames.map(\.rawValue)) + for name in activityNames { + registeredSchedules.removeValue(forKey: name.rawValue) + } } } diff --git a/NormalTests/TestSupport/FakeScreenTimeService.swift b/NormalTests/TestSupport/FakeScreenTimeService.swift index 24bd155..ef816f9 100644 --- a/NormalTests/TestSupport/FakeScreenTimeService.swift +++ b/NormalTests/TestSupport/FakeScreenTimeService.swift @@ -9,19 +9,20 @@ final class FakeScreenTimeService: ScreenTimeProviding { var authorizationState: AuthorizationState = .notAuthorized var lastUpdate: Date = .now - var applyShieldOnAllCalled = false - var applyShieldOnAllSelection: FamilyActivitySelection? - var applyShieldOnAllCustomDomains: [String]? - var applyShieldOnAllBlockAllPreventsAppDelete: Bool? - var removeShieldOnAllCalled = false - var removeShieldOnAllBlockAllPreventsAppDelete: Bool? + var blockAllCalled = false + var blockAllSelection: FamilyActivitySelection? + var blockAllCustomDomains: [String]? + var blockAllPreventsAppDelete: Bool? + var unblockAllCalled = false + var unblockAllPreventsAppDelete: Bool? var removeAllRestrictionsCalled = false - var addToShieldsCalled = false - var addToShieldsSelection: FamilyActivitySelection? - var addToShieldsCustomDomains: [String]? - var removeFromShieldsCalled = false - var removeFromShieldsSelection: FamilyActivitySelection? - var removeFromShieldsCustomDomains: [String]? + var blockCalled = false + var blockSelection: FamilyActivitySelection? + var blockCustomDomains: [String]? + var unblockCalled = false + var unblockSelection: FamilyActivitySelection? + var unblockCustomDomains: [String]? + var reconcileCallCount = 0 var enablePreventAppDeleteCalled = false var disablePreventAppDeleteCalled = false var notifyUpdateCallCount = 0 @@ -66,23 +67,23 @@ final class FakeScreenTimeService: ScreenTimeProviding { disablePreventAppDeleteCalled = true } - func applyShieldOnAll( + func blockAll( selection: FamilyActivitySelection, customDomains: [String] = [], blockAllPreventsAppDelete: Bool ) { - applyShieldOnAllCalled = true - applyShieldOnAllSelection = selection - applyShieldOnAllCustomDomains = customDomains - applyShieldOnAllBlockAllPreventsAppDelete = blockAllPreventsAppDelete + blockAllCalled = true + blockAllSelection = selection + blockAllCustomDomains = customDomains + self.blockAllPreventsAppDelete = blockAllPreventsAppDelete if blockAllPreventsAppDelete { enablePreventAppDelete() } } - func removeShieldOnAll(blockAllPreventsAppDelete: Bool) { - removeShieldOnAllCalled = true - removeShieldOnAllBlockAllPreventsAppDelete = blockAllPreventsAppDelete + func unblockAll(blockAllPreventsAppDelete: Bool) { + unblockAllCalled = true + unblockAllPreventsAppDelete = blockAllPreventsAppDelete if blockAllPreventsAppDelete { disablePreventAppDelete() } @@ -93,19 +94,29 @@ final class FakeScreenTimeService: ScreenTimeProviding { disablePreventAppDelete() } - func addToShields(selection: FamilyActivitySelection, customDomains: [String] = []) { - addToShieldsCalled = true - addToShieldsSelection = selection - addToShieldsCustomDomains = customDomains + func block(selection: FamilyActivitySelection, customDomains: [String] = []) { + blockCalled = true + blockSelection = selection + blockCustomDomains = customDomains } - func removeFromShields(selection: FamilyActivitySelection, customDomains: [String] = []) { - removeFromShieldsCalled = true - removeFromShieldsSelection = selection - removeFromShieldsCustomDomains = customDomains + func unblock(selection: FamilyActivitySelection, customDomains: [String] = []) { + unblockCalled = true + unblockSelection = selection + unblockCustomDomains = customDomains } - func clearCustomDomainFilter() {} + func updateMainList( + from _: FamilyActivitySelection, + customDomains _: [String], + to _: FamilyActivitySelection, + customDomains _: [String], + addedBlockedAsOf _: Date + ) {} + + func reconcile() { + reconcileCallCount += 1 + } func activeShieldCount() -> Int { stubActiveShieldCount diff --git a/NormalTests/TestSupport/FakeSharedStore.swift b/NormalTests/TestSupport/FakeSharedStore.swift index 0da8d8f..546e692 100644 --- a/NormalTests/TestSupport/FakeSharedStore.swift +++ b/NormalTests/TestSupport/FakeSharedStore.swift @@ -10,6 +10,8 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { var usageLimits: [UsageLimitDTO] = [] var usageDayState: UsageDayStateDTO = .empty var usageRegistration: String? + var manualBlockState: ManualBlockState? + private(set) var manualBlockStateSaveCount = 0 func loadTimedUnblocks() -> [TimedUnblockDTO] { timedUnblocks @@ -32,11 +34,6 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { timedUnblocks.first { $0.activityName == activityName } } - func isMainTimedUnblockActive() -> Bool { - guard let main = timedUnblocks.first(where: { $0.id == "main" }) else { return false } - return main.endDate > .now - } - func saveSchedules(_ dtos: [ScheduleDTO]) { schedules = dtos } @@ -49,11 +46,6 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { scheduleOverrideActive } - func setScheduleOverrideActive(_ active: Bool) { - scheduleOverrideActive = active - scheduleOverrideSince = active ? .now : nil - } - func loadScheduleOverrideSince() -> Date? { scheduleOverrideSince } @@ -107,4 +99,13 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { func loadUsageLimitConfig() -> UsageLimitConfig { usageLimitConfig } + + func saveManualBlockState(_ state: ManualBlockState) { + manualBlockState = state + manualBlockStateSaveCount += 1 + } + + func loadManualBlockState() -> ManualBlockState? { + manualBlockState + } } diff --git a/NormalTests/TestSupport/StatefulScreenTimeSpy.swift b/NormalTests/TestSupport/StatefulScreenTimeSpy.swift index ee96d3a..319bf0a 100644 --- a/NormalTests/TestSupport/StatefulScreenTimeSpy.swift +++ b/NormalTests/TestSupport/StatefulScreenTimeSpy.swift @@ -49,7 +49,7 @@ final class StatefulScreenTimeSpy: ScreenTimeProviding { checkInvariant() } - func applyShieldOnAll( + func blockAll( selection _: FamilyActivitySelection, customDomains _: [String] = [], blockAllPreventsAppDelete: Bool @@ -61,7 +61,7 @@ final class StatefulScreenTimeSpy: ScreenTimeProviding { checkInvariant() } - func removeShieldOnAll(blockAllPreventsAppDelete: Bool) { + func unblockAll(blockAllPreventsAppDelete: Bool) { shieldActive = false if blockAllPreventsAppDelete { appDeleteDisabled = false @@ -75,16 +75,26 @@ final class StatefulScreenTimeSpy: ScreenTimeProviding { checkInvariant() } - func addToShields(selection _: FamilyActivitySelection, customDomains _: [String] = []) { + func block(selection _: FamilyActivitySelection, customDomains _: [String] = []) { shieldActive = true checkInvariant() } - func removeFromShields(selection _: FamilyActivitySelection, customDomains _: [String] = []) { + func unblock(selection _: FamilyActivitySelection, customDomains _: [String] = []) { checkInvariant() } - func clearCustomDomainFilter() {} + func updateMainList( + from _: FamilyActivitySelection, + customDomains _: [String], + to _: FamilyActivitySelection, + customDomains _: [String], + addedBlockedAsOf _: Date + ) {} + + func reconcile() { + checkInvariant() + } func activeShieldCount() -> Int { shieldActive ? 1 : 0 diff --git a/NormalTests/TestSupport/TokenFixtures.swift b/NormalTests/TestSupport/TokenFixtures.swift index b5d6ecf..24996b3 100644 --- a/NormalTests/TestSupport/TokenFixtures.swift +++ b/NormalTests/TestSupport/TokenFixtures.swift @@ -7,9 +7,19 @@ nonisolated enum TokenFixtures { decode(id) } - static func selection(apps: [UInt8]) -> FamilyActivitySelection { + static func category(_ id: UInt8) -> ActivityCategoryToken { + decode(id) + } + + static func webDomain(_ id: UInt8) -> WebDomainToken { + decode(id) + } + + static func selection(apps: [UInt8], categories: [UInt8] = [], webDomains: [UInt8] = []) -> FamilyActivitySelection { var selection = FamilyActivitySelection() selection.applicationTokens = Set(apps.map(app)) + selection.categoryTokens = Set(categories.map(category)) + selection.webDomainTokens = Set(webDomains.map(webDomain)) return selection } diff --git a/Shared/CallbackLog.swift b/Shared/CallbackLog.swift new file mode 100644 index 0000000..be41b72 --- /dev/null +++ b/Shared/CallbackLog.swift @@ -0,0 +1,34 @@ +import Foundation + +nonisolated enum CallbackLog { + static let maxEntries = 1000 + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: SharedConstants.appGroupID)? + .appending(path: "callbackLog.txt") + } + + static func record(_ message: @autoclosure () -> String, at date: Date = .now) { + #if DEBUG + guard let fileURL else { return } + let timestamp = Date.ISO8601FormatStyle(includingFractionalSeconds: true, timeZone: .current).format(date) + let lines = read() + ["\(timestamp) \(message())"] + try? lines.suffix(maxEntries) + .joined(separator: "\n") + .write(to: fileURL, atomically: true, encoding: .utf8) + #endif + } + + static func read() -> [String] { + guard let fileURL, + let text = try? String(contentsOf: fileURL, encoding: .utf8) + else { return [] } + return text.split(separator: "\n").map(String.init) + } + + static func clear() { + guard let fileURL else { return } + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Shared/CustomDomainFilter.swift b/Shared/CustomDomainFilter.swift index 5e111ce..822c9e7 100644 --- a/Shared/CustomDomainFilter.swift +++ b/Shared/CustomDomainFilter.swift @@ -8,24 +8,10 @@ extension ManagedSettingsStore { return [] } - func unionFilterDomains(_ domains: [String]) { - guard !domains.isEmpty else { return } - writeFilter(WebFilter.union(current: filterDomains(), adding: domains)) - } - - func subtractFilterDomains(_ domains: [String]) { - guard !domains.isEmpty else { return } - writeFilter(WebFilter.subtract(current: filterDomains(), removing: domains)) - } - func replaceFilterDomains(_ domains: [String]) { writeFilter(WebFilter.webDomains(from: domains)) } - func clearFilterDomains() { - webContent.blockedByFilter = nil - } - private func writeFilter(_ domains: Set) { webContent.blockedByFilter = WebFilter.policy(for: domains) } @@ -36,14 +22,6 @@ enum WebFilter { Set(domains.compactMap(DomainNormalizer.normalize).map { WebDomain(domain: $0) }) } - static func union(current: Set, adding domains: [String]) -> Set { - current.union(webDomains(from: domains)) - } - - static func subtract(current: Set, removing domains: [String]) -> Set { - current.subtracting(webDomains(from: domains)) - } - static func policy(for domains: Set) -> WebContentSettings.FilterPolicy? { domains.isEmpty ? nil : .specific(domains) } diff --git a/Shared/ManagedSettingsUtil.swift b/Shared/ManagedSettingsUtil.swift index df3de33..e9cae1d 100644 --- a/Shared/ManagedSettingsUtil.swift +++ b/Shared/ManagedSettingsUtil.swift @@ -13,43 +13,9 @@ extension ShieldSettings.ActivityCategoryPolicy? { } extension ManagedSettingsStore { - func unionShields(with selection: FamilyActivitySelection) { - var apps = shield.applications ?? Set() - apps.formUnion(selection.applicationTokens) - shield.applications = apps - - var web = shield.webDomains ?? Set() - web.formUnion(selection.webDomainTokens) - shield.webDomains = web - - var cats = shield.applicationCategories.tokenSet - cats.formUnion(selection.categoryTokens) - shield.applicationCategories = .from(tokens: cats) - } - - func subtractShields(with selection: FamilyActivitySelection) { - var apps = shield.applications ?? Set() - apps.subtract(selection.applicationTokens) - shield.applications = apps - - var web = shield.webDomains ?? Set() - web.subtract(selection.webDomainTokens) - shield.webDomains = web - - var cats = shield.applicationCategories.tokenSet - cats.subtract(selection.categoryTokens) - shield.applicationCategories = .from(tokens: cats) - } - func replaceShields(with selection: FamilyActivitySelection) { shield.applications = selection.applicationTokens shield.webDomains = selection.webDomainTokens shield.applicationCategories = .from(tokens: selection.categoryTokens) } - - func clearShields() { - shield.applications = nil - shield.webDomains = nil - shield.applicationCategories = nil - } } diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index 5bd9c85..faa3ce0 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -18,6 +18,7 @@ nonisolated enum SharedConstants { static let usageRegistration = "usageRegistration_v1" static let usageLimitsIntervalStart = "usageLimitsIntervalStart_v1" static let usageLimitConfig = "usageLimitConfig_v1" + static let manualBlockState = "manualBlockState_v1" } static let mainTimedUnblockActivityName = "timedUnblock_main" diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index fc2f005..43e9188 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -47,6 +47,8 @@ nonisolated struct ScheduleDTO: Codable, Sendable, Identifiable { let weekdays: Set let shouldBlock: Bool let isTimed: Bool + let isEnabled: Bool? + let activatedAt: Date? private let encodedCustomDomains: [String]? var customDomains: [String] { @@ -63,7 +65,9 @@ nonisolated struct ScheduleDTO: Codable, Sendable, Identifiable { weekdays: Set, shouldBlock: Bool, isTimed: Bool, - customDomains: [String]? = nil + customDomains: [String]? = nil, + isEnabled: Bool? = nil, + activatedAt: Date? = nil ) { self.id = id self.name = name @@ -75,12 +79,14 @@ nonisolated struct ScheduleDTO: Codable, Sendable, Identifiable { self.shouldBlock = shouldBlock self.isTimed = isTimed encodedCustomDomains = customDomains + self.isEnabled = isEnabled + self.activatedAt = activatedAt } } nonisolated extension ScheduleDTO { - var wrapsPastMidnight: Bool { - startHour * 60 + startMinute + durationMinutes >= 24 * 60 + func isEffectivelyEnabled(monitoredActivities: Set) -> Bool { + isEnabled ?? monitoredActivities.contains(SharedConstants.scheduleActivityName(for: id)) } func isActive(at date: Date, calendar: Calendar = .current) -> Bool { @@ -97,12 +103,6 @@ nonisolated extension ScheduleDTO { calendar: calendar ) } - - func endApplies(on date: Date, calendar: Calendar = .current) -> Bool { - let today = calendar.component(.weekday, from: date) - let startWeekday = wrapsPastMidnight ? (today == 1 ? 7 : today - 1) : today - return weekdays.contains(startWeekday) - } } nonisolated extension FamilyActivitySelection { @@ -147,7 +147,13 @@ nonisolated enum ScheduleWindow { bySettingHour: startHour, minute: startMinute, second: 0, of: day ) else { continue } - let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60)) + let end = end( + of: day, + startHour: startHour, + startMinute: startMinute, + durationMinutes: durationMinutes, + calendar: calendar + ) ?? start.addingTimeInterval(TimeInterval(durationMinutes * 60)) if weekdays.contains(calendar.component(.weekday, from: start)), date >= start, date < end { @@ -156,58 +162,21 @@ nonisolated enum ScheduleWindow { } return nil } -} -nonisolated struct ShieldExclusion { - private(set) var selection = FamilyActivitySelection() - private(set) var domains: Set = [] - - static func groupUnblocks(_ unblocks: [TimedUnblockDTO], at date: Date) -> ShieldExclusion { - var exclusion = ShieldExclusion() - for unblock in unblocks where unblock.isGroupUnblock && unblock.endDate > date { - exclusion.add(unblock.selectionData, domains: unblock.customDomains) - } - return exclusion - } - - static func activeSchedules( - _ schedules: [ScheduleDTO], - shouldBlock: Bool, - at date: Date, - excluding excludedID: UUID? = nil, - calendar: Calendar = .current, - isEnabled: (ScheduleDTO) -> Bool - ) -> ShieldExclusion { - var exclusion = ShieldExclusion() - for schedule in schedules - where schedule.id != excludedID - && schedule.shouldBlock == shouldBlock - && isEnabled(schedule) - && schedule.isActive(at: date, calendar: calendar) - { - exclusion.add(schedule.selectionData, domains: schedule.customDomains) - } - return exclusion - } - - func union(_ other: ShieldExclusion) -> ShieldExclusion { - var merged = self - merged.selection = selection.union(other.selection) - merged.domains.formUnion(other.domains) - return merged - } - - private mutating func add(_ selectionData: Data, domains: [String]) { - guard let added = try? FamilyActivitySelection.fromData(selectionData) else { return } - selection = selection.union(added) - self.domains.formUnion(domains) - } - - func applied(to selection: FamilyActivitySelection) -> FamilyActivitySelection { - selection.subtracting(self.selection) - } - - func applied(to domains: [String]) -> [String] { - domains.filter { !self.domains.contains($0) } + private static func end( + of day: Date, + startHour: Int, + startMinute: Int, + durationMinutes: Int, + calendar: Calendar + ) -> Date? { + let endMinute = startHour * 60 + startMinute + durationMinutes + guard let endDay = calendar.date(byAdding: .day, value: endMinute / 1440, to: day) else { return nil } + return calendar.date( + bySettingHour: endMinute % 1440 / 60, + minute: endMinute % 60, + second: 0, + of: endDay + ) } } diff --git a/Shared/SharedStore.swift b/Shared/SharedStore.swift index 228b045..d17e153 100644 --- a/Shared/SharedStore.swift +++ b/Shared/SharedStore.swift @@ -38,13 +38,6 @@ struct SharedStore: SharedStoreProviding, Sendable { loadTimedUnblocks().first { $0.activityName == activityName } } - func isMainTimedUnblockActive() -> Bool { - guard let main = loadTimedUnblocks().first(where: { $0.id == "main" }) else { - return false - } - return main.endDate > Date.now - } - func saveSchedules(_ dtos: [ScheduleDTO]) { let data = try? PropertyListEncoder().encode(dtos) defaults.set(data, forKey: SharedConstants.DefaultsKey.schedules) @@ -61,18 +54,6 @@ struct SharedStore: SharedStoreProviding, Sendable { defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride) } - func setScheduleOverrideActive(_ active: Bool) { - defaults.set(active, forKey: SharedConstants.DefaultsKey.scheduleOverride) - if active { - defaults.set( - Date.now.timeIntervalSinceReferenceDate, - forKey: SharedConstants.DefaultsKey.scheduleOverrideSince - ) - } else { - defaults.removeObject(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) - } - } - func loadScheduleOverrideSince() -> Date? { guard let value = defaults.object(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) as? Double else { return nil } @@ -133,6 +114,16 @@ struct SharedStore: SharedStoreProviding, Sendable { defaults.set(data, forKey: SharedConstants.DefaultsKey.usageLimitConfig) } + func saveManualBlockState(_ state: ManualBlockState) { + let data = try? PropertyListEncoder().encode(state) + defaults.set(data, forKey: SharedConstants.DefaultsKey.manualBlockState) + } + + func loadManualBlockState() -> ManualBlockState? { + guard let data = defaults.data(forKey: SharedConstants.DefaultsKey.manualBlockState) else { return nil } + return try? PropertyListDecoder().decode(ManualBlockState.self, from: data) + } + func loadUsageLimitConfig() -> UsageLimitConfig { guard let data = defaults.data(forKey: SharedConstants.DefaultsKey.usageLimitConfig) else { return UsageLimitConfig() diff --git a/Shared/SharedStoreProviding.swift b/Shared/SharedStoreProviding.swift index 9126b89..e71ff78 100644 --- a/Shared/SharedStoreProviding.swift +++ b/Shared/SharedStoreProviding.swift @@ -6,11 +6,9 @@ nonisolated protocol SharedStoreProviding: Sendable { func upsertTimedUnblock(_ unblock: TimedUnblockDTO) func removeTimedUnblock(id: String) func findTimedUnblock(activityName: String) -> TimedUnblockDTO? - func isMainTimedUnblockActive() -> Bool func saveSchedules(_ dtos: [ScheduleDTO]) func loadSchedules() -> [ScheduleDTO] func isScheduleOverrideActive() -> Bool - func setScheduleOverrideActive(_ active: Bool) func loadScheduleOverrideSince() -> Date? func isCustomDomainsEnabled() -> Bool func setCustomDomainsEnabled(_ enabled: Bool) @@ -24,39 +22,11 @@ nonisolated protocol SharedStoreProviding: Sendable { func loadUsageLimitsIntervalStart() -> Date? func saveUsageLimitConfig(_ config: UsageLimitConfig) func loadUsageLimitConfig() -> UsageLimitConfig -} - -enum ScheduleStartDecision: Equatable { - case skip - case apply + func saveManualBlockState(_ state: ManualBlockState) + func loadManualBlockState() -> ManualBlockState? } extension SharedStoreProviding { - func isUnblockAllInEffect() -> Bool { - isMainTimedUnblockActive() || isScheduleOverrideActive() - } - - func suppressesSchedule(windowStart: Date) -> Bool { - if isMainTimedUnblockActive() { - return true - } - guard isScheduleOverrideActive() else { return false } - guard let since = loadScheduleOverrideSince() else { return true } - return windowStart < since - } - - func resolveScheduleStart(windowStart: Date) -> ScheduleStartDecision { - if isMainTimedUnblockActive() { - return .skip - } - guard isScheduleOverrideActive() else { return .apply } - if let since = loadScheduleOverrideSince(), windowStart < since { - return .skip - } - setScheduleOverrideActive(false) - return .apply - } - var usagePeriod: UsagePeriod { loadUsageLimitConfig().period } diff --git a/Shared/ShieldReconciler.swift b/Shared/ShieldReconciler.swift new file mode 100644 index 0000000..d3e6e51 --- /dev/null +++ b/Shared/ShieldReconciler.swift @@ -0,0 +1,104 @@ +import Foundation +import ManagedSettings + +nonisolated protocol ShieldApplying: AnyObject { + func currentShields() -> ShieldPlan + func apply(_ plan: ShieldPlan) +} + +nonisolated enum SharedFileLock { + private static var url: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: SharedConstants.appGroupID)? + .appending(path: "shields.lock") + } + + static func withLock(_ body: () throws -> T) rethrows -> T? { + guard let path = url?.path(percentEncoded: false) else { return try body() } + let descriptor = open(path, O_CREAT | O_RDWR, 0o644) + guard descriptor >= 0 else { + return errno == EPERM || errno == EACCES ? nil : try body() + } + defer { close(descriptor) } + while flock(descriptor, LOCK_EX) != 0 { + guard errno == EINTR else { return try body() } + } + defer { flock(descriptor, LOCK_UN) } + return try body() + } +} + +nonisolated enum ShieldReconciler { + @discardableResult + static func reconcile( + sharedStore: some SharedStoreProviding, + shields: some ShieldApplying, + monitoredActivities: Set, + at date: Date = .now, + update: (inout ManualBlockState) -> Void = { _ in } + ) -> ShieldPlan? { + SharedFileLock.withLock { + let schedules = sharedStore.loadSchedules() + .filter { $0.isEffectivelyEnabled(monitoredActivities: monitoredActivities) } + let timedUnblocks = sharedStore.loadTimedUnblocks() + let current = shields.currentShields() + + let saved = sharedStore.loadManualBlockState() + var manual = saved + ?? ShieldResolver.migratedManualState( + shielded: current, + enabledSchedules: schedules, + timedUnblocks: timedUnblocks, + scheduleOverrideActive: sharedStore.isScheduleOverrideActive(), + scheduleOverrideSince: sharedStore.loadScheduleOverrideSince(), + at: date + ) + update(&manual) + if manual != saved { + sharedStore.saveManualBlockState(manual) + } + + let plan = ShieldResolver.plan( + manual: manual, + enabledSchedules: schedules, + timedUnblocks: timedUnblocks, + customDomainsEnabled: sharedStore.isCustomDomainsEnabled(), + at: date + ) + if plan.differs(from: current) { + shields.apply(plan) + } + return plan + } + } +} + +nonisolated extension ShieldPlan { + func differs(from current: ShieldPlan) -> Bool { + applications != current.applications + || webDomains != current.webDomains + || categories != current.categories + || customDomains != current.customDomains + || (denyAppRemoval != nil && denyAppRemoval != current.denyAppRemoval) + } +} + +extension ManagedSettingsStore: ShieldApplying { + func currentShields() -> ShieldPlan { + var plan = ShieldPlan() + plan.applications = shield.applications ?? [] + plan.webDomains = shield.webDomains ?? [] + plan.categories = shield.applicationCategories.tokenSet + plan.customDomains = Set(filterDomains().compactMap(\.domain)) + plan.denyAppRemoval = application.denyAppRemoval ?? false + return plan + } + + func apply(_ plan: ShieldPlan) { + replaceShields(with: plan.selection) + replaceFilterDomains(Array(plan.customDomains)) + if let deny = plan.denyAppRemoval { + application.denyAppRemoval = deny + } + } +} diff --git a/Shared/ShieldResolver.swift b/Shared/ShieldResolver.swift new file mode 100644 index 0000000..ca59080 --- /dev/null +++ b/Shared/ShieldResolver.swift @@ -0,0 +1,305 @@ +import FamilyControls +import Foundation +import ManagedSettings + +nonisolated struct ManualEntry: Codable, Equatable { + let isBlocked: Bool + let date: Date +} + +nonisolated struct ManualLayer: Codable, Equatable { + private struct StoredEntry: Codable { + let token: Token + let entry: ManualEntry + } + + private enum CodingKeys: String, CodingKey { + case baseline + case entries + } + + private(set) var baseline = ManualEntry(isBlocked: false, date: .distantPast) + private(set) var entries: [Token: ManualEntry] = [:] + + init() {} + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + baseline = try container.decode(ManualEntry.self, forKey: .baseline) + let stored = try container.decode([StoredEntry].self, forKey: .entries) + entries = Dictionary(stored.map { ($0.token, $0.entry) }, uniquingKeysWith: { _, latest in latest }) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(baseline, forKey: .baseline) + try container.encode(entries.map { StoredEntry(token: $0.key, entry: $0.value) }, forKey: .entries) + } + + var blockedTokens: Set { + Set(entries.filter(\.value.isBlocked).keys) + } + + func entry(for token: Token) -> ManualEntry { + entries[token] ?? baseline + } + + mutating func set(_ tokens: some Sequence, blocked: Bool, at date: Date) { + for token in tokens { + entries[token] = ManualEntry(isBlocked: blocked, date: date) + } + } + + mutating func reset(blocking tokens: some Sequence, at date: Date) { + baseline = ManualEntry(isBlocked: false, date: date) + entries = [:] + set(tokens, blocked: true, at: date) + } +} + +nonisolated struct ManualBlockState: Codable, Equatable { + var applications = ManualLayer() + var webDomains = ManualLayer() + var categories = ManualLayer() + var customDomains = ManualLayer() + var appDeletionBlocked: ManualEntry? + var migratedAt: Date? + + mutating func blockAll(_ selection: FamilyActivitySelection, customDomains domains: [String], at date: Date) { + applications.reset(blocking: selection.applicationTokens, at: date) + webDomains.reset(blocking: selection.webDomainTokens, at: date) + categories.reset(blocking: selection.categoryTokens, at: date) + customDomains.reset(blocking: domains.compactMap(DomainNormalizer.normalize), at: date) + } + + mutating func setAppDeletionBlocked(_ blocked: Bool, at date: Date) { + appDeletionBlocked = ManualEntry(isBlocked: blocked, date: date) + } + + mutating func unblockAll(at date: Date) { + blockAll(FamilyActivitySelection(), customDomains: [], at: date) + } + + mutating func block(_ selection: FamilyActivitySelection, customDomains domains: [String], at date: Date) { + set(selection, customDomains: domains, blocked: true, at: date) + } + + mutating func unblock(_ selection: FamilyActivitySelection, customDomains domains: [String], at date: Date) { + set(selection, customDomains: domains, blocked: false, at: date) + } + + private mutating func set( + _ selection: FamilyActivitySelection, + customDomains domains: [String], + blocked: Bool, + at date: Date + ) { + applications.set(selection.applicationTokens, blocked: blocked, at: date) + webDomains.set(selection.webDomainTokens, blocked: blocked, at: date) + categories.set(selection.categoryTokens, blocked: blocked, at: date) + customDomains.set(domains.compactMap(DomainNormalizer.normalize), blocked: blocked, at: date) + } +} + +nonisolated struct ShieldPlan: Equatable { + var applications: Set = [] + var webDomains: Set = [] + var categories: Set = [] + var customDomains: Set = [] + var denyAppRemoval: Bool? + + var selection: FamilyActivitySelection { + var selection = FamilyActivitySelection() + selection.applicationTokens = applications + selection.webDomainTokens = webDomains + selection.categoryTokens = categories + return selection + } + + mutating func formUnion(_ selection: FamilyActivitySelection, customDomains domains: [String]) { + applications.formUnion(selection.applicationTokens) + webDomains.formUnion(selection.webDomainTokens) + categories.formUnion(selection.categoryTokens) + customDomains.formUnion(domains.compactMap(DomainNormalizer.normalize)) + } +} + +nonisolated enum ShieldResolver { + private struct Claim { + let date: Date + let isBlocked: Bool + let isTimedUnblock: Bool + let coversEverything: Bool + let selection: FamilyActivitySelection + let customDomains: Set + } + + private static let maxTimedUnblockDuration: TimeInterval = 24 * 60 * 60 + + static func plan( + manual: ManualBlockState, + enabledSchedules: [ScheduleDTO], + timedUnblocks: [TimedUnblockDTO], + customDomainsEnabled: Bool, + at date: Date, + calendar: Calendar = .current + ) -> ShieldPlan { + let claims = enabledSchedules.compactMap { + claim(for: $0, migratedAt: manual.migratedAt, at: date, calendar: calendar) + } + + timedUnblocks.compactMap { claim(for: $0, at: date) } + + var plan = ShieldPlan() + plan.applications = resolve(manual.applications, claims, \.selection.applicationTokens) + plan.webDomains = resolve(manual.webDomains, claims, \.selection.webDomainTokens) + plan.categories = resolve(manual.categories, claims, \.selection.categoryTokens) + if customDomainsEnabled { + plan.customDomains = resolve(manual.customDomains, claims, \.customDomains) + } + plan.denyAppRemoval = appDeletionBlocked(manual: manual, timedUnblocks: timedUnblocks, at: date) + return plan + } + + static func migratedManualState( + shielded current: ShieldPlan, + enabledSchedules: [ScheduleDTO], + timedUnblocks: [TimedUnblockDTO], + scheduleOverrideActive: Bool, + scheduleOverrideSince: Date?, + at date: Date + ) -> ManualBlockState { + var restored = ShieldPlan() + for unblock in timedUnblocks { + guard let selection = try? FamilyActivitySelection.fromData(unblock.selectionData) else { continue } + restored.formUnion(selection, customDomains: unblock.customDomains) + } + if !scheduleOverrideActive { + var repaired = ShieldPlan() + var scheduleBlocked = ShieldPlan() + for schedule in enabledSchedules { + guard let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) else { continue } + if schedule.shouldBlock { + scheduleBlocked.formUnion(selection, customDomains: schedule.customDomains) + } else if schedule.isTimed { + repaired.formUnion(selection, customDomains: schedule.customDomains) + } + } + repaired.applications.subtract(scheduleBlocked.applications) + repaired.webDomains.subtract(scheduleBlocked.webDomains) + repaired.categories.subtract(scheduleBlocked.categories) + repaired.customDomains.subtract(scheduleBlocked.customDomains) + restored.formUnion(repaired.selection, customDomains: Array(repaired.customDomains)) + } + restored.applications.subtract(current.applications) + restored.webDomains.subtract(current.webDomains) + restored.categories.subtract(current.categories) + restored.customDomains.subtract(current.customDomains) + + var state = ManualBlockState() + state.migratedAt = date + let shieldedSince = scheduleOverrideActive ? (scheduleOverrideSince ?? date) : .distantPast + state.blockAll(current.selection, customDomains: Array(current.customDomains), at: shieldedSince) + state.block(restored.selection, customDomains: Array(restored.customDomains), at: .distantPast) + let mainUnblockLiftsAppDeletion = timedUnblocks.contains { + !$0.isGroupUnblock && $0.blockAllPreventsAppDelete == true + } + if let deny = current.denyAppRemoval { + state.setAppDeletionBlocked(deny || mainUnblockLiftsAppDeletion, at: .distantPast) + } else if mainUnblockLiftsAppDeletion { + state.setAppDeletionBlocked(true, at: .distantPast) + } + return state + } + + private static func appDeletionBlocked( + manual: ManualBlockState, + timedUnblocks: [TimedUnblockDTO], + at date: Date + ) -> Bool? { + let liftingUnblock = timedUnblocks.first { + !$0.isGroupUnblock && $0.endDate > date && $0.blockAllPreventsAppDelete == true + } + guard let liftingUnblock else { return manual.appDeletionBlocked?.isBlocked } + guard let entry = manual.appDeletionBlocked else { return false } + return entry.date > startDate(of: liftingUnblock) ? entry.isBlocked : false + } + + private static func startDate(of unblock: TimedUnblockDTO) -> Date { + unblock.startDate ?? unblock.endDate.addingTimeInterval(-maxTimedUnblockDuration) + } + + private static func claim( + for schedule: ScheduleDTO, + migratedAt: Date?, + at date: Date, + calendar: Calendar + ) -> Claim? { + let start = schedule.isTimed + ? schedule.activeWindowStart(at: date, calendar: calendar) + : schedule.latestStart(atOrBefore: date, calendar: calendar) + let activation = schedule.activatedAt ?? (schedule.isTimed ? nil : migratedAt) ?? .distantPast + guard let start, + schedule.isTimed || start.addingTimeInterval(TimeInterval(schedule.durationMinutes * 60)) > activation, + let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) + else { return nil } + return Claim( + date: max(start, activation), + isBlocked: schedule.shouldBlock, + isTimedUnblock: false, + coversEverything: false, + selection: selection, + customDomains: Set(schedule.customDomains.compactMap(DomainNormalizer.normalize)) + ) + } + + private static func claim(for unblock: TimedUnblockDTO, at date: Date) -> Claim? { + guard unblock.endDate > date, + let selection = try? FamilyActivitySelection.fromData(unblock.selectionData) + else { return nil } + return Claim( + date: startDate(of: unblock), + isBlocked: false, + isTimedUnblock: true, + coversEverything: !unblock.isGroupUnblock, + selection: selection, + customDomains: Set(unblock.customDomains.compactMap(DomainNormalizer.normalize)) + ) + } + + private static func resolve( + _ manual: ManualLayer, + _ claims: [Claim], + _ tokens: KeyPath> + ) -> Set { + var candidates = Set(manual.entries.keys) + for claim in claims { + candidates.formUnion(claim[keyPath: tokens]) + } + return candidates.filter { token in + let covering = claims.filter { $0.coversEverything || $0[keyPath: tokens].contains(token) } + let timedUnblocks = covering.filter(\.isTimedUnblock) + var winner = manual.entry(for: token) + for claim in timedUnblocks.isEmpty ? covering : timedUnblocks { + if claim.date > winner.date || (claim.date == winner.date && claim.isBlocked) { + winner = ManualEntry(isBlocked: claim.isBlocked, date: claim.date) + } + } + return winner.isBlocked + } + } +} + +nonisolated extension ScheduleDTO { + func latestStart(atOrBefore date: Date, calendar: Calendar = .current) -> Date? { + let startOfToday = calendar.startOfDay(for: date) + for dayOffset in 0 ... 7 { + guard let day = calendar.date(byAdding: .day, value: -dayOffset, to: startOfToday), + let start = calendar.date(bySettingHour: startHour, minute: startMinute, second: 0, of: day) + else { continue } + if start <= date, weekdays.contains(calendar.component(.weekday, from: start)) { + return start + } + } + return nil + } +}