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
10 changes: 10 additions & 0 deletions OpenAppLock/Logic/RulePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ enum RulePolicy {
!isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
}

/// Whether the Uninstall Protection toggle may be changed right now. It is
/// locked while any hard-mode rule is actively blocking — turning it off
/// mid-block would be an escape hatch, the very thing it exists to prevent.
static func canToggleUninstallProtection(
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
!isAnyHardLocked(rules: rules, usageFor: usageFor, at: now, calendar: calendar)
}

/// Whether the device's app removal should be denied right now: the user has
/// turned on Uninstall Protection *and* a hard block is currently in force.
/// Engaging this while a hard rule blocks makes the block harder to escape
Expand Down
4 changes: 3 additions & 1 deletion OpenAppLock/Services/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ protocol AppSettingsReading: AnyObject {
/// Observable so the Settings screen's toggle stays in sync.
@Observable
final class AppSettingsStore: AppSettingsReading {
static let uninstallProtectionKey = "uninstallProtectionEnabled"
/// The single source of truth for the key is `AppGroup`, so the app and the
/// extensions read and write the same defaults entry.
static let uninstallProtectionKey = AppGroup.uninstallProtectionKey

@ObservationIgnored private let defaults: UserDefaults

Expand Down
43 changes: 37 additions & 6 deletions OpenAppLock/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,36 @@ struct SettingsView: View {
NavigationStack {
List {
Section {
Toggle("Uninstall Protection", isOn: uninstallProtectionBinding)
.accessibilityIdentifier("uninstallProtectionToggle")
if isUninstallProtectionLocked {
// Mirror the Home "Currently Blocking" hard-row treatment:
// the control is replaced by a red lock so the setting
// can't be turned off mid-block (its whole purpose).
HStack {
Text("Uninstall Protection")
Spacer()
Image(systemName: "lock.fill")
.foregroundStyle(.red)
}
.accessibilityElement(children: .combine)
.accessibilityIdentifier("uninstallProtectionLockIcon")
} else {
Toggle("Uninstall Protection", isOn: uninstallProtectionBinding)
.accessibilityIdentifier("uninstallProtectionToggle")
}
} header: {
Text("Protection").textCase(nil)
} footer: {
Text(
"While on, apps can't be deleted from this device whenever a "
+ "Hard Mode rule is actively blocking — so the block can't be "
+ "removed by uninstalling.")
if isUninstallProtectionLocked {
Text(
"Locked while a Hard Mode rule is actively blocking — Uninstall "
+ "Protection can't be changed until the block ends.")
.accessibilityIdentifier("uninstallProtectionLockedNotice")
} else {
Text(
"While on, apps can't be deleted from this device whenever a "
+ "Hard Mode rule is actively blocking — so the block can't be "
+ "removed by uninstalling.")
}
}
Section {
NavigationLink {
Expand All @@ -48,12 +69,22 @@ struct SettingsView: View {
}
}

/// True while any Hard Mode rule is actively blocking, which locks the
/// toggle (it must not be turned off while a hard block is in force).
private var isUninstallProtectionLocked: Bool {
!RulePolicy.canToggleUninstallProtection(
rules: rules, usageFor: { enforcer.usage(for: $0) })
}

/// Drives the toggle's visual state from `@State` while persisting and
/// re-enforcing on every change — so protection engages/lifts immediately.
private var uninstallProtectionBinding: Binding<Bool> {
Binding(
get: { uninstallProtectionOn },
set: { newValue in
// Defense in depth: the locked row shows no toggle, but never
// let a write through while a hard block is in force.
guard !isUninstallProtectionLocked else { return }
uninstallProtectionOn = newValue
settings.uninstallProtectionEnabled = newValue
enforcer.refresh(rules: rules)
Expand Down
13 changes: 13 additions & 0 deletions OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
)
}

/// Re-evaluates Uninstall Protection from the snapshots + opt-in after each
/// callback, so app-removal denial tracks hard-mode blocks even while the
/// app is closed.
private var uninstallProtection: UninstallProtectionEnforcer {
UninstallProtectionEnforcer(
snapshots: RuleSnapshotStore(),
shields: ManagedSettingsShieldController()
)
}

override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
if let ruleID = MonitoringPlan.ruleID(fromDailyActivityName: activity.rawValue) {
Expand All @@ -35,6 +45,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
// pause and the midnight-crossing rule).
scheduleEnforcement.reconcile(ruleID: ruleID)
}
uninstallProtection.reconcile()
}

override func intervalDidEnd(for activity: DeviceActivityName) {
Expand All @@ -48,6 +59,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
// one clears.
scheduleEnforcement.reconcile(ruleID: ruleID)
}
uninstallProtection.reconcile()
}

override func eventDidReachThreshold(
Expand All @@ -58,5 +70,6 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
let minutes = MonitoringPlan.minutes(fromEventName: event.rawValue)
else { return }
enforcement.handleUsageMinutes(minutes, ruleID: ruleID)
uninstallProtection.reconcile()
}
}
6 changes: 6 additions & 0 deletions OpenAppLockShieldAction/ShieldActionExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ final class ShieldActionExtension: ShieldActionDelegate {
return .defer
}
startOpenSession(ruleID: ruleID)
// Keep Uninstall Protection in step with the (possibly changed) blocking
// state now that an open was spent.
UninstallProtectionEnforcer(
snapshots: RuleSnapshotStore(),
shields: ManagedSettingsShieldController()
).reconcile()
return .none
}

Expand Down
146 changes: 146 additions & 0 deletions OpenAppLockTests/RulePolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,150 @@ struct UninstallProtectionPolicyTests {
rules: [rule], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) },
at: mondayDuringWork, calendar: utc))
}

