Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Normal/Services/ScheduleService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 18 additions & 5 deletions NormalMonitor/NormalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
46 changes: 0 additions & 46 deletions NormalTests/Shared/GroupUnblockExclusionTests.swift

This file was deleted.

125 changes: 125 additions & 0 deletions NormalTests/Shared/ShieldExclusionTests.swift
Original file line number Diff line number Diff line change
@@ -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)])
}
}
43 changes: 38 additions & 5 deletions Shared/SharedDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,20 +158,53 @@
}
}

nonisolated struct GroupUnblockExclusion {
nonisolated struct ShieldExclusion {
private(set) var selection = FamilyActivitySelection()
private(set) var domains: Set<String> = []

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)

Check warning on line 168 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

main actor-isolated property 'customDomains' can not be referenced from a nonisolated context

Check warning on line 168 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

main actor-isolated property 'customDomains' can not be referenced from a nonisolated context
}
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)

Check warning on line 186 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'isActive(at:calendar:)' in a synchronous nonisolated context [#ActorIsolatedCall]

Check warning on line 186 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'isActive(at:calendar:)' in a synchronous nonisolated context [#ActorIsolatedCall]
{
exclusion.add(schedule.selectionData, domains: schedule.customDomains)

Check warning on line 188 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

main actor-isolated property 'customDomains' can not be referenced from a nonisolated context

Check warning on line 188 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

main actor-isolated property 'customDomains' can not be referenced from a nonisolated context
}
return exclusion
}

func union(_ other: ShieldExclusion) -> ShieldExclusion {
var merged = self
merged.selection = selection.union(other.selection)

Check warning on line 195 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'union' in a synchronous nonisolated context [#ActorIsolatedCall]

Check warning on line 195 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'union' in a synchronous nonisolated context [#ActorIsolatedCall]
merged.domains.formUnion(other.domains)
return merged
}

private mutating func add(_ selectionData: Data, domains: [String]) {
guard let added = try? FamilyActivitySelection.fromData(selectionData) else { return }

Check warning on line 201 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated static method 'fromData' in a synchronous nonisolated context [#ActorIsolatedCall]

Check warning on line 201 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated static method 'fromData' in a synchronous nonisolated context [#ActorIsolatedCall]
selection = selection.union(added)

Check warning on line 202 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'union' in a synchronous nonisolated context [#ActorIsolatedCall]

Check warning on line 202 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'union' in a synchronous nonisolated context [#ActorIsolatedCall]
self.domains.formUnion(domains)
}

func applied(to selection: FamilyActivitySelection) -> FamilyActivitySelection {
selection.subtracting(self.selection)

Check warning on line 207 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'subtracting' in a synchronous nonisolated context [#ActorIsolatedCall]

Check warning on line 207 in Shared/SharedDTO.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated instance method 'subtracting' in a synchronous nonisolated context [#ActorIsolatedCall]
}

func applied(to domains: [String]) -> [String] {
Expand Down
Loading