expo-app-blocker 0.1.70 → 0.1.72

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,29 +14,10 @@ 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 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 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.
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.
23
19
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
24
20
  private let unlockActivityName = "appBlocker.temporaryUnlock"
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
40
21
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
41
22
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
42
23
  private let minimumTemporaryUnlockMinutes = 1
@@ -239,9 +220,11 @@ public class ExpoAppBlockerModule: Module {
239
220
  return
240
221
  }
241
222
 
242
- let budgetSeconds = sanitizedDurationMinutes * 60
243
- self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
244
- self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
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)
245
228
 
246
229
  DispatchQueue.main.async {
247
230
  self.store.shield.applications = nil
@@ -249,40 +232,50 @@ public class ExpoAppBlockerModule: Module {
249
232
  self.store.shield.webDomains = nil
250
233
  }
251
234
 
252
- // Re-block once cumulative *usage* of the blocked apps hits the budget.
253
- // iOS only counts foreground time, so the budget pauses/resumes for free.
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.
254
239
  do {
255
- try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
240
+ try self.scheduleRelockActivity(expirationDate: expirationDate)
256
241
  } catch {
257
- // Monitoring failed to start (e.g. threshold below Apple's granularity).
258
- // The unlock still happens; the shield falls back to re-applying on the
259
- // next app launch via checkAndApplyUnlockState.
260
- print("[AppBlocker] Usage-based relock failed to start: \(error.localizedDescription)")
242
+ print("[AppBlocker] Schedule relock failed (duration may be too short): \(error.localizedDescription)")
261
243
  }
262
244
 
263
- // `expiresAt` is a best-effort wall-clock hint only — actual relock is
264
- // usage-based, so the real expiry depends on how much the apps are used.
265
- let approxExpiresAt = Date().addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
266
245
  DispatchQueue.main.async {
267
246
  promise.resolve([
268
247
  "unlocked": true,
269
- "expiresAt": approxExpiresAt
248
+ "expiresAt": expirationDate.timeIntervalSince1970
270
249
  ])
271
250
  }
272
251
  }
273
252
  }
274
253
 
275
254
  Function("isTemporarilyUnlocked") { () -> Bool in
276
- return self.remainingUnlockSeconds() > 0
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
277
263
  }
278
264
 
279
- // Remaining = granted budget minus the consumed minutes the monitor extension
280
- // has written to the App Group as usage accrued. Steps down by ~1 minute per
281
- // minute of actual blocked-app usage and freezes while the apps aren't used.
282
- // It's minute-granular (not smooth seconds) and may lag real usage slightly —
283
- // Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
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.
284
269
  Function("getRemainingUnlockTime") { () -> Int in
285
- return self.remainingUnlockSeconds()
270
+ guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
271
+ return 0
272
+ }
273
+ let remaining = expirationDate.timeIntervalSince(Date())
274
+ if remaining > 0 {
275
+ return Int(remaining)
276
+ }
277
+ self.relockApps()
278
+ return 0
286
279
  }
287
280
 
