diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 214a5ee..5677ec5 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -106,7 +106,7 @@ final class ScheduleService { let domains = effectiveDomains(schedule) if schedule.shouldBlock { guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return } - let exclusion = GroupUnblockExclusion(unblocks: sharedStore.loadTimedUnblocks(), at: .now) + let exclusion = ShieldExclusion.groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now) screenTimeService.addToShields( selection: exclusion.applied(to: schedule.selection), customDomains: exclusion.applied(to: domains) diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index ce1fadb..dc258b0 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -121,19 +121,32 @@ final class NormalMonitor: DeviceActivityMonitor { else { return } let domains = gatedDomains(schedule.customDomains) + let stillActive = otherActiveSchedules(like: schedule) if schedule.shouldBlock { - store.subtractShields(with: selection) - store.subtractFilterDomains(domains) + store.subtractShields(with: stillActive.applied(to: selection)) + store.subtractFilterDomains(stillActive.applied(to: domains)) } else { guard !sharedStore.isUnblockAllInEffect() else { return } - let exclusion = activeGroupUnblockExclusion() + let exclusion = activeGroupUnblockExclusion().union(stillActive) store.unionShields(with: exclusion.applied(to: selection)) store.unionFilterDomains(exclusion.applied(to: domains)) } } - private func activeGroupUnblockExclusion() -> GroupUnblockExclusion { - GroupUnblockExclusion(unblocks: sharedStore.loadTimedUnblocks(), at: .now) + private func activeGroupUnblockExclusion() -> ShieldExclusion { + .groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now) + } + + private func otherActiveSchedules(like schedule: ScheduleDTO) -> ShieldExclusion { + let monitored = Set(DeviceActivityCenter().activities.map(\.rawValue)) + return .activeSchedules( + sharedStore.loadSchedules(), + shouldBlock: schedule.shouldBlock, + at: .now, + excluding: schedule.id + ) { other in + monitored.contains(SharedConstants.scheduleActivityName(for: other.id)) + } } private func gatedDomains(_ domains: [String]) -> [String] { diff --git a/NormalTests/Shared/GroupUnblockExclusionTests.swift b/NormalTests/Shared/GroupUnblockExclusionTests.swift deleted file mode 100644 index b3df439..0000000 --- a/NormalTests/Shared/GroupUnblockExclusionTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import FamilyControls -import Foundation -@testable import Normal -import Testing - -struct GroupUnblockExclusionTests { - 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 - ) - } - - @Test func removesAppsAndDomainsOfActiveGroupUnblocks() throws { - let exclusion = try GroupUnblockExclusion( - unblocks: [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 GroupUnblockExclusion( - unblocks: [ - 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)]) - } -} diff --git a/NormalTests/Shared/ShieldExclusionTests.swift b/NormalTests/Shared/ShieldExclusionTests.swift new file mode 100644 index 0000000..7da9538 --- /dev/null +++ b/NormalTests/Shared/ShieldExclusionTests.swift @@ -0,0 +1,125 @@ +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)]) + } +} diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index e70d43a..82f094c 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -158,16 +158,49 @@ nonisolated enum ScheduleWindow { } } -nonisolated struct GroupUnblockExclusion { +nonisolated struct ShieldExclusion { private(set) var selection = FamilyActivitySelection() private(set) var domains: Set = [] - init(unblocks: [TimedUnblockDTO], at date: Date) { + static func groupUnblocks(_ unblocks: [TimedUnblockDTO], at date: Date) -> ShieldExclusion { + var exclusion = ShieldExclusion() for unblock in unblocks where unblock.isGroupUnblock && unblock.endDate > date { - guard let unblocked = try? FamilyActivitySelection.fromData(unblock.selectionData) else { continue } - selection = selection.union(unblocked) - domains.formUnion(unblock.customDomains) + exclusion.add(unblock.selectionData, domains: unblock.customDomains) } + return exclusion + } + + static func activeSchedules( + _ schedules: [ScheduleDTO], + shouldBlock: Bool, + at date: Date, + excluding excludedID: UUID, + 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 {