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
12 changes: 7 additions & 5 deletions Normal/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 5 additions & 1 deletion Normal/Services/ScheduleService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 0 additions & 8 deletions Normal/Shared/Util/FamilyControlsUtil.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 11 additions & 4 deletions NormalMonitor/NormalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 : []
}
Expand Down
22 changes: 22 additions & 0 deletions NormalTests/Services/ScheduleServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
46 changes: 46 additions & 0 deletions NormalTests/Shared/GroupUnblockExclusionTests.swift
Original file line number Diff line number Diff line change
@@ -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)])
}
}
20 changes: 20 additions & 0 deletions NormalTests/TestSupport/TokenFixtures.swift
Original file line number Diff line number Diff line change
@@ -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))

Check warning on line 12 in NormalTests/TestSupport/TokenFixtures.swift

View workflow job for this annotation

GitHub Actions / Unit tests

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

Check warning on line 12 in NormalTests/TestSupport/TokenFixtures.swift

View workflow job for this annotation

GitHub Actions / Unit tests

call to main actor-isolated static method 'app' in a synchronous nonisolated context [#ActorIsolatedCall]
return selection
}

private static func decode<T: Decodable>(_ id: UInt8) -> T {
let json = #"{"data":"\#(Data([id]).base64EncodedString())"}"#
return try! JSONDecoder().decode(T.self, from: Data(json.utf8))
}
}
29 changes: 29 additions & 0 deletions Shared/SharedDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@
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 {
Expand Down Expand Up @@ -149,3 +157,24 @@
return nil
}
}

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

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 }

Check warning on line 167 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 167 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(unblocked)

Check warning on line 168 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 168 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]
domains.formUnion(unblock.customDomains)

Check warning on line 169 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 169 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
}
}

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

Check warning on line 174 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 174 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] {
domains.filter { !self.domains.contains($0) }
}
}
Loading