expo-app-blocker 0.1.71 → 0.1.73

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.
@@ -540,21 +540,42 @@ public class ExpoAppBlockerModule: Module {
540
540
  let activityName = DeviceActivityName(unlockActivityName)
541
541
  let calendar = Calendar.current
542
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
543
 
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())
560
+ }
561
+
562
+ let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
548
563
  let schedule: DeviceActivitySchedule
549
- if nowDay == expirationDay {
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
550
569
  schedule = DeviceActivitySchedule(
551
570
  intervalStart: startComponents,
552
571
  intervalEnd: endComponents,
553
- repeats: false
572
+ repeats: false,
573
+ warningTime: warning
554
574
  )
555
575
  } else {
556
- // Expiration crosses midnight — cap the interval at end-of-day; the host
557
- // relock handles the remainder on next foreground.
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.
558
579
  schedule = DeviceActivitySchedule(
559
580
  intervalStart: startComponents,
560
581
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
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",
@@ -24,24 +24,38 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
24
24
  sharedDefaults = UserDefaults(suiteName: appGroupIdentifier)
25
25
  }
26
26
 
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
+ }
35
+
27
36
  override func intervalDidEnd(for activity: DeviceActivityName) {
28
37
  super.intervalDidEnd(for: activity)
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.
38
+ relockIfExpired()
39
+ }
40
+
41
+ override func intervalDidStart(for activity: DeviceActivityName) {
42
+ super.intervalDidStart(for: activity)
43
+ }
44
+
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() {
33
51
  if let expiration = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date,
34
- Date() < expiration {
52
+ expiration.timeIntervalSinceNow > 60 {
35
53
  return
36
54
  }
37
55
  sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
38
56
  reapplyBlockConfiguration()
39
57
  }
40
58
 
41
- override func intervalDidStart(for activity: DeviceActivityName) {
42
- super.intervalDidStart(for: activity)
43
- }
44
-
45
59
  private func reapplyBlockConfiguration() {
46
60
  let userDefaults = sharedDefaults ?? UserDefaults.standard
47
61