expo-app-blocker 0.1.73 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,10 +14,31 @@ public class ExpoAppBlockerModule: Module {
14
14
  private var sharedDefaults: UserDefaults?
15
15
  private let userDefaults = UserDefaults.standard
16
16
  private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
17
- // Stores the grant's wall-clock expiration (Date). Presence + a future date means
18
- // a temporary unlock is active; the shield is re-applied once it passes.
17
+ // Stores the granted earned-time budget in **seconds** (Int). Presence with a
18
+ // value > 0 means a temporary unlock is active. Enforcement is usage-based: the
19
+ // shield is re-applied by the DeviceActivityMonitor once cumulative foreground
20
+ // usage of the blocked apps reaches the budget (see `startUsageBasedRelock`). The
21
+ // budget pauses when no blocked app is in use and resumes on return.
19
22
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
23
+ // Consumed seconds of the active unlock, written by the monitor extension as
24
+ // blocked-app usage accrues. Remaining = budget − consumed.
25
+ private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
26
+ // Wall-clock instant the active budget was granted (Date). Used for the daily
27
+ // reset (budget is cleared when the calendar day changes) and as the upper bound
28
+ // for the monitor's premature-fire guard (usage can't exceed elapsed wall-clock).
29
+ private let unlockGrantedAtKey = "appBlocker.unlockGrantedAt.v1"
20
30
  private let unlockActivityName = "appBlocker.temporaryUnlock"
31
+ // Sub-minute usage steps. We register one DeviceActivityEvent per `usageStepSeconds`
32
+ // of the budget (threshold = k×step seconds of measured usage). Each step's
33
+ // eventDidReachThreshold lets the monitor write consumed SECONDS back to the App
34
+ // Group. The event name carries its threshold (`appBlocker.usageStep.<seconds>`).
35
+ private let usageStepEventPrefix = "appBlocker.usageStep."
36
+ // Apple's usage thresholds are coarse/unreliable below ~a minute; 30s is a best-
37
+ // effort finer grain backstopped by later steps and the final (== budget) event.
38
+ private let usageStepSeconds = 30
39
+ // Cap on registered step events; the step auto-coarsens for large budgets so the
40
+ // event count stays under this (Apple degrades with too many events).
41
+ private let maxUsageSteps = 60
21
42
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
22
43
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
23
44
  private let minimumTemporaryUnlockMinutes = 1
@@ -220,11 +241,14 @@ public class ExpoAppBlockerModule: Module {
220
241
  return
221
242
  }
222
243
 
223
- // Wall-clock expiry is the source of truth. (Usage-based relock was
224
- // removed: Apple won't fire DeviceActivity usage thresholds under ~15 min,
225
- // so short earned-time budgets never re-blocked and the countdown froze.)
226
- let expirationDate = Date().addingTimeInterval(TimeInterval(sanitizedDurationMinutes * 60))
227
- self.sharedDefaults?.set(expirationDate, forKey: self.temporaryUnlockKey)
244
+ // Usage-based budget is the source of truth: the grant is spent only while a
245
+ // blocked app is in the foreground, so it pauses on leave and resumes on
246
+ // return. (See `startUsageBasedRelock`.) The budget is cleared at midnight.
247
+ let budgetSeconds = sanitizedDurationMinutes * 60
248
+ let grantedAt = Date()
249
+ self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
250
+ self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
251
+ self.sharedDefaults?.set(grantedAt, forKey: self.unlockGrantedAtKey)
228
252
 
229
253
  DispatchQueue.main.async {
230
254
  self.store.shield.applications = nil
@@ -232,49 +256,43 @@ public class ExpoAppBlockerModule: Module {
232
256
  self.store.shield.webDomains = nil
233
257
  }
234
258
 
235
- // Best-effort schedule: only fires for grants ~15 min (Apple's minimum).
236
- // Shorter grants throw here and are re-blocked by the host instead
237
- // getRemainingUnlockTime and the foreground checkAndApplyUnlockState relock
238
- // once the expiration passes.
259
+ // Arm usage-threshold monitoring so the monitor re-blocks once measured
260
+ // blocked-app usage reaches the budget. Non-fatal if it can't start the
261
+ // host-side relock (getRemainingUnlockTime poll / foreground check) is a
262
+ // backstop once the monitor reports consumption.
239
263
  do {
240
- try self.scheduleRelockActivity(expirationDate: expirationDate)
264
+ try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
241
265
  } catch {
242
- print("[AppBlocker] Schedule relock failed (duration may be too short): \(error.localizedDescription)")
266
+ print("[AppBlocker] startUsageBasedRelock failed: \(error.localizedDescription)")
243
267
  }
244
268
 
245
269
  DispatchQueue.main.async {
246
270
  promise.resolve([
247
271
  "unlocked": true,
248
- "expiresAt": expirationDate.timeIntervalSince1970
272
+ "expiresAt": grantedAt.addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
249
273
  ])
250
274
  }
251
275
  }
252
276
  }
