expo-app-blocker 0.1.64 → 0.1.66

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.
package/README.md CHANGED
@@ -513,10 +513,10 @@ await relockApps(); // drop the budget now, re-block imme
513
513
 
514
514
  | | Android | iOS |
515
515
  |---|---|---|
516
- | Mechanism | Foreground-service poll consumes the budget each tick spent inside a blocked app | DeviceActivity usage-threshold event re-applies the Family Controls shield after N minutes of measured usage |
517
- | `getRemainingUnlockTime()` | Live — ticks down while inside a blocked app, freezes otherwise | Returns the **granted** budget; iOS can't expose live usage, so it stays flat until the threshold fires (then `0`) |
516
+ | Mechanism | Foreground-service poll consumes the budget each tick spent inside a blocked app | DeviceActivity usage-threshold events stepped ~every 30s of the budget; the monitor extension records consumed seconds and re-applies the shield on the final step |
517
+ | `getRemainingUnlockTime()` | Live — ticks down by the second while inside a blocked app, freezes otherwise | **~30s-granular** steps down as measured usage accrues (the monitor writes consumed seconds to the App Group), freezes when the apps aren't used |
518
518
  | `isTemporarilyUnlocked()` | Returns `false` (use `getRemainingUnlockTime() > 0`) | `true` while budget remains |
519
- | Caveats | — | Apple thresholds are unreliable below a few minutes; unspent budget is cleared at the daily schedule boundary (midnight) |
519
+ | Caveats | — | Apple thresholds are coarse/unreliable below ~a minute, so the finest steps may fire late or be skipped (later steps + the final threshold still re-block, bounding overshoot to ~one step); large budgets auto-coarsen the step to stay under the event cap; unspent budget is cleared at the daily boundary (midnight) |
520
520
 
521
521
  > **Android only:** when the budget runs out while the user is still inside a
