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
5 changes: 5 additions & 0 deletions Normal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
2F4718A12F556F340013F94D /* Exceptions for "Shared" folder in "NormalMonitor" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CallbackLog.swift,
CustomDomainFilter.swift,
DailyLimitEnforcement.swift,
DataExtension.swift,
Expand All @@ -90,6 +91,8 @@
SharedDTO.swift,
SharedStore.swift,
SharedStoreProviding.swift,
ShieldReconciler.swift,
ShieldResolver.swift,
UsageDTO.swift,
);
target = 2F4718642F5569C90013F94D /* NormalMonitor */;
Expand Down Expand Up @@ -171,10 +174,12 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
DataExtension.swift,
DomainNormalizer.swift,
SharedConstants.swift,
SharedDTO.swift,
SharedStore.swift,
SharedStoreProviding.swift,
ShieldResolver.swift,
UsageDTO.swift,
);
target = 2FDA0000000000000000B003 /* NormalShield */;
Expand Down
4 changes: 1 addition & 3 deletions Normal/App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ struct AppContainer: View {
.task { mirrorCustomDomainsEnabled() }
.onChange(of: allSettings.first?.enableCustomDomains ?? false) { _, enabled in
scheduleService.mirrorCustomDomainsEnabled(enabled)
if !enabled {
screenTimeService.clearCustomDomainFilter()
}
screenTimeService.reconcile()
}
}

