Skip to content

Commit cfd3fd2

Browse files
authored
Merge pull request #66 from normalengineering/fix-schedule-conflicts
fix: schedule conflicts followup
2 parents 1851da8 + 43804a2 commit cfd3fd2

5 files changed

Lines changed: 182 additions & 57 deletions

File tree

‎Normal/Services/ScheduleService.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ final class ScheduleService {
106106
let domains = effectiveDomains(schedule)
107107
if schedule.shouldBlock {
108108
guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return }
109-
let exclusion = GroupUnblockExclusion(unblocks: sharedStore.loadTimedUnblocks(), at: .now)
109+
let exclusion = ShieldExclusion.groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now)
110110
screenTimeService.addToShields(
111111
selection: exclusion.applied(to: schedule.selection),
112112
customDomains: exclusion.applied(to: domains)

‎NormalMonitor/NormalMonitor.swift‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,32 @@ final class NormalMonitor: DeviceActivityMonitor {
121121
else { return }
122122

123123
let domains = gatedDomains(schedule.customDomains)
124+
let stillActive = otherActiveSchedules(like: schedule)
124125
if schedule.shouldBlock {
125-
store.subtractShields(with: selection)
126-
store.subtractFilterDomains(domains)
126+
store.subtractShields(with: stillActive.applied(to: selection))
127+
store.subtractFilterDomains(stillActive.applied(to: domains))
127128
} else {
128129
guard !sharedStore.isUnblockAllInEffect() else { return }
129-
let exclusion = activeGroupUnblockExclusion()
130+
let exclusion = activeGroupUnblockExclusion().union(stillActive)
130131
store.unionShields(with: exclusion.applied(to: selection))
131132
store.unionFilterDomains(exclusion.applied(to: domains))
132133
}
133134
}
134135

135-
private func activeGroupUnblockExclusion() -> GroupUnblockExclusion {
136-
GroupUnblockExclusion(unblocks: sharedStore.loadTimedUnblocks(), at: .now)
136+
private func activeGroupUnblockExclusion() -> ShieldExclusion {
137+
.groupUnblocks(sharedStore.loadTimedUnblocks(), at: .now)
138+
}
139+
140+
private func otherActiveSchedules(like schedule: ScheduleDTO) -> ShieldExclusion {
141+
let monitored = Set(DeviceActivityCenter().activities.map(\.rawValue))
142+
return .activeSchedules(
143+
sharedStore.loadSchedules(),
144+
shouldBlock: schedule.shouldBlock,
145+
at: .now,
146+
excluding: schedule.id
147+
) { other in
148+
monitored.contains(SharedConstants.scheduleActivityName(for: other.id))
149+
}
137150
}
138151

139152
private func gatedDomains(_ domains: [String]) -> [String] {

‎NormalTests/Shared/GroupUnblockExclusionTests.swift‎

Lines changed: 0 additions & 46 deletions
This file was deleted.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import FamilyControls
2+
import Foundation
3+
@testable import Normal
4+
import Testing
5+
6+
struct ShieldExclusionTests {
7+
private let cal: Calendar = {
8+
var c = Calendar(identifier: .gregorian)
9+
c.timeZone = TimeZone(identifier: "UTC")!
10+
return c
11+
}()
12+
13+
// Wednesday, June 5 2024, 12:00 UTC.
14+
private var noonWednesday: Date {
15+
cal.date(from: DateComponents(year: 2024, month: 6, day: 5, hour: 12))!
16+
}
17+
18+
private func unblock(
19+
id: String,
20+
apps: [UInt8],
21+
domains: [String] = [],
22+
endDate: Date = .now.addingTimeInterval(.hours(1))
23+
) throws -> TimedUnblockDTO {
24+
try TimedUnblockDTO(
25+
id: id,
26+
selectionData: TokenFixtures.selection(apps: apps).toData(),
27+
endDate: endDate,
28+
activityName: "timedUnblock_\(id)",
29+
isGroupUnblock: id != TimedUnblockService.mainID,
30+
customDomains: domains
31+
)
32+
}
33+
34+
private func schedule(
35+
apps: [UInt8],
36+
domains: [String] = [],
37+
startHour: Int = 10,
38+
durationMinutes: Int = 240,
39+
shouldBlock: Bool = true
40+
) throws -> ScheduleDTO {
41+
try ScheduleDTO(
42+
id: UUID(),
43+
name: "S",
44+
selectionData: TokenFixtures.selection(apps: apps).toData(),
45+
startHour: startHour,
46+
startMinute: 0,
47+
durationMinutes: durationMinutes,
48+
weekdays: [4],
49+
shouldBlock: shouldBlock,
50+
isTimed: true,
51+
customDomains: domains
52+
)
53+
}
54+
55+
@Test func removesAppsAndDomainsOfActiveGroupUnblocks() throws {
56+
let exclusion = try ShieldExclusion.groupUnblocks(
57+
[unblock(id: "g1", apps: [2], domains: ["b.com"])],
58+
at: .now
59+
)
60+
61+
let remaining = exclusion.applied(to: TokenFixtures.selection(apps: [1, 2]))
62+
#expect(remaining.applicationTokens == [TokenFixtures.app(1)])
63+
#expect(exclusion.applied(to: ["a.com", "b.com"]) == ["a.com"])
64+
}
65+
66+
@Test func ignoresExpiredGroupUnblocksAndMainUnblock() throws {
67+
let exclusion = try ShieldExclusion.groupUnblocks(
68+
[
69+
unblock(id: "g1", apps: [1], endDate: .now.addingTimeInterval(-60)),
70+
unblock(id: TimedUnblockService.mainID, apps: [2]),
71+
],
72+
at: .now
73+
)
74+
75+
let remaining = exclusion.applied(to: TokenFixtures.selection(apps: [1, 2]))
76+
#expect(remaining.applicationTokens == [TokenFixtures.app(1), TokenFixtures.app(2)])
77+
}
78+
79+
@Test func keepsAppsOfOtherActiveSchedulesOfSameKind() throws {
80+
let ending = try schedule(apps: [1, 2], domains: ["a.com", "b.com"], startHour: 9, durationMinutes: 180)
81+
let stillRunning = try schedule(apps: [2, 3], domains: ["b.com"])
82+
83+
let keep = ShieldExclusion.activeSchedules(
84+
[ending, stillRunning],
85+
shouldBlock: true,
86+
at: noonWednesday,
87+
excluding: ending.id,
88+
calendar: cal
89+
) { _ in true }
90+
91+
#expect(keep.applied(to: TokenFixtures.selection(apps: [1, 2])).applicationTokens == [TokenFixtures.app(1)])
92+
#expect(keep.applied(to: ["a.com", "b.com"]) == ["a.com"])
93+
}
94+
95+
@Test func ignoresDisabledInactiveAndOtherKindSchedules() throws {
96+
let ending = try schedule(apps: [1], startHour: 9, durationMinutes: 180)
97+
let disabled = try schedule(apps: [1])
98+
let inactive = try schedule(apps: [1], startHour: 14, durationMinutes: 60)
99+
let allowing = try schedule(apps: [1], shouldBlock: false)
100+
101+
let keep = ShieldExclusion.activeSchedules(
102+
[ending, disabled, inactive, allowing],
103+
shouldBlock: true,
104+
at: noonWednesday,
105+
excluding: ending.id,
106+
calendar: cal
107+
) { $0.id != disabled.id }
108+
109+
#expect(keep.applied(to: TokenFixtures.selection(apps: [1])).applicationTokens == [TokenFixtures.app(1)])
110+
}
111+
112+
@Test func unionCombinesBothExclusions() throws {
113+
let groups = try ShieldExclusion.groupUnblocks([unblock(id: "g1", apps: [1])], at: .now)
114+
let schedules = try ShieldExclusion.activeSchedules(
115+
[schedule(apps: [2])],
116+
shouldBlock: true,
117+
at: noonWednesday,
118+
excluding: UUID(),
119+
calendar: cal
120+
) { _ in true }
121+
122+
let remaining = groups.union(schedules).applied(to: TokenFixtures.selection(apps: [1, 2, 3]))
123+
#expect(remaining.applicationTokens == [TokenFixtures.app(3)])
124+
}
125+
}

‎Shared/SharedDTO.swift‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,16 +158,49 @@ nonisolated enum ScheduleWindow {
158158
}
159159
}
160160

161-
nonisolated struct GroupUnblockExclusion {
161+
nonisolated struct ShieldExclusion {
162162
private(set) var selection = FamilyActivitySelection()
163163
private(set) var domains: Set<String> = []
164164

165-
init(unblocks: [TimedUnblockDTO], at date: Date) {
165+
static func groupUnblocks(_ unblocks: [TimedUnblockDTO], at date: Date) -> ShieldExclusion {
166+
var exclusion = ShieldExclusion()
166167
for unblock in unblocks where unblock.isGroupUnblock && unblock.endDate > date {
167-
guard let unblocked = try? FamilyActivitySelection.fromData(unblock.selectionData) else { continue }
168-
selection = selection.union(unblocked)
169-
domains.formUnion(unblock.customDomains)
168+
exclusion.add(unblock.selectionData, domains: unblock.customDomains)
170169
}
170+
return exclusion
171+
}
172+
173+
static func activeSchedules(
174+
_ schedules: [ScheduleDTO],
175+
shouldBlock: Bool,
176+
at date: Date,
177+
excluding excludedID: UUID,
178+
calendar: Calendar = .current,
179+
isEnabled: (ScheduleDTO) -> Bool
180+
) -> ShieldExclusion {
181+
var exclusion = ShieldExclusion()
182+
for schedule in schedules
183+
where schedule.id != excludedID
184+
&& schedule.shouldBlock == shouldBlock
185+
&& isEnabled(schedule)
186+
&& schedule.isActive(at: date, calendar: calendar)
187+
{
188+
exclusion.add(schedule.selectionData, domains: schedule.customDomains)
189+
}
190+
return exclusion
191+
}
192+
193+
func union(_ other: ShieldExclusion) -> ShieldExclusion {
194+
var merged = self
195+
merged.selection = selection.union(other.selection)
196+
merged.domains.formUnion(other.domains)
197+
return merged
198+
}
199+
200+
private mutating func add(_ selectionData: Data, domains: [String]) {
201+
guard let added = try? FamilyActivitySelection.fromData(selectionData) else { return }
202+
selection = selection.union(added)
203+
self.domains.formUnion(domains)
171204
}
172205

173206
func applied(to selection: FamilyActivitySelection) -> FamilyActivitySelection {

0 commit comments

Comments
 (0)