253
277
 
254
278
  Function("isTemporarilyUnlocked") { () -> Bool in
255
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
256
- return false
257
- }
258
- if Date() < expirationDate {
259
- return true
260
- }
261
- self.relockApps()
262
- return false
279
+ return self.remainingUnlockSeconds() > 0
263
280
  }
264
281
 
265
- // Wall-clock remaining seconds, counting down in real time. When the host app
266
- // polls this (e.g. the blocking-status banner, every second) and the grant has
267
- // expired, it re-applies the shield the primary relock path for grants under
268
- // ~15 min, which Apple's DeviceActivity cannot enforce on its own.
282
+ // Remaining earned-time seconds = granted budget measured blocked-app usage.
283
+ // Stays flat while no blocked app is in use (the budget pauses on leave) and
284
+ // drops as the monitor reports consumption. When the host polls this (e.g. the
285
+ // blocking-status banner) and it has reached 0 budget spent or the day rolled
286
+ // over — the shield is re-applied as a backstop to the monitor.
269
287
  Function("getRemainingUnlockTime") { () -> Int in
270
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
271
- return 0
272
- }
273
- let remaining = expirationDate.timeIntervalSince(Date())
288
+ let hadBudget = ((self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Int) ?? 0) > 0
289
+ let remaining = self.remainingUnlockSeconds()
274
290
  if remaining > 0 {
275
- return Int(remaining)
291
+ return remaining
292
+ }
293
+ if hadBudget {
294
+ self.relockApps()
276
295
  }
277
- self.relockApps()
278
296
  return 0
279
297
  }
280
298
 
