From 8c8e01e532fb416b1f240aefa06167527a5925cf Mon Sep 17 00:00:00 2001 From: ykisana Date: Sun, 11 Oct 2026 14:07:01 -0600 Subject: [PATCH] fix: conflicting blocks --- Normal/Resources/Localizable.xcstrings | 12 +++-- Normal/Services/ScheduleService.swift | 6 ++- Normal/Shared/Util/FamilyControlsUtil.swift | 8 ---- NormalMonitor/NormalMonitor.swift | 15 ++++-- .../Services/ScheduleServiceTests.swift | 22 +++++++++ .../Shared/GroupUnblockExclusionTests.swift | 46 +++++++++++++++++++ NormalTests/TestSupport/TokenFixtures.swift | 20 ++++++++ Shared/SharedDTO.swift | 29 ++++++++++++ 8 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 NormalTests/Shared/GroupUnblockExclusionTests.swift create mode 100644 NormalTests/TestSupport/TokenFixtures.swift diff --git a/Normal/Resources/Localizable.xcstrings b/Normal/Resources/Localizable.xcstrings index 8f43f52..fefb7ec 100644 --- a/Normal/Resources/Localizable.xcstrings +++ b/Normal/Resources/Localizable.xcstrings @@ -281,13 +281,14 @@ "comment" : "A footer description for the custom domains section in the general settings view.", "isCommentAutoGenerated" : true }, - "After %@ of combined use, these stay blocked until %@, even while everything else is unblocked. Time already used today counts; if you're already past it, the limit starts at the next reset." : { - "comment" : "Explains a Max Daily Limit. The first argument is the allowance, e.g. “1 hour”; the second is the reset time.", + "After %@ of combined use, these stay blocked until %@, even if unblocked with a key. Time already used today counts; if you're already past it, the limit starts at the next reset." : { + "comment" : "A description of how long a usage limit lasts. The argument is the number of minutes.", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "After %1$@ of combined use, these stay blocked until %2$@, even while everything else is unblocked. Time already used today counts; if you're already past it, the limit starts at the next reset." + "value" : "After %1$@ of combined use, these stay blocked until %2$@, even if unblocked with a key. Time already used today counts; if you're already past it, the limit starts at the next reset." } } } @@ -1131,8 +1132,9 @@ "comment" : "A label that appears below a donation amount, indicating that it's a one-time gift.", "isCommentAutoGenerated" : true }, - "Once a limit runs out, its apps stay blocked until the reset, even while everything else is unblocked." : { - "comment" : "Footer under the list of Max Daily Limits." + "Once a limit runs out, its apps stay blocked until the reset, even if unblocked with a key." : { + "comment" : "A description of the behavior of usage limits.", + "isCommentAutoGenerated" : true }, "One-time tip" : { "comment" : "A label displayed as the footer of a donation view section that offers one-time tips.", diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 349bd7a..214a5ee 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -106,7 +106,11 @@ final class ScheduleService { let domains = effectiveDomains(schedule) if schedule.shouldBlock { guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return } - screenTimeService.addToShields(selection: schedule.selection, customDomains: domains) + let exclusion = GroupUnblockExclusion(unblocks: 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) } diff --git a/Normal/Shared/Util/FamilyControlsUtil.swift b/Normal/Shared/Util/FamilyControlsUtil.swift index 9444334..af3bb52 100644 --- a/Normal/Shared/Util/FamilyControlsUtil.swift +++ b/Normal/Shared/Util/FamilyControlsUtil.swift @@ -62,14 +62,6 @@ extension FamilyActivitySelection { } } - func subtracting(_ other: FamilyActivitySelection) -> FamilyActivitySelection { - var result = self - result.applicationTokens.subtract(other.applicationTokens) - result.webDomainTokens.subtract(other.webDomainTokens) - result.categoryTokens.subtract(other.categoryTokens) - return result - } - func intersection(_ other: FamilyActivitySelection) -> FamilyActivitySelection { var result = self result.applicationTokens.formIntersection(other.applicationTokens) diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 5808024..ce1fadb 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -104,8 +104,9 @@ final class NormalMonitor: DeviceActivityMonitor { let domains = gatedDomains(schedule.customDomains) if schedule.shouldBlock { - store.unionShields(with: selection) - store.unionFilterDomains(domains) + let exclusion = activeGroupUnblockExclusion() + store.unionShields(with: exclusion.applied(to: selection)) + store.unionFilterDomains(exclusion.applied(to: domains)) } else { store.subtractShields(with: selection) store.subtractFilterDomains(domains) @@ -124,11 +125,17 @@ final class NormalMonitor: DeviceActivityMonitor { store.subtractShields(with: selection) store.subtractFilterDomains(domains) } else { - store.unionShields(with: selection) - store.unionFilterDomains(domains) + guard !sharedStore.isUnblockAllInEffect() else { return } + let exclusion = activeGroupUnblockExclusion() + 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 gatedDomains(_ domains: [String]) -> [String] { sharedStore.isCustomDomainsEnabled() ? domains : [] } diff --git a/NormalTests/Services/ScheduleServiceTests.swift b/NormalTests/Services/ScheduleServiceTests.swift index 95102c6..c921ecd 100644 --- a/NormalTests/Services/ScheduleServiceTests.swift +++ b/NormalTests/Services/ScheduleServiceTests.swift @@ -205,6 +205,28 @@ struct ScheduleServiceTests { #expect(store.isScheduleOverrideActive()) } + @Test func activeScheduleLeavesActiveGroupUnblockAppsUnblocked() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.customDomainsEnabled = true + 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"] + )] + let schedule = activeNowSchedule() + schedule.selection = TokenFixtures.selection(apps: [1, 2]) + schedule.customDomains = ["a.com", "b.com"] + + try service.sync(schedule, screenTimeService: screenTime) + + #expect(screenTime.addToShieldsSelection?.applicationTokens == [TokenFixtures.app(1)]) + #expect(screenTime.addToShieldsCustomDomains == ["a.com"]) + } + @Test func setScheduleOverrideWritesFlag() { let (service, _, store) = makeService() diff --git a/NormalTests/Shared/GroupUnblockExclusionTests.swift b/NormalTests/Shared/GroupUnblockExclusionTests.swift new file mode 100644 index 0000000..b3df439 --- /dev/null +++ b/NormalTests/Shared/GroupUnblockExclusionTests.swift @@ -0,0 +1,46 @@ +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/TestSupport/TokenFixtures.swift b/NormalTests/TestSupport/TokenFixtures.swift new file mode 100644 index 0000000..01ebbed --- /dev/null +++ b/NormalTests/TestSupport/TokenFixtures.swift @@ -0,0 +1,20 @@ +import FamilyControls +import Foundation +import ManagedSettings + +enum TokenFixtures { + static func app(_ id: UInt8) -> ApplicationToken { + decode(id) + } + + static func selection(apps: [UInt8]) -> FamilyActivitySelection { + var selection = FamilyActivitySelection() + selection.applicationTokens = Set(apps.map(app)) + return selection + } + + private static func decode(_ id: UInt8) -> T { + let json = #"{"data":"\#(Data([id]).base64EncodedString())"}"# + return try! JSONDecoder().decode(T.self, from: Data(json.utf8)) + } +} diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index d6a4b94..e70d43a 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -121,6 +121,14 @@ extension FamilyActivitySelection { merged.categoryTokens.formUnion(other.categoryTokens) return merged } + + func subtracting(_ other: FamilyActivitySelection) -> FamilyActivitySelection { + var result = self + result.applicationTokens.subtract(other.applicationTokens) + result.webDomainTokens.subtract(other.webDomainTokens) + result.categoryTokens.subtract(other.categoryTokens) + return result + } } nonisolated enum ScheduleWindow { @@ -149,3 +157,24 @@ nonisolated enum ScheduleWindow { return nil } } + +nonisolated struct GroupUnblockExclusion { + private(set) var selection = FamilyActivitySelection() + private(set) var domains: Set = [] + + init(unblocks: [TimedUnblockDTO], at date: Date) { + 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) + } + } + + func applied(to selection: FamilyActivitySelection) -> FamilyActivitySelection { + selection.subtracting(self.selection) + } + + func applied(to domains: [String]) -> [String] { + domains.filter { !self.domains.contains($0) } + } +}