expo-app-blocker 0.1.64 → 0.1.65

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
@@ -18,9 +18,25 @@ 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"
25
41
  private let minimumTemporaryUnlockMinutes = 1
26
42
  private var didLoadPersistedConfig = false
@@ -170,7 +186,7 @@ public class ExpoAppBlockerModule: Module {
170
186
  self.currentBlockConfig = nil
171
187
  self.userDefaults.removeObject(forKey: self.blockConfigStorageKey)
172
188
  self.sharedDefaults?.removeObject(forKey: self.blockConfigStorageKey)
173
- self.sharedDefaults?.removeObject(forKey: self.temporaryUnlockKey)
189
+ self.clearUnlockState()
174
190
  }
175
191
  }
176
192
 
@@ -206,6 +222,7 @@ public class ExpoAppBlockerModule: Module {
206
222
 
207
223
  let budgetSeconds = sanitizedDurationMinutes * 60
208
224
  self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
225
+ self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
209
226
 
210
227
  DispatchQueue.main.async {
211
228
  self.store.shield.applications = nil
@@ -216,7 +233,7 @@ public class ExpoAppBlockerModule: Module {
216
233
  // Re-block once cumulative *usage* of the blocked apps hits the budget.
217
234
  // iOS only counts foreground time, so the budget pauses/resumes for free.
218
235
  do {
219
- try self.startUsageBasedRelock(minutes: sanitizedDurationMinutes)
236
+ try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
220
237
  } catch {
221
238
  // Monitoring failed to start (e.g. threshold below Apple's granularity).
222
239
  // The unlock still happens; the shield falls back to re-applying on the
@@ -237,15 +254,16 @@ public class ExpoAppBlockerModule: Module {
237
254
  }
238
255
 
239
256
  Function("isTemporarilyUnlocked") { () -> Bool in
240
- return self.remainingUnlockBudgetSeconds() > 0
257
+ return self.remainingUnlockSeconds() > 0
241
258
  }
242
259
 
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).
260
+ // Remaining = granted budget minus the consumed minutes the monitor extension
261
+ // has written to the App Group as usage accrued. Steps down by ~1 minute per
262
+ // minute of actual blocked-app usage and freezes while the apps aren't used.
263
+ // It's minute-granular (not smooth seconds) and may lag real usage slightly —
264
+ // Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
247
265
  Function("getRemainingUnlockTime") { () -> Int in
248
- return self.remainingUnlockBudgetSeconds()
266
+ return self.remainingUnlockSeconds()
249
267
  }
250
268
 
251
269
  AsyncFunction("relockApps") { (promise: Promise) in
@@ -341,7 +359,7 @@ public class ExpoAppBlockerModule: Module {
341
359
 
342
360
  ensureLoadedPersistedConfig()
343
361
 
344
- if remainingUnlockBudgetSeconds() > 0 {
362
+ if remainingUnlockSeconds() > 0 {
345
363
  // Unlock still active — keep the shield off. The usage-threshold monitor
346
364
  // started in `temporaryUnlock` persists across app launches and will
347
365
  // re-apply the shield once the usage budget is spent.
@@ -465,7 +483,7 @@ public class ExpoAppBlockerModule: Module {
465
483
  }
466
484
 
467
485
  private func relockApps() {
468
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
486
+ clearUnlockState()
469
487
  cancelRelockActivity()
470
488
  ensureLoadedPersistedConfig()
471
489
 
@@ -482,19 +500,26 @@ public class ExpoAppBlockerModule: Module {
482
500
  // MARK: - Activity Scheduling
483
501
 
484
502
  /// 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.
503
+ /// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
504
+ /// so the budget naturally pauses when the apps aren't in use and resumes on return.
505
+ ///
506
+ /// We register a series of threshold events stepping by `usageStepSeconds` (auto-
507
+ /// coarsened so the count stays under `maxUsageSteps`). The event name carries its
508
+ /// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
509
+ /// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
510
+ /// consumed-second count to the App Group — giving the host app a sub-minute,
511
+ /// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
512
+ /// (== budget) is where the monitor re-applies the shield.
487
513
  ///
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).
514
+ /// Uses an all-day repeating schedule purely as a container for the events.
515
+ /// `repeats: true` keeps the monitor alive across days so apps are never left
516
+ /// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
517
+ /// unspent budget at the day boundary (earned time does not carry across midnight).
494
518
  ///
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 {
519
+ /// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
520
+ /// finest steps may fire late or be skipped — later steps and the final threshold
521
+ /// still re-block, bounding overshoot to roughly one step.
522
+ private func startUsageBasedRelock(budgetSeconds: Int) throws {
498
523
  scheduleLock.lock()
499
524
  defer { scheduleLock.unlock() }
500
525
 
@@ -513,23 +538,46 @@ public class ExpoAppBlockerModule: Module {
513
538
  return
514
539
  }
515
540
 
516
- let event = DeviceActivityEvent(
517
- applications: appTokens,
518
- categories: categoryTokens,
519
- webDomains: webDomainTokens,
520
- threshold: DateComponents(minute: max(1, minutes))
521
- )
541
+ let budget = max(1, budgetSeconds)
542
+ // Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
543
+ let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
544
+ var thresholds: [Int] = []
545
+ var t = step
546
+ while t < budget {
547
+ thresholds.append(t)
548
+ t += step
549
+ }
550
+ thresholds.append(budget) // always include the exact budget as the final re-block
551
+
552
+ var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
553
+ for seconds in thresholds {
554
+ events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
555
+ applications: appTokens,
556
+ categories: categoryTokens,
557
+ webDomains: webDomainTokens,
558
+ threshold: dateComponents(fromSeconds: seconds)
559
+ )
560
+ }
522
561
 
562
+ // CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
563
+ // thresholds measure usage accumulated *within the interval, from its start*. An
564
+ // all-day [00:00, 23:59] interval would count usage since midnight — so any prior
565
+ // blocked-app use today would have already crossed the thresholds before monitoring
566
+ // began, and the system never fires a (new) crossing → the shield never re-applies.
567
+ // Starting the interval at the current time makes thresholds count from the unlock
568
+ // moment. repeats:false because we re-register on every unlock.
569
+ let now = Date()
570
+ let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
523
571
  let schedule = DeviceActivitySchedule(
524
- intervalStart: DateComponents(hour: 0, minute: 0, second: 0),
572
+ intervalStart: startComps,
525
573
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
526
- repeats: true
574
+ repeats: false
527
575
  )
528
576
 
529
577
  try activityCenter.startMonitoring(
530
578
  DeviceActivityName(unlockActivityName),
531
579
  during: schedule,
532
- events: [DeviceActivityEvent.Name(usageEventName): event]
580
+ events: events
533
581
  )
534
582
  }
535
583
 
@@ -545,12 +593,33 @@ public class ExpoAppBlockerModule: Module {
545
593
  }
546
594
 
547
595
  private func isTemporarilyUnlockedInternal() -> Bool {
548
- return remainingUnlockBudgetSeconds() > 0
596
+ return remainingUnlockSeconds() > 0
549
597
  }
550
598
 
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
599
+ /// Clear all persisted unlock state (budget + consumed counter).
600
+ private func clearUnlockState() {
601
+ sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
602
+ sharedDefaults?.removeObject(forKey: usageConsumedKey)
603
+ }
604
+
605
+ /// Seconds of earned time still available: the granted budget minus the seconds
606
+ /// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
607
+ /// or the budget is fully consumed. Clamped at 0.
608
+ private func remainingUnlockSeconds() -> Int {
609
+ let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
610
+ if budgetSeconds <= 0 { return 0 }
611
+ let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
612
+ return max(0, budgetSeconds - consumedSeconds)
613
+ }
614
+
615
+ /// Build a DateComponents threshold from a total number of seconds (normalized
616
+ /// into hour/minute/second so the system reads it cleanly).
617
+ private func dateComponents(fromSeconds total: Int) -> DateComponents {
618
+ return DateComponents(
619
+ hour: total / 3600,
620
+ minute: (total % 3600) / 60,
621
+ second: total % 60
622
+ )
554
623
  }
555
624
 
556
625
  // 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.65",
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",
@@ -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() {