522
522
  > blocked app, the deep link fires with `reason=expired` (see
@@ -2,9 +2,15 @@ package expo.modules.appblocker
2
2
 
3
3
  import android.content.Context
4
4
  import android.content.SharedPreferences
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
5
7
 
6
8
  object AppBlockerPrefs {
7
9
  const val PREFS_NAME = "expo_app_blocker_prefs"
10
+ private const val KEY_PENDING_INTERCEPTS = "pending_intercepts"
11
+ private const val KEY_LAST_INTERCEPT_TS = "last_intercept_ts"
12
+ private const val INTERCEPT_DEBOUNCE_MS = 2_000L
13
+ private const val MAX_PENDING_INTERCEPTS = 200
8
14
  const val KEY_BLOCKED_PACKAGES = "blocked_packages"
9
15
  private const val KEY_OVERLAY_TITLE = "overlay_title"
10
16
  private const val KEY_OVERLAY_TEXT = "overlay_text"
@@ -149,6 +155,59 @@ object AppBlockerPrefs {
149
155
  fun getNotificationText(context: Context): String =
150
156
  get(context).getString(KEY_NOTIFICATION_TEXT, null) ?: "{appName} is blocked. Tap to manage."
151
157
 
158
+ /**
159
+ * Queue one OS-level block event for the app to drain into
160
+ * `blocker_intercepts`. Debounced globally so the poll loop can't emit
161
+ * duplicates for a single block, and capped to bound storage.
162
+ */
163
+ fun appendIntercept(context: Context, appName: String, interceptedAtMs: Long) {
164
+ val prefs = get(context)
165
+ val lastTs = prefs.getLong(KEY_LAST_INTERCEPT_TS, 0L)
166
+ if (lastTs > 0L && interceptedAtMs - lastTs < INTERCEPT_DEBOUNCE_MS) return
167
+
168
+ val arr = try {
169
+ JSONArray(prefs.getString(KEY_PENDING_INTERCEPTS, "[]"))
170
+ } catch (e: Exception) {
171
+ JSONArray()
172
+ }
173
+ arr.put(JSONObject().put("appName", appName).put("interceptedAt", interceptedAtMs))
174
+
175
+ val trimmed = if (arr.length() > MAX_PENDING_INTERCEPTS) {
176
+ JSONArray().also { t ->
177
+ for (i in (arr.length() - MAX_PENDING_INTERCEPTS) until arr.length()) t.put(arr.get(i))
178
+ }
179
+ } else {
180
+ arr
181
+ }
182
+
183
+ prefs.edit()
184
+ .putString(KEY_PENDING_INTERCEPTS, trimmed.toString())
185
+ .putLong(KEY_LAST_INTERCEPT_TS, interceptedAtMs)
186
+ .apply()
187
+ }
188
+
189
+ /** Return and clear the queued block events. */
190
+ fun drainIntercepts(context: Context): List<Map<String, Any>> {
191
+ val prefs = get(context)
192
+ val arr = try {
193
+ JSONArray(prefs.getString(KEY_PENDING_INTERCEPTS, "[]"))
194
+ } catch (e: Exception) {
195
+ JSONArray()
196
+ }
197
+ val out = ArrayList<Map<String, Any>>(arr.length())
198
+ for (i in 0 until arr.length()) {
199
+ val o = arr.getJSONObject(i)
200
+ out.add(
201
+ mapOf(
202
+ "appName" to o.optString("appName", ""),
203
+ "interceptedAt" to o.optDouble("interceptedAt"),
204
+ )
205
+ )
206
+ }
207
+ if (arr.length() > 0) prefs.edit().remove(KEY_PENDING_INTERCEPTS).apply()
208
+ return out
209
+ }
210
+
152
211
  private fun putNullableFloat(editor: SharedPreferences.Editor, key: String, value: Float?) {
153
212
  if (value != null) editor.putFloat(key, value) else editor.remove(key)
154
213
  }
@@ -97,10 +97,22 @@ class AppBlockerService : Service() {
97
97
  private fun enforceBlock(packageName: String, reason: BlockReason) {
98
98
  overlayManager.show(packageName, reason)
99
99
  showBlockedNotification(packageName, reason)
100
+ recordIntercept(packageName)
100
101
  blocking = true
101
102
  consumingSinceMs = 0L
102
103
  }
103
104
 
105
+ /** Queue this block event for the app to drain (debounced in prefs). */
106
+ private fun recordIntercept(packageName: String) {
107
+ val appName = try {
108
+ val pm = this.packageManager
109
+ pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
110
+ } catch (e: Exception) {
111
+ packageName
112
+ }
113
+ AppBlockerPrefs.appendIntercept(this, appName, System.currentTimeMillis())
114
+ }
115
+
104
116
  private fun showBlockedNotification(packageName: String, reason: BlockReason) {
105
117
  val appName = try {
106
118
  val pm = this.packageManager
@@ -103,6 +103,10 @@ class ExpoAppBlockerModule : Module() {
103
103
  AppBlockerPrefs.getBlockedPackages(context).toList()
104
104
  }
105
105
 
106
+ Function("drainPendingIntercepts") {
107
+ AppBlockerPrefs.drainIntercepts(context)
108
+ }
109
+
106
110
  Function("startMonitoring") {
107
111
  AppBlockerService.start(context)
108
112
  Log.d(TAG, "startMonitoring called")
@@ -18,10 +18,27 @@ public class ExpoAppBlockerModule: Module {
18
18
  // value > 0 means a temporary unlock is active. Enforcement is usage-based: the
19
19
  // shield is re-applied by the DeviceActivityMonitor once cumulative foreground
20
20
  // usage of the blocked apps reaches the threshold (see `startUsageBasedRelock`).
21
+ // Stores the granted budget in SECONDS (Int). Presence with value > 0 means a
22
+ // temporary unlock is active; the monitor reads it to know when to fully re-block.
21
23
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
22
24
  private let unlockActivityName = "appBlocker.temporaryUnlock"
23
- private let usageEventName = "appBlocker.usageThreshold"
25
+ // Sub-minute usage steps. We register one DeviceActivityEvent per `usageStepSeconds`
26
+ // of the budget (threshold = k×step seconds of measured usage). Each step's
27
+ // eventDidReachThreshold lets the monitor extension write consumed SECONDS back to
28
+ // the App Group — readable by the host app (unlike DeviceActivityReport). The event
29
+ // name carries its threshold in seconds (`appBlocker.usageStep.<seconds>`).
30
+ private let usageStepEventPrefix = "appBlocker.usageStep."
31
+ // Consumed seconds of the active unlock, written by the monitor extension.
32
+ private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
33
+ // Target resolution. Apple's thresholds are coarse/unreliable below ~a minute, so
34
+ // 30s is a best-effort finer grain; the per-step events that don't fire are still
35
+ // backstopped by later (coarser-elapsed) ones and the final re-block threshold.
36
+ private let usageStepSeconds = 30
37
+ // Cap on registered step events. For large budgets the step auto-coarsens so the
38
+ // event count stays under this (Apple degrades with too many events).
39
+ private let maxUsageSteps = 60
24
40
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
41
+ private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
25
42
  private let minimumTemporaryUnlockMinutes = 1
26
43
  private var didLoadPersistedConfig = false
27
44
 
@@ -170,7 +187,7 @@ public class ExpoAppBlockerModule: Module {
170
187
  self.currentBlockConfig = nil
171
188
  self.userDefaults.removeObject(forKey: self.blockConfigStorageKey)
172
189
  self.sharedDefaults?.removeObject(forKey: self.blockConfigStorageKey)
173
- self.sharedDefaults?.removeObject(forKey: self.temporaryUnlockKey)
190
+ self.clearUnlockState()
174
191
  }
175
192
  }
176
193
 
@@ -184,6 +201,16 @@ public class ExpoAppBlockerModule: Module {
184
201
  return hasPending
185
202
  }
186
203
 
204
+ Function("drainPendingIntercepts") { () -> [[String: Any]] in
205
+ guard let defaults = self.sharedDefaults else { return [] }
206
+ let queue = defaults.array(forKey: self.pendingInterceptsKey) as? [[String: Any]] ?? []
207
+ if !queue.isEmpty {
208
+ defaults.removeObject(forKey: self.pendingInterceptsKey)
209
+ defaults.synchronize()
210
+ }
211
+ return queue
212
+ }
213
+
187
214
  Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
188
215
  self.ensureLoadedPersistedConfig()
189
216
  guard let config = self.currentBlockConfig else {
@@ -206,6 +233,7 @@ public class ExpoAppBlockerModule: Module {
206
233
 
207
234
  let budgetSeconds = sanitizedDurationMinutes * 60
208
235
  self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
236
+ self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
209
237
 
210
238
  DispatchQueue.main.async {
211
239
  self.store.shield.applications = nil
@@ -216,7 +244,7 @@ public class ExpoAppBlockerModule: Module {
216
244
  // Re-block once cumulative *usage* of the blocked apps hits the budget.
217
245
  // iOS only counts foreground time, so the budget pauses/resumes for free.
218
246
  do {
219
- try self.startUsageBasedRelock(minutes: sanitizedDurationMinutes)
247
+ try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
220
248
  } catch {
221
249
  // Monitoring failed to start (e.g. threshold below Apple's granularity).
222
250
  // The unlock still happens; the shield falls back to re-applying on the
@@ -237,15 +265,16 @@ public class ExpoAppBlockerModule: Module {
237
265
  }
238
266
 
239
267
  Function("isTemporarilyUnlocked") { () -> Bool in
240
- return self.remainingUnlockBudgetSeconds() > 0
268
+ return self.remainingUnlockSeconds() > 0
241
269
  }
242
270
 
243
- // NOTE: On iOS this returns the *granted* budget, not a live remaining count —
244
- // Apple does not expose cumulative usage to the app, so the value does not tick
245
- // down as the blocked apps are used. It drops to 0 once the usage threshold
246
- // re-applies the shield (or on relock).
271
+ // Remaining = granted budget minus the consumed minutes the monitor extension
272
+ // has written to the App Group as usage accrued. Steps down by ~1 minute per
273
+ // minute of actual blocked-app usage and freezes while the apps aren't used.
274
+ // It's minute-granular (not smooth seconds) and may lag real usage slightly —
275
+ // Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
247
276
  Function("getRemainingUnlockTime") { () -> Int in
248
- return self.remainingUnlockBudgetSeconds()
277
+ return self.remainingUnlockSeconds()
249
278
  }
250
279
 
251
280
  AsyncFunction("relockApps") { (promise: Promise) in
@@ -341,7 +370,7 @@ public class ExpoAppBlockerModule: Module {
341
370
 
342
371
  ensureLoadedPersistedConfig()
343
372
 
344
- if remainingUnlockBudgetSeconds() > 0 {
373
+ if remainingUnlockSeconds() > 0 {
345
374
  // Unlock still active — keep the shield off. The usage-threshold monitor
346
375
  // started in `temporaryUnlock` persists across app launches and will
347
376
  // re-apply the shield once the usage budget is spent.
@@ -465,7 +494,7 @@ public class ExpoAppBlockerModule: Module {
465
494
  }
466
495
 
467
496
  private func relockApps() {
468
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
497
+ clearUnlockState()
469
498
  cancelRelockActivity()
470
499
  ensureLoadedPersistedConfig()
471
500
 
@@ -482,19 +511,26 @@ public class ExpoAppBlockerModule: Module {
482
511
  // MARK: - Activity Scheduling
483
512
 
484
513
  /// Start usage-based monitoring: re-apply the shield once cumulative foreground
485
- /// usage of the blocked apps reaches `minutes`. iOS counts only active usage, so
486
- /// the budget naturally pauses when the apps aren't in use and resumes on return.
514
+ /// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
515
+ /// so the budget naturally pauses when the apps aren't in use and resumes on return.
516
+ ///
517
+ /// We register a series of threshold events stepping by `usageStepSeconds` (auto-
518
+ /// coarsened so the count stays under `maxUsageSteps`). The event name carries its
519
+ /// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
520
+ /// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
521
+ /// consumed-second count to the App Group — giving the host app a sub-minute,
522
+ /// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
523
+ /// (== budget) is where the monitor re-applies the shield.
487
524
  ///
488
- /// Uses an all-day repeating schedule purely as a container for the usage event;
489
- /// the `eventDidReachThreshold` callback (in the DeviceActivityMonitor extension)
490
- /// does the actual relock. `repeats: true` keeps the monitor alive across days so
491
- /// apps are never left unlocked without enforcement; the tradeoff is that
492
- /// `intervalDidEnd` clears any unspent budget at the day boundary (earned time
493
- /// does not carry across midnight).
525
+ /// Uses an all-day repeating schedule purely as a container for the events.
526
+ /// `repeats: true` keeps the monitor alive across days so apps are never left
527
+ /// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
528
+ /// unspent budget at the day boundary (earned time does not carry across midnight).
494
529
  ///
495
- /// Note: Apple's usage thresholds are unreliable below a few minutes — very small
496
- /// budgets may relock late or only at the daily interval boundary.
497
- private func startUsageBasedRelock(minutes: Int) throws {
530
+ /// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
531
+ /// finest steps may fire late or be skipped — later steps and the final threshold
532
+ /// still re-block, bounding overshoot to roughly one step.
533
+ private func startUsageBasedRelock(budgetSeconds: Int) throws {
498
534
  scheduleLock.lock()
499
535
  defer { scheduleLock.unlock() }
500
536
 
@@ -513,23 +549,46 @@ public class ExpoAppBlockerModule: Module {
513
549
  return
514
550
  }
515
551
 
516
- let event = DeviceActivityEvent(
517
- applications: appTokens,
518
- categories: categoryTokens,
519
- webDomains: webDomainTokens,
520
- threshold: DateComponents(minute: max(1, minutes))
521
- )
552
+ let budget = max(1, budgetSeconds)
553
+ // Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
554
+ let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
555
+ var thresholds: [Int] = []
556
+ var t = step
557
+ while t < budget {
558
+ thresholds.append(t)
559
+ t += step
560
+ }
561
+ thresholds.append(budget) // always include the exact budget as the final re-block
562
+
563
+ var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
564
+ for seconds in thresholds {
565
+ events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
566
+ applications: appTokens,
567
+ categories: categoryTokens,
568
+ webDomains: webDomainTokens,
569
+ threshold: dateComponents(fromSeconds: seconds)
570
+ )
571
+ }
522
572
 
573
+ // CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
574
+ // thresholds measure usage accumulated *within the interval, from its start*. An
575
+ // all-day [00:00, 23:59] interval would count usage since midnight — so any prior
576
+ // blocked-app use today would have already crossed the thresholds before monitoring
577
+ // began, and the system never fires a (new) crossing → the shield never re-applies.
578
+ // Starting the interval at the current time makes thresholds count from the unlock
579
+ // moment. repeats:false because we re-register on every unlock.
580
+ let now = Date()
581
+ let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
523
582
  let schedule = DeviceActivitySchedule(
524
- intervalStart: DateComponents(hour: 0, minute: 0, second: 0),
583
+ intervalStart: startComps,
525
584
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
526
- repeats: true
585
+ repeats: false
527
586
  )
528
587
 
529
588
  try activityCenter.startMonitoring(
530
589
  DeviceActivityName(unlockActivityName),
531
590
  during: schedule,
532
- events: [DeviceActivityEvent.Name(usageEventName): event]
591
+ events: events
533
592
  )
534
593
  }
535
594
 
@@ -545,12 +604,33 @@ public class ExpoAppBlockerModule: Module {
545
604
  }
546
605
 
547
606
  private func isTemporarilyUnlockedInternal() -> Bool {
548
- return remainingUnlockBudgetSeconds() > 0
607
+ return remainingUnlockSeconds() > 0
608
+ }
609
+
610
+ /// Clear all persisted unlock state (budget + consumed counter).
611
+ private func clearUnlockState() {
612
+ sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
613
+ sharedDefaults?.removeObject(forKey: usageConsumedKey)
614
+ }
615
+
616
+ /// Seconds of earned time still available: the granted budget minus the seconds
617
+ /// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
618
+ /// or the budget is fully consumed. Clamped at 0.
619
+ private func remainingUnlockSeconds() -> Int {
620
+ let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
621
+ if budgetSeconds <= 0 { return 0 }
622
+ let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
623
+ return max(0, budgetSeconds - consumedSeconds)
549
624
  }
550
625
 
551
- /// Granted earned-time budget in seconds (0 if no active unlock). See `temporaryUnlockKey`.
552
- private func remainingUnlockBudgetSeconds() -> Int {
553
- return (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
626
+ /// Build a DateComponents threshold from a total number of seconds (normalized
627
+ /// into hour/minute/second so the system reads it cleanly).
628
+ private func dateComponents(fromSeconds total: Int) -> DateComponents {
629
+ return DateComponents(
630
+ hour: total / 3600,
631
+ minute: (total % 3600) / 60,
632
+ second: total % 60
633
+ )
554
634
  }
555
635
 
556
636
  // MARK: - Serialization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
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",
package/src/index.ts CHANGED
@@ -222,6 +222,28 @@ export function checkAndClearPendingUnlock(): boolean {
222
222
  return NativeModule.checkAndClearPendingUnlock();
223
223
  }
224
224
 
225
+ /**
226
+ * One OS-level block event: the blocker intercepted a blocked app (iOS
227
+ * shield render / Android foreground block). `interceptedAt` is epoch
228
+ * milliseconds; `appName` is the localized app name when the platform
229
+ * can resolve it (null otherwise).
230
+ */
231
+ export interface PendingIntercept {
232
+ appName: string | null;
233
+ interceptedAt: number;
234
+ }
235
+
236
+ /**
237
+ * Drain and clear the queue of block events recorded natively since the
238
+ * last call. Implemented on both platforms (iOS App Group queue / Android
239
+ * SharedPreferences queue). The app calls this on foreground and persists
240
+ * the results to power the "blocks" counter.
241
+ */
242
+ export function drainPendingIntercepts(): PendingIntercept[] {
243
+ if (Platform.OS !== "ios" && Platform.OS !== "android") return [];
244
+ return NativeModule.drainPendingIntercepts() ?? [];
245
+ }
246
+
225
247
  export function addPendingUnlockListener(
226
248
  handler: () => void
227
249
  ): { remove: () => void } | null {
@@ -4,11 +4,19 @@ import FamilyControls
4
4
  import Foundation
5
5
 
6
6
  @available(iOS 15.0, *)
7
- class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
7
+ // NOTE: the class name MUST be `DeviceActivityMonitorExtension` — it has to match
8
+ // the `NSExtensionPrincipalClass` (`$(PRODUCT_MODULE_NAME).DeviceActivityMonitorExtension`)
9
+ // that @bacons/apple-targets writes into the extension's Info.plist. If it doesn't
10
+ // match, iOS cannot instantiate the extension and NONE of the callbacks fire.
11
+ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
8
12
  // CONFIGURE: Replace with your App Group identifier
9
13
  private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
10
14
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
11
15
  private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
16
+ // Keep these in sync with ExpoAppBlockerModule.swift. temporaryUnlockKey holds the
17
+ // budget in seconds; usageConsumedKey accumulates consumed seconds.
18
+ private let usageStepEventPrefix = "appBlocker.usageStep."
19
+ private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
12
20
 
13
21
  private let store = ManagedSettingsStore()
14
22
  private var sharedDefaults: UserDefaults?
@@ -20,24 +28,61 @@ class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
20
28
 
21
29
  override func intervalDidEnd(for activity: DeviceActivityName) {
22
30
  super.intervalDidEnd(for: activity)
23
- // Daily safety net: clear any active unlock budget at the schedule boundary.
24
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
25
- reapplyBlockConfiguration()
31
+ // Intentionally a no-op. Re-block is driven solely by eventDidReachThreshold
32
+ // (the usage budget). We must NOT clear unlock state or reapply the shield here:
33
+ // stopMonitoring() during a re-grant also fires intervalDidEnd, which would wipe
34
+ // the freshly-granted budget and re-shield the apps immediately after the user
35
+ // earned time. (Tradeoff: an unspent budget is not force-cleared at the daily
36
+ // schedule boundary — an acceptable edge case.)
26
37
  }
27
38
 
28
39
  override func intervalDidStart(for activity: DeviceActivityName) {
29
40
  super.intervalDidStart(for: activity)
30
41
  }
31
42
 
43
+ // Fires once per usage step (threshold = N seconds of measured blocked-app use).
44
+ // We write the consumed-second count back to the App Group so the host app can
45
+ // show a live, pause-when-away countdown; once consumed reaches the budget we
46
+ // clear the unlock and re-apply the shield.
32
47
  override func eventDidReachThreshold(
33
48
  _ event: DeviceActivityEvent.Name,
34
49
  activity: DeviceActivityName
35
50
  ) {
36
51
  super.eventDidReachThreshold(event, activity: activity)
37
- // The user has spent their earned usage budget on the blocked apps —
38
- // clear the unlock and re-apply the shield.
52
+
53
+ let stepSeconds = parseStepSeconds(from: event.rawValue)
54
+ guard stepSeconds > 0 else {
55
+ // Unknown event — treat as a full relock to stay safe.
56
+ clearUnlockState()
57
+ reapplyBlockConfiguration()
58
+ return
59
+ }
60
+
61
+ // Record consumed seconds monotonically (steps can arrive out of order).
62
+ let prev = sharedDefaults?.integer(forKey: usageConsumedKey) ?? 0
63
+ if stepSeconds > prev {
64
+ sharedDefaults?.set(stepSeconds, forKey: usageConsumedKey)
65
+ }
66
+
67
+ let budgetSeconds = sharedDefaults?.integer(forKey: temporaryUnlockKey) ?? 0
68
+ if budgetSeconds <= 0 || stepSeconds >= budgetSeconds {
69
+ // Budget fully spent — re-block.
70
+ clearUnlockState()
71
+ reapplyBlockConfiguration()
72
+ }
73
+ }
74
+
75
+ /// Extract the threshold seconds from an event name like `appBlocker.usageStep.90`;
76
+ /// 0 if not a usage step.
77
+ private func parseStepSeconds(from rawName: String) -> Int {
78
+ guard rawName.hasPrefix(usageStepEventPrefix) else { return 0 }
79
+ let suffix = rawName.dropFirst(usageStepEventPrefix.count)
80
+ return Int(suffix) ?? 0
81
+ }
82
+
83
+ private func clearUnlockState() {
39
84
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
40
- reapplyBlockConfiguration()
85
+ sharedDefaults?.removeObject(forKey: usageConsumedKey)
41
86
  }
42
87
 
43
88
  private func reapplyBlockConfiguration() {
@@ -43,6 +43,30 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
43
43
  return Date() < expiration
44
44
  }
45
45
 
46
+ // Block-event queue, drained by the app into `blocker_intercepts` to power
47
+ // the "blocks" counter. The system re-renders the shield often (app
48
+ // switcher previews, re-foreground), so a short global debounce collapses
49
+ // those bursts into one logical block event.
50
+ private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
51
+ private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
52
+ private let interceptDebounceMs: Double = 2_000
53
+ private let maxPendingIntercepts = 200
54
+
55
+ private func recordIntercept(appName: String) {
56
+ guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
57
+ let nowMs = Date().timeIntervalSince1970 * 1000.0
58
+ let lastMs = defaults.double(forKey: lastInterceptTsKey)
59
+ if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
60
+
61
+ var queue = defaults.array(forKey: pendingInterceptsKey) as? [[String: Any]] ?? []
62
+ queue.append(["appName": appName, "interceptedAt": nowMs])
63
+ if queue.count > maxPendingIntercepts {
64
+ queue = Array(queue.suffix(maxPendingIntercepts))
65
+ }
66
+ defaults.set(queue, forKey: pendingInterceptsKey)
67
+ defaults.set(nowMs, forKey: lastInterceptTsKey)
68
+ }
69
+
46
70
  private func makeConfig(appName: String) -> ShieldConfiguration {
47
71
  if isTemporarilyUnlocked() {
48
72
  return ShieldConfiguration(
@@ -57,6 +81,10 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
57
81
  )
58
82
  }
59
83
 
84
+ // A blocked app is being shielded — this is a block event. Record it
85
+ // (debounced) for the app to drain.
86
+ recordIntercept(appName: appName)
87
+
60
88
  let count = getBlockedAppCount()
61
89
  // The plugin replaces this placeholder with a Swift string literal
62
90
  // containing `\(count)` interpolation, or `""` when the user opted out.