Expand Down
11 changes: 6 additions & 5 deletions Normal/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,17 @@ final class AppServices {
let usageLimit: UsageLimitService

init() {
let screenTime = ScreenTimeService()
self.screenTime = screenTime

if UITestSupport.isActive {
let center = UITestDeviceActivityCenter()
let store = SharedStore(
defaults: UserDefaults(suiteName: "uitest-\(UUID().uuidString)")
)
let screenTime = ScreenTimeService(sharedStore: store, activityCenter: center)
self.screenTime = screenTime
timedUnblock = TimedUnblockService(
activityCenter: center,
sharedStore: store,
onExpiration: { screenTime.notifyUpdate() }
onExpiration: { screenTime.reconcile() }
)
schedule = ScheduleService(activityCenter: center, sharedStore: store)
appReview = AppReviewService(
Expand All @@ -34,7 +33,9 @@ final class AppServices {
ledger: InMemoryUsageLimitLedger()
)
} else {
timedUnblock = TimedUnblockService(onExpiration: { screenTime.notifyUpdate() })
let screenTime = ScreenTimeService()
self.screenTime = screenTime
timedUnblock = TimedUnblockService(onExpiration: { screenTime.reconcile() })
schedule = ScheduleService()
appReview = AppReviewService()
emergencyUnblock = EmergencyUnblockService(ledger: KeychainEmergencyUnblockLedger())
Expand Down
4 changes: 2 additions & 2 deletions Normal/App/NormalApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ struct NormalApp: App {
}

if UITestSupport.isActive {
context.insert(SelectedApps(selection: FamilyActivitySelection()))
context.insert(SelectedApps(selection: UITestSupport.mainSelection))
if !UITestSupport.noKeys {
context.insert(Key(name: "Test Key", type: .qr, rawValue: UITestSupport.stubScanValue))
}
if UITestSupport.seedGroupKey {
let group = AppGroup(
id: UITestSupport.seedGroupID,
name: "Test Group",
selection: FamilyActivitySelection()
selection: UITestSupport.groupSelection
)
context.insert(group)
context.insert(Key(name: "Group Key", type: .qr, rawValue: "GROUP-VALUE", groupID: group.id))
Expand Down
24 changes: 20 additions & 4 deletions Normal/App/UITestDeviceActivityCenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,27 @@ import DeviceActivity
import Foundation

final class UITestDeviceActivityCenter: DeviceActivityProviding {
private var schedules: [String: DeviceActivitySchedule] = [:]

var monitoredActivityNames: Set<String> {
Set(schedules.keys)
}

func startMonitoring(
_: DeviceActivityName,
during _: DeviceActivitySchedule,
_ activityName: DeviceActivityName,
during schedule: DeviceActivitySchedule,
events _: [DeviceActivityEvent.Name: DeviceActivityEvent]
) throws {}
) throws {
schedules[activityName.rawValue] = schedule
}

func stopMonitoring(_ activityNames: [DeviceActivityName]) {
for name in activityNames {
schedules.removeValue(forKey: name.rawValue)
}
}

func stopMonitoring(_: [DeviceActivityName]) {}
func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? {
schedules[activityName.rawValue]
}
}
15 changes: 15 additions & 0 deletions Normal/App/UITestSupport.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import FamilyControls
import Foundation
import ManagedSettings

enum UITestSupport {
private static let arguments = ProcessInfo.processInfo.arguments
Expand Down Expand Up @@ -32,6 +34,19 @@ enum UITestSupport {

static let stubScanValue = "UITEST-SCAN-VALUE"

static let mainSelection = selection(apps: [1, 2, 3])

static let groupSelection = selection(apps: [1])

private static func selection(apps: [UInt8]) -> FamilyActivitySelection {
var selection = FamilyActivitySelection()
selection.applicationTokens = Set(apps.compactMap { id in
let json = #"{"data":"\#(Data([id]).base64EncodedString())"}"#
return try? JSONDecoder().decode(ApplicationToken.self, from: Data(json.utf8))
})
return selection
}

private static func value(after flag: String) -> String? {
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { return nil }
return arguments[index + 1]
Expand Down
9 changes: 2 additions & 7 deletions Normal/Intents/AppBlockIntentPerformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,26 @@ final class AppBlockIntentPerformer: BlockIntentPerforming {
private let container: ModelContainer
private let screenTimeService: any ScreenTimeProviding
private let timedUnblockService: TimedUnblockService
private let scheduleService: ScheduleService
private let syncsExternalSurfaces: Bool
private let logger = Logger(subsystem: "com.normalengineering.normal", category: "Intents")

init(
container: ModelContainer,
screenTimeService: any ScreenTimeProviding,
timedUnblockService: TimedUnblockService,
scheduleService: ScheduleService,
syncsExternalSurfaces: Bool = true
) {
self.container = container
self.screenTimeService = screenTimeService
self.timedUnblockService = timedUnblockService
self.scheduleService = scheduleService
self.syncsExternalSurfaces = syncsExternalSurfaces
}

convenience init(container: ModelContainer, services: AppServices) {
self.init(
container: container,
screenTimeService: services.screenTime,
timedUnblockService: services.timedUnblock,
scheduleService: services.schedule
timedUnblockService: services.timedUnblock
)
}

Expand All @@ -55,8 +51,7 @@ final class AppBlockIntentPerformer: BlockIntentPerforming {
mainSelection: mainSelection,
settings: settings,
screenTimeService: screenTimeService,
timedUnblockService: timedUnblockService,
scheduleService: scheduleService
timedUnblockService: timedUnblockService
)
syncExternalSurfaces(settings: settings)
return BlockOutcome(name: String(localized: "All Apps"), wasAlreadyBlocked: wasAlreadyBlocked)
Expand Down
17 changes: 15 additions & 2 deletions Normal/Models/BlockSchedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,18 @@ final class BlockSchedule: Identifiable {
)
}

func toDTO() -> ScheduleDTO? {
func hasSameRules(as dto: ScheduleDTO) -> Bool {
startHour == dto.startHour
&& startMinute == dto.startMinute
&& effectiveDurationMinutes == dto.durationMinutes
&& weekdays == dto.weekdays
&& shouldBlock == dto.shouldBlock
&& isTimed == dto.isTimed
&& customDomains == dto.customDomains
&& (try? FamilyActivitySelection.fromData(dto.selectionData)) == selection
}

func toDTO(activatedAt: Date? = nil) -> ScheduleDTO? {
guard let data = try? selection.toData() else { return nil }
return ScheduleDTO(
id: id,
Expand All @@ -127,7 +138,9 @@ final class BlockSchedule: Identifiable {
weekdays: weekdays,
shouldBlock: shouldBlock,
isTimed: isTimed,
customDomains: customDomains
customDomains: customDomains,
isEnabled: isEnabled,
activatedAt: activatedAt
)
}
}
16 changes: 16 additions & 0 deletions Normal/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,10 @@
"comment" : "Description of the Block Group intent.",
"isCommentAutoGenerated" : true
},
"Callback Log (%lld)" : {
"comment" : "The title of the callback log view. The argument is the number of lines in the log.",
"isCommentAutoGenerated" : true
},
"Camera Access Needed" : {
"comment" : "A label describing the need for camera access.",
"isCommentAutoGenerated" : true
Expand Down Expand Up @@ -506,6 +510,10 @@
"comment" : "A description for the \"No App Selection\" ContentUnavailableView in the GroupsView.",
"isCommentAutoGenerated" : true
},
"Clear" : {
"comment" : "A button that clears the callback log.",
"isCommentAutoGenerated" : true
},
"Close" : {
"comment" : "A button to close the settings view.",
"isCommentAutoGenerated" : true
Expand All @@ -524,6 +532,10 @@
},
"Continue" : {

},
"Copy" : {
"comment" : "A button that copies the callback log to the clipboard.",
"isCommentAutoGenerated" : true
},
"Create a schedule to automatically block or unblock apps at set times." : {
"comment" : "A footer message in the Schedules view that instructs the user on how to create schedules.",
Expand Down Expand Up @@ -1272,6 +1284,10 @@
"comment" : "The text of a button that saves a newly created app group.",
"isCommentAutoGenerated" : true
},
"Saved, but iOS only tracks 20 schedules and timers at once, so this schedule may start or end up to 30 minutes late. Turn off another schedule to make it exact." : {
"comment" : "Error description for a schedule registration error.",
"isCommentAutoGenerated" : true
},
"Scan QR Code / Barcode" : {
"comment" : "The title of the view.",
"isCommentAutoGenerated" : true
Expand Down
19 changes: 5 additions & 14 deletions Normal/Services/BlockActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ enum BlockActions {
mainSelection: SelectedApps,
settings: Settings,
screenTimeService: any ScreenTimeProviding,
timedUnblockService: TimedUnblockService,
scheduleService: ScheduleService
timedUnblockService: TimedUnblockService
) {
screenTimeService.applyShieldOnAll(
timedUnblockService.clearAll()
screenTimeService.blockAll(
selection: mainSelection.selection,
customDomains: settings.enableCustomDomains ? mainSelection.customDomains : [],
blockAllPreventsAppDelete: settings.blockAllPreventsAppDelete
)
timedUnblockService.clearAll()
scheduleService.setScheduleOverride(false)
}

static func blockGroup(
Expand All @@ -25,15 +23,9 @@ enum BlockActions {
) {
let customDomains = settings.enableCustomDomains ? group.customDomains : []
if timedUnblockService.isGroupUnblockActive(groupId: group.id) {
timedUnblockService.cancelGroup(
groupId: group.id,
selection: group.selection,
customDomains: customDomains,
screenTimeService: screenTimeService
)
} else {
screenTimeService.addToShields(selection: group.selection, customDomains: customDomains)
timedUnblockService.cancelGroup(groupId: group.id)
}
screenTimeService.block(selection: group.selection, customDomains: customDomains)
}

static func emergencyUnblock(
Expand All @@ -45,7 +37,6 @@ enum BlockActions {
) {
timedUnblockService.discardAll()
scheduleService.disableAll(schedules, screenTimeService: screenTimeService)
scheduleService.setScheduleOverride(false)
usageLimitService.overrideToday()
screenTimeService.removeAllRestrictions()
}
Expand Down
43 changes: 42 additions & 1 deletion Normal/Services/DeviceActivityProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,47 @@ protocol DeviceActivityProviding {
) throws

func stopMonitoring(_ activityNames: [DeviceActivityName])

var monitoredActivityNames: Set<String> { get }

func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule?
}

extension DeviceActivityCenter: DeviceActivityProviding {
var monitoredActivityNames: Set<String> {
Set(activities.map(\.rawValue))
}

func registeredSchedule(for activityName: DeviceActivityName) -> DeviceActivitySchedule? {
schedule(for: activityName)
}
}

extension DeviceActivityCenter: DeviceActivityProviding {}
extension DeviceActivitySchedule {
func hasSameWindow(as other: DeviceActivitySchedule) -> Bool {
repeats == other.repeats
&& Self.time(intervalStart) == Self.time(other.intervalStart)
&& Self.time(intervalEnd) == Self.time(other.intervalEnd)
}

private static func time(_ components: DateComponents) -> [Int?] {
[components.year, components.month, components.day, components.hour, components.minute, components.second ?? 0]
}
}

extension DeviceActivityProviding {
func syncHeartbeat(sharedStore: any SharedStoreProviding, at date: Date) {
let name = DeviceActivityName(SharedConstants.reblockHeartbeatActivityName)
let monitored = monitoredActivityNames
let needed = sharedStore.loadSchedules().contains { $0.isEffectivelyEnabled(monitoredActivities: monitored) }
|| sharedStore.loadTimedUnblocks().contains { $0.endDate > date }
let heartbeat = DeviceActivityScheduleFactory.heartbeat()
let current = registeredSchedule(for: name)
if needed {
guard current?.hasSameWindow(as: heartbeat) != true else { return }
try? startMonitoring(name, during: heartbeat, events: [:])
} else if current != nil || monitored.contains(name.rawValue) {
stopMonitoring([name])
}
}
}
Loading
Loading