@@ -371,24 +389,23 @@ public class ExpoAppBlockerModule: Module {
371
389
 
372
390
  ensureLoadedPersistedConfig()
373
391
 
374
- if let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date {
375
- let remaining = expirationDate.timeIntervalSince(Date())
376
-
377
- if remaining > 0 {
378
- // Unlock still active keep the shield off and (re-)arm the schedule so a
379
- // grant ~15 min re-blocks even while the user stays in a blocked app.
392
+ let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
393
+ if budgetSeconds > 0 {
394
+ if remainingUnlockSeconds() > 0 {
395
+ // Budget still available (and the day hasn't rolled over) — keep the shield
396
+ // off. Do NOT re-arm monitoring here: DeviceActivity monitoring is system-
397
+ // level and survives app termination, so the schedule registered at grant
398
+ // time is still running. Re-registering would restart the usage interval at
399
+ // "now" and discard accrued usage — letting a user reset their budget by
400
+ // bouncing back to this app. The monitor re-blocks on its own once usage
401
+ // reaches the budget; this branch only ensures the shield stays off.
380
402
  DispatchQueue.main.async {
381
403
  self.store.shield.applications = nil
382
404
  self.store.shield.applicationCategories = nil
383
405
  self.store.shield.webDomains = nil
384
406
  }
385
-
386
- do {
387
- try scheduleRelockActivity(expirationDate: expirationDate)
388
- } catch {
389
- relockApps()
390
- }
391
407
  } else {
408
+ // Budget spent or cleared by the daily reset.
392
409
  relockApps()
393
410
  }
394
411
  } else if let config = currentBlockConfig {
@@ -522,68 +539,85 @@ public class ExpoAppBlockerModule: Module {
522
539
 
523
540
  // MARK: - Activity Scheduling
524
541
 
525
- /// Arm a time-based re-block: a `DeviceActivitySchedule` whose interval ends at
526
- /// `expirationDate`. The DeviceActivityMonitor's `intervalDidEnd` re-applies the
527
- /// shield at that wall-clock moment, so a grant re-blocks even while the user
528
- /// stays in a blocked app.
542
+ /// Start usage-based monitoring: re-apply the shield once cumulative foreground
543
+ /// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
544
+ /// so the budget naturally pauses when the apps aren't in use and resumes on return.
545
+ ///
546
+ /// We register a series of threshold events stepping by `usageStepSeconds` (auto-
547
+ /// coarsened so the count stays under `maxUsageSteps`). The event name carries its
548
+ /// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
549
+ /// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
550
+ /// consumed-second count to the App Group — giving the host app a sub-minute,
551
+ /// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
552
+ /// (== budget) is where the monitor re-applies the shield.
529
553
  ///
530
- /// Apple requires the interval to span at least ~15 minutes, so grants shorter
531
- /// than that make `startMonitoring` throw the caller treats that as non-fatal
532
- /// and relies on the host-side relock (getRemainingUnlockTime poll /
533
- /// checkAndApplyUnlockState on foreground) once the expiration passes.
534
- private func scheduleRelockActivity(expirationDate: Date) throws {
554
+ /// The interval ends at 23:59:59 so the monitor's `intervalDidEnd` clears any
555
+ /// unspent budget at the day boundary (earned time does not carry across midnight).
556
+ /// `repeats: false` because we re-register on every unlock.
557
+ ///
558
+ /// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
559
+ /// finest steps may fire late or be skipped — later steps and the final threshold
560
+ /// still re-block, bounding overshoot to roughly one step.
561
+ private func startUsageBasedRelock(budgetSeconds: Int) throws {
535
562
  scheduleLock.lock()
536
563
  defer { scheduleLock.unlock() }
537
564
 
538
565
  cancelRelockActivityLocked()
539
566
 
540
- let activityName = DeviceActivityName(unlockActivityName)
541
- let calendar = Calendar.current
542
- let now = Date()
567
+ guard let config = currentBlockConfig else {
568
+ print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
569
+ return
570
+ }
571
+ let appTokens = Set(config.items.compactMap { $0.appToken })
572
+ let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
573
+ let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
543
574
 
544
- // Apple rejects DeviceActivitySchedule intervals shorter than 15 minutes, so
545
- // a short earned grant can't simply end the interval at its real expiration.
546
- // Pad the interval to the 15-min minimum and use `warningTime` so
547
- // `intervalWillEndWarning` fires at the REAL expiration — letting the monitor
548
- // re-block while the user is still inside the blocked app (sub-15-min grants).
549
- // Grants ≥ 15 min end the interval exactly at expiration (no warning needed).
550
- let minIntervalSec: TimeInterval = 15 * 60
551
- let durationSec = max(0, expirationDate.timeIntervalSince(now))
552
-
553
- let intervalEndDate: Date
554
- var warningSec = 0
555
- if durationSec >= minIntervalSec {
556
- intervalEndDate = expirationDate
557
- } else {
558
- intervalEndDate = now.addingTimeInterval(minIntervalSec)
559
- warningSec = Int(intervalEndDate.timeIntervalSince(expirationDate).rounded())
575
+ guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
576
+ print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
577
+ return
560
578
  }
561
579
 
562
- let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
563
- let schedule: DeviceActivitySchedule
564
-
565
- if calendar.isDate(now, inSameDayAs: intervalEndDate) {
566
- let endComponents = calendar.dateComponents([.hour, .minute, .second], from: intervalEndDate)
567
- let warning: DateComponents? =
568
- warningSec > 0 ? DateComponents(minute: warningSec / 60, second: warningSec % 60) : nil
569
- schedule = DeviceActivitySchedule(
570
- intervalStart: startComponents,
571
- intervalEnd: endComponents,
572
- repeats: false,
573
- warningTime: warning
574
- )
575
- } else {
576
- // Padded interval crosses midnight — cap at end-of-day (no warning); the
577
- // host-side relock (getRemainingUnlockTime poll / foreground check) covers
578
- // the remainder on next return to the app.
579
- schedule = DeviceActivitySchedule(
580
- intervalStart: startComponents,
581
- intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
582
- repeats: false
580
+ let budget = max(1, budgetSeconds)
581
+ // Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
582
+ let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
583
+ var thresholds: [Int] = []
584
+ var t = step
585
+ while t < budget {
586
+ thresholds.append(t)
587
+ t += step
588
+ }
589
+ thresholds.append(budget) // always include the exact budget as the final re-block
590
+
591
+ var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
592
+ for seconds in thresholds {
593
+ events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
594
+ applications: appTokens,
595
+ categories: categoryTokens,
596
+ webDomains: webDomainTokens,
597
+ threshold: dateComponents(fromSeconds: seconds)
583
598
  )
584
599
  }
585
600
 
586
- try activityCenter.startMonitoring(activityName, during: schedule)
601
+ // CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
602
+ // thresholds measure usage accumulated *within the interval, from its start*. An
603
+ // all-day [00:00, 23:59] interval would count usage since midnight — so any prior
604
+ // blocked-app use today would have already crossed the thresholds before monitoring
605
+ // began, and the system never fires a (new) crossing → the shield never re-applies.
606
+ // Starting the interval at the current time makes thresholds count from the unlock
607
+ // moment. repeats:false because we re-register on every unlock.
608
+ let now = Date()
609
+ let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
610
+ let schedule = DeviceActivitySchedule(
611
+ intervalStart: startComps,
612
+ intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
613
+ repeats: false
614
+ )
615
+
616
+ try activityCenter.startMonitoring(
617
+ DeviceActivityName(unlockActivityName),
618
+ during: schedule,
619
+ events: events
620
+ )
587
621
  }
588
622
 
589
623
  private func cancelRelockActivity() {
@@ -601,18 +635,39 @@ public class ExpoAppBlockerModule: Module {
601
635
  return remainingUnlockSeconds() > 0
602
636
  }
603
637
 
604
- /// Clear all persisted unlock state (budget + consumed counter).
638
+ /// Clear all persisted unlock state (budget + consumed counter + grant time).
605
639
  private func clearUnlockState() {
606
640
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
641
+ sharedDefaults?.removeObject(forKey: usageConsumedKey)
642
+ sharedDefaults?.removeObject(forKey: unlockGrantedAtKey)
607
643
  }
608
644
 
609
- /// Seconds of earned time still available: wall-clock time until the grant's
610
- /// expiration. 0 if no active unlock or it has already expired. Clamped at 0.
645
+ /// Seconds of earned time still available: the granted budget minus the seconds of
646
+ /// blocked-app usage the monitor extension has recorded. Returns 0 if there is no
647
+ /// active unlock, the budget is fully consumed, or the grant is from a previous day
648
+ /// (earned time does not carry across midnight). Clamped at 0.
611
649
  private func remainingUnlockSeconds() -> Int {
612
- guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
650
+ let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
651
+ if budgetSeconds <= 0 { return 0 }
652
+
653
+ // Daily reset: a grant made on an earlier calendar day is stale.
654
+ if let grantedAt = sharedDefaults?.object(forKey: unlockGrantedAtKey) as? Date,
655
+ !Calendar.current.isDate(grantedAt, inSameDayAs: Date()) {
613
656
  return 0
614
657
  }
615
- return max(0, Int(expirationDate.timeIntervalSince(Date())))
658
+
659
+ let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
660
+ return max(0, budgetSeconds - consumedSeconds)
661
+ }
662
+
663
+ /// Build a DateComponents threshold from a total number of seconds (normalized
664
+ /// into hour/minute/second so the system reads it cleanly).
665
+ private func dateComponents(fromSeconds total: Int) -> DateComponents {
666
+ return DateComponents(
667
+ hour: total / 3600,
668
+ minute: (total % 3600) / 60,
669
+ second: total % 60
670
+ )
616
671
  }
617
672
 
618
673
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "Expo module for cross-platform app blocking. Android: UsageStatsManager + Overlay. iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity).",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -11,9 +11,16 @@ import Foundation
11
11
  class DeviceActivityMonitorExtension: DeviceActivityMonitor {
12
12
  // CONFIGURE: Replace with your App Group identifier
13
13
  private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
14
- // Holds the grant's wall-clock expiration (Date); kept in sync with
15
- // ExpoAppBlockerModule.swift. Presence + a future date means an unlock is active.
14
+ // Granted earned-time budget in SECONDS (Int); kept in sync with
15
+ // ExpoAppBlockerModule.swift. Presence with value > 0 means an unlock is active.
16
16
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
17
+ // Consumed seconds, written here as blocked-app usage thresholds fire.
18
+ private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
19
+ // Wall-clock instant the budget was granted (Date) — upper bound for the
20
+ // premature-fire guard (measured usage can't exceed elapsed wall-clock).
21
+ private let unlockGrantedAtKey = "appBlocker.unlockGrantedAt.v1"
22
+ // Usage-step event-name prefix; the suffix is the threshold in seconds.
23
+ private let usageStepEventPrefix = "appBlocker.usageStep."
17
24
  private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
18
25
 
19
26
  private let store = ManagedSettingsStore()
@@ -24,36 +31,83 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
24
31
  sharedDefaults = UserDefaults(suiteName: appGroupIdentifier)
25
32
  }
26
33
 
27
- // Fires at `intervalEnd warningTime`, which the host aligns to the grant's
28
- // real expiration. This is the ONLY callback that fires for sub-15-min grants
29
- // (Apple's schedule interval minimum is 15 min), so it's the primary
30
- // re-block-while-inside path. `intervalDidEnd` covers the ≥15-min case + safety.
31
- override func intervalWillEndWarning(for activity: DeviceActivityName) {
32
- super.intervalWillEndWarning(for: activity)
33
- relockIfExpired()
34
+ /// Fires once per usage step (threshold = N seconds of measured blocked-app use).
35
+ /// Records consumed seconds back to the App Group so the host can show a paused,
36
+ /// pause-when-away countdown; once consumption reaches the budget, re-applies the
37
+ /// shield. This is the primary pause-on-leave relock path.
38
+ override func eventDidReachThreshold(
39
+ _ event: DeviceActivityEvent.Name,
40
+ activity: DeviceActivityName
41
+ ) {
42
+ super.eventDidReachThreshold(event, activity: activity)
43
+
44
+ let stepSeconds = parseStepSeconds(from: event.rawValue)
45
+ guard stepSeconds > 0 else {
46
+ // Unknown event — treat as a full relock to stay safe.
47
+ clearUnlockState()
48
+ reapplyBlockConfiguration()
49
+ return
50
+ }
51
+
52
+ // Premature-fire guard (iOS-26 bug + clock skew): measured usage can never
53
+ // exceed the wall-clock elapsed since the grant. If a step claims more usage
54
+ // than has physically elapsed (+30s tolerance), it's spurious — ignore it.
55
+ if let grantedAt = sharedDefaults?.object(forKey: unlockGrantedAtKey) as? Date {
56
+ let elapsed = Date().timeIntervalSince(grantedAt)
57
+ if Double(stepSeconds) > elapsed + 30 {
58
+ return
59
+ }
60
+ }
61
+
62
+ // Record consumed seconds monotonically (steps can arrive out of order).
63
+ let prev = sharedDefaults?.integer(forKey: usageConsumedKey) ?? 0
64
+ if stepSeconds > prev {
65
+ sharedDefaults?.set(stepSeconds, forKey: usageConsumedKey)
66
+ }
67
+
68
+ let budgetSeconds = sharedDefaults?.integer(forKey: temporaryUnlockKey) ?? 0
69
+ if budgetSeconds <= 0 || stepSeconds >= budgetSeconds {
70
+ // Budget fully spent — re-block.
71
+ clearUnlockState()
72
+ reapplyBlockConfiguration()
73
+ }
34
74
  }
35
75
 
76
+ /// Fires at the schedule's interval end (23:59:59) — the daily reset. Clears any
77
+ /// unspent budget and re-applies the shield so earned time does not carry across
78
+ /// midnight.
79
+ ///
80
+ /// Guards against the spurious callback that `stopMonitoring()` fires during a
81
+ /// re-grant: that fire happens whenever the user earns time, not at the day
82
+ /// boundary, so only honor it in the last couple of minutes before midnight.
36
83
  override func intervalDidEnd(for activity: DeviceActivityName) {
37
84
  super.intervalDidEnd(for: activity)
38
- relockIfExpired()
85
+
86
+ let comps = Calendar.current.dateComponents([.hour, .minute], from: Date())
87
+ guard comps.hour == 23, (comps.minute ?? 0) >= 58 else {
88
+ return
89
+ }
90
+ clearUnlockState()
91
+ reapplyBlockConfiguration()
39
92
  }
40
93
 
41
94
  override func intervalDidStart(for activity: DeviceActivityName) {
42
95
  super.intervalDidStart(for: activity)
43
96
  }
44
97
 
45
- /// Re-apply the shield once the grant's wall-clock expiration has arrived.
46
- /// Guards against the spurious callback that `stopMonitoring()` fires during a
47
- /// re-grant: if the stored expiration is still comfortably in the future
48
- /// (> 60s), this is a re-arm — not an expiry — so the fresh grant is kept.
49
- /// The 60s tolerance also absorbs callback/clock skew at the real boundary.
50
- private func relockIfExpired() {
51
- if let expiration = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date,
52
- expiration.timeIntervalSinceNow > 60 {
53
- return
54
- }
98
+ /// Extract the threshold seconds from an event name like `appBlocker.usageStep.90`;
99
+ /// 0 if the name is not a usage step.
100
+ private func parseStepSeconds(from rawName: String) -> Int {
101
+ guard rawName.hasPrefix(usageStepEventPrefix) else { return 0 }
102
+ let suffix = rawName.dropFirst(usageStepEventPrefix.count)
103
+ return Int(suffix) ?? 0
104
+ }
105
+
106
+ /// Clear all persisted unlock state (budget + consumed counter + grant time).
107
+ private func clearUnlockState() {
55
108
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
56
- reapplyBlockConfiguration()
109
+ sharedDefaults?.removeObject(forKey: usageConsumedKey)
110
+ sharedDefaults?.removeObject(forKey: unlockGrantedAtKey)
57
111
  }
58
112
 
59
113
  private func reapplyBlockConfiguration() {