288
281
  AsyncFunction("relockApps") { (promise: Promise) in
@@ -378,14 +371,25 @@ public class ExpoAppBlockerModule: Module {
378
371
 
379
372
  ensureLoadedPersistedConfig()
380
373
 
381
- if remainingUnlockSeconds() > 0 {
382
- // Unlock still active — keep the shield off. The usage-threshold monitor
383
- // started in `temporaryUnlock` persists across app launches and will
384
- // re-apply the shield once the usage budget is spent.
385
- DispatchQueue.main.async {
386
- self.store.shield.applications = nil
387
- self.store.shield.applicationCategories = nil
388
- self.store.shield.webDomains = nil
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.
380
+ DispatchQueue.main.async {
381
+ self.store.shield.applications = nil
382
+ self.store.shield.applicationCategories = nil
383
+ self.store.shield.webDomains = nil
384
+ }
385
+
386
+ do {
387
+ try scheduleRelockActivity(expirationDate: expirationDate)
388
+ } catch {
389
+ relockApps()
390
+ }
391
+ } else {
392
+ relockApps()
389
393
  }
390
394
  } else if let config = currentBlockConfig {
391
395
  do {
@@ -518,86 +522,47 @@ public class ExpoAppBlockerModule: Module {
518
522
 
519
523
  // MARK: - Activity Scheduling
520
524
 
521
- /// Start usage-based monitoring: re-apply the shield once cumulative foreground
522
- /// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
523
- /// so the budget naturally pauses when the apps aren't in use and resumes on return.
524
- ///
525
- /// We register a series of threshold events stepping by `usageStepSeconds` (auto-
526
- /// coarsened so the count stays under `maxUsageSteps`). The event name carries its
527
- /// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
528
- /// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
529
- /// consumed-second count to the App Group — giving the host app a sub-minute,
530
- /// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
531
- /// (== budget) is where the monitor re-applies the shield.
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.
532
529
  ///
533
- /// Uses an all-day repeating schedule purely as a container for the events.
534
- /// `repeats: true` keeps the monitor alive across days so apps are never left
535
- /// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
536
- /// unspent budget at the day boundary (earned time does not carry across midnight).
537
- ///
538
- /// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
539
- /// finest steps may fire late or be skipped — later steps and the final threshold
540
- /// still re-block, bounding overshoot to roughly one step.
541
- private func startUsageBasedRelock(budgetSeconds: Int) throws {
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 {
542
535
  scheduleLock.lock()
543
536
  defer { scheduleLock.unlock() }
544
537
 
545
538
  cancelRelockActivityLocked()
546
539
 
547
- guard let config = currentBlockConfig else {
548
- print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
549
- return
550
- }
551
- let appTokens = Set(config.items.compactMap { $0.appToken })
552
- let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
553
- let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
554
-
555
- guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
556
- print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
557
- return
558
- }
559
-
560
- let budget = max(1, budgetSeconds)
561
- // Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
562
- let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
563
- var thresholds: [Int] = []
564
- var t = step
565
- while t < budget {
566
- thresholds.append(t)
567
- t += step
568
- }
569
- thresholds.append(budget) // always include the exact budget as the final re-block
570
-
571
- var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
572
- for seconds in thresholds {
573
- events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
574
- applications: appTokens,
575
- categories: categoryTokens,
576
- webDomains: webDomainTokens,
577
- threshold: dateComponents(fromSeconds: seconds)
540
+ let activityName = DeviceActivityName(unlockActivityName)
541
+ let calendar = Calendar.current
542
+ let now = Date()
543
+ let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
544
+ let endComponents = calendar.dateComponents([.hour, .minute, .second], from: expirationDate)
545
+ let nowDay = calendar.startOfDay(for: now)
546
+ let expirationDay = calendar.startOfDay(for: expirationDate)
547
+
548
+ let schedule: DeviceActivitySchedule
549
+ if nowDay == expirationDay {
550
+ schedule = DeviceActivitySchedule(
551
+ intervalStart: startComponents,
552
+ intervalEnd: endComponents,
553
+ repeats: false
554
+ )
555
+ } else {
556
+ // Expiration crosses midnight — cap the interval at end-of-day; the host
557
+ // relock handles the remainder on next foreground.
558
+ schedule = DeviceActivitySchedule(
559
+ intervalStart: startComponents,
560
+ intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
561
+ repeats: false
578
562
  )
579
563
  }
580
564
 
581
- // CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
582
- // thresholds measure usage accumulated *within the interval, from its start*. An
583
- // all-day [00:00, 23:59] interval would count usage since midnight — so any prior
584
- // blocked-app use today would have already crossed the thresholds before monitoring
585
- // began, and the system never fires a (new) crossing → the shield never re-applies.
586
- // Starting the interval at the current time makes thresholds count from the unlock
587
- // moment. repeats:false because we re-register on every unlock.
588
- let now = Date()
589
- let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
590
- let schedule = DeviceActivitySchedule(
591
- intervalStart: startComps,
592
- intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
593
- repeats: false
594
- )
595
-
596
- try activityCenter.startMonitoring(
597
- DeviceActivityName(unlockActivityName),
598
- during: schedule,
599
- events: events
600
- )
565
+ try activityCenter.startMonitoring(activityName, during: schedule)
601
566
  }
602
567
 
603
568
  private func cancelRelockActivity() {
@@ -618,28 +583,17 @@ public class ExpoAppBlockerModule: Module {
618
583
  /// Clear all persisted unlock state (budget + consumed counter).
619
584
  private func clearUnlockState() {
620
585
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
621
- sharedDefaults?.removeObject(forKey: usageConsumedKey)
622
586
  }
623
587
 
624
- /// Seconds of earned time still available: the granted budget minus the seconds
625
- /// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
626
- /// or the budget is fully consumed. Clamped at 0.
588
+ /// Seconds of earned time still available: wall-clock time until the grant's
589
+ /// expiration. 0 if no active unlock or it has already expired. Clamped at 0.
627
590
  private func remainingUnlockSeconds() -> Int {
628
- let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
629
- if budgetSeconds <= 0 { return 0 }
630
- let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
631
- return max(0, budgetSeconds - consumedSeconds)
591
+ guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
592
+ return 0
593
+ }
594
+ return max(0, Int(expirationDate.timeIntervalSince(Date())))
632
595
  }
633
596
 
634
- /// Build a DateComponents threshold from a total number of seconds (normalized
635
- /// into hour/minute/second so the system reads it cleanly).
636
- private func dateComponents(fromSeconds total: Int) -> DateComponents {
637
- return DateComponents(
638
- hour: total / 3600,
639
- minute: (total % 3600) / 60,
640
- second: total % 60
641
- )
642
- }
643
597
 
644
598
  // MARK: - Serialization
645
599
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
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,12 +11,10 @@ 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
16
  private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
15
17
  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"
20
18
 
21
19
  private let store = ManagedSettingsStore()
22
20
  private var sharedDefaults: UserDefaults?
@@ -28,61 +26,20 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
28
26
 
29
27
  override func intervalDidEnd(for activity: DeviceActivityName) {
30
28
  super.intervalDidEnd(for: activity)
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.)
37
- }
38
-
39
- override func intervalDidStart(for activity: DeviceActivityName) {
40
- super.intervalDidStart(for: activity)
41
- }
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.
47
- override func eventDidReachThreshold(
48
- _ event: DeviceActivityEvent.Name,
49
- activity: DeviceActivityName
50
- ) {
51
- super.eventDidReachThreshold(event, activity: activity)
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()
29
+ // The schedule's interval ends at the grant's wall-clock expiration → re-block.
30
+ // Guard against the spurious intervalDidEnd that stopMonitoring() fires during a
31
+ // re-grant: if the stored expiration is still in the future this is a re-arm, not
32
+ // an expiry, so leave the freshly-granted unlock intact.
33
+ if let expiration = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date,
34
+ Date() < expiration {
58
35
  return
59
36
  }
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
37
+ sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
38
+ reapplyBlockConfiguration()
81
39
  }
82
40
 
83
- private func clearUnlockState() {
84
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
85
- sharedDefaults?.removeObject(forKey: usageConsumedKey)
41
+ override func intervalDidStart(for activity: DeviceActivityName) {
42
+ super.intervalDidStart(for: activity)
86
43
  }
87
44
 
88
45
  private func reapplyBlockConfiguration() {