@Test("The toggle is locked while a hard rule is actively blocking")
func toggleLockedDuringHardBlock() {
let hard = scheduleRule(hardMode: true)
// Actively blocking → locked.
#expect(
!RulePolicy.canToggleUninstallProtection(
rules: [hard], at: mondayDuringWork, calendar: utc))
// Outside its window → editable again.
#expect(
RulePolicy.canToggleUninstallProtection(
rules: [hard], at: mondayEvening, calendar: utc))
}

@Test("The toggle stays editable when only a soft rule is blocking")
func toggleEditableWithSoftRule() {
let soft = scheduleRule(hardMode: false)
#expect(
RulePolicy.canToggleUninstallProtection(
rules: [soft], at: mondayDuringWork, calendar: utc))
}

@Test("A spent hard-mode limit rule locks the toggle")
func toggleLockedByHardLimit() {
let rule = hardLimitRule()
#expect(
!RulePolicy.canToggleUninstallProtection(
rules: [rule], usageFor: { _ in RuleUsage(minutesUsed: 45) },
at: mondayDuringWork, calendar: utc))
#expect(
RulePolicy.canToggleUninstallProtection(
rules: [rule], usageFor: { _ in RuleUsage(minutesUsed: 10) },
at: mondayDuringWork, calendar: utc))
}
}

/// The snapshot-based mirror of the uninstall-protection policy, used by the
/// background (extension) enforcement path. It must agree with `RulePolicy` —
/// the parity test below is the anti-drift guard.
@MainActor
@Suite("Uninstall protection policy (snapshot)")
struct UninstallProtectionSnapshotPolicyTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it

func scheduleRule(hardMode: Bool) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}

/// A hard time-limit blocking every day, so it is scheduled on the Monday
/// the test dates fall on.
func hardLimitRule() -> BlockingRule {
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
hardMode: true,
days: Weekday.everyDay)
}

/// A hard time-limit scheduled only on Tuesdays — never active on the test
/// Mondays even when its budget is spent.
func tuesdayHardLimitRule() -> BlockingRule {
BlockingRule(
name: "Tuesday Only",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
hardMode: true,
days: [.tuesday])
}

@Test("App removal denied only with the setting on AND a hard snapshot active")
func deniedOnlyWhenEnabledAndHardLocked() {
let snap = RuleSnapshot(rule: scheduleRule(hardMode: true))
#expect(
!UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: false, at: mondayDuringWork, calendar: utc))
#expect(
UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, at: mondayDuringWork, calendar: utc))
#expect(
!UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, at: mondayEvening, calendar: utc))
}

@Test("A soft snapshot never triggers denial")
func softRuleNeverDenies() {
let snap = RuleSnapshot(rule: scheduleRule(hardMode: false))
#expect(
!UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, at: mondayDuringWork, calendar: utc))
}

@Test("A spent hard-mode limit snapshot denies; unspent does not")
func spentHardLimitDenies() {
let snap = RuleSnapshot(rule: hardLimitRule())
#expect(
UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 45) },
at: mondayDuringWork, calendar: utc))
#expect(
!UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 10) },
at: mondayDuringWork, calendar: utc))
}

@Test("A spent hard limit not scheduled today does not deny")
func spentButNotScheduledTodayDoesNotDeny() {
// Guards the WIP bug: omitting the scheduled-today check would wrongly
// deny removal for a Tuesday-only rule on a Monday.
let snap = RuleSnapshot(rule: tuesdayHardLimitRule())
#expect(
!UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: true, usageFor: { _ in RuleUsage(minutesUsed: 99) },
at: mondayDuringWork, calendar: utc))
}

@Test("Snapshot policy agrees with RulePolicy across representative cases")
func parityWithRulePolicy() {
let disabled = BlockingRule(name: "Off", isEnabled: false, hardMode: true)
let cases: [(BlockingRule, RuleUsage?)] = [
(scheduleRule(hardMode: true), nil),
(scheduleRule(hardMode: false), nil),
(hardLimitRule(), RuleUsage(minutesUsed: 45)),
(hardLimitRule(), RuleUsage(minutesUsed: 10)),
(tuesdayHardLimitRule(), RuleUsage(minutesUsed: 99)),
(disabled, nil),
]
for (rule, usage) in cases {
let snap = RuleSnapshot(rule: rule)
for moment in [mondayDuringWork, mondayEvening] {
#expect(
UninstallProtectionPolicy.isHardLocked(
snap, usage: usage, at: moment, calendar: utc)
== RulePolicy.isHardLocked(
rule, usage: usage, at: moment, calendar: utc))
for enabled in [true, false] {
#expect(
UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: [snap], enabled: enabled, usageFor: { _ in usage },
at: moment, calendar: utc)
== RulePolicy.shouldDenyAppRemoval(
rules: [rule], enabled: enabled, usageFor: { _ in usage },
at: moment, calendar: utc))
}
}
}
}
}
87 changes: 87 additions & 0 deletions OpenAppLockTests/UninstallProtectionEnforcerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// UninstallProtectionEnforcerTests.swift
// OpenAppLockTests
//

import Foundation
import Testing

@testable import OpenAppLock

/// The background (extension) path that recomputes app-removal denial from the
/// shared snapshots + the persisted opt-in, mirroring `RuleEnforcer.refresh`'s
/// foreground decision.
@MainActor
@Suite("Uninstall protection background enforcer")
struct UninstallProtectionEnforcerTests {
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it

private func freshDefaults() -> UserDefaults {
let name = "uninstall-enforcer-tests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defaults.removePersistentDomain(forName: name)
return defaults
}

/// Wires an enforcer whose stores all read from one isolated defaults suite,
/// pre-seeded with the given snapshots and opt-in flag.
private func makeEnforcer(
snapshots: [RuleSnapshot], enabled: Bool, shields: MockShieldController
) -> UninstallProtectionEnforcer {
let defaults = freshDefaults()
defaults.set(enabled, forKey: AppGroup.uninstallProtectionKey)
let store = RuleSnapshotStore(defaults: defaults)
store.save(snapshots)
return UninstallProtectionEnforcer(
snapshots: store, shields: shields,
ledger: UsageLedger(defaults: defaults), defaults: defaults)
}

private func hardSchedule() -> RuleSnapshot {
RuleSnapshot(rule: BlockingRule(name: "Locked In", hardMode: true))
}

@Test("Denies removal when opted in and a hard rule is actively blocking")
func deniesWhenEnabledAndHardActive() {
let shields = MockShieldController()
let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: true, shields: shields)

enforcer.reconcile(at: mondayDuringWork, calendar: utc)

#expect(shields.appRemovalDenied)
}

@Test("Does not deny when the opt-in is off")
func doesNotDenyWhenDisabled() {
let shields = MockShieldController()
let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: false, shields: shields)

enforcer.reconcile(at: mondayDuringWork, calendar: utc)

#expect(!shields.appRemovalDenied)
}

@Test("A soft rule never denies, even opted in")
func doesNotDenyForSoftRule() {
let shields = MockShieldController()
let soft = RuleSnapshot(rule: BlockingRule(name: "Work Time"))
let enforcer = makeEnforcer(snapshots: [soft], enabled: true, shields: shields)

enforcer.reconcile(at: mondayDuringWork, calendar: utc)

#expect(!shields.appRemovalDenied)
}

@Test("Denial lifts once the hard window ends")
func liftsWhenWindowEnds() {
let shields = MockShieldController()
let enforcer = makeEnforcer(snapshots: [hardSchedule()], enabled: true, shields: shields)

enforcer.reconcile(at: mondayDuringWork, calendar: utc)
#expect(shields.appRemovalDenied)

enforcer.reconcile(at: mondayEvening, calendar: utc)
#expect(!shields.appRemovalDenied)
}
}
Loading
Loading