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
|
|
18
|
-
//
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
//
|
|
253
|
-
//
|
|
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.
|
|
240
|
+
try self.scheduleRelockActivity(expirationDate: expirationDate)
|
|
256
241
|
} catch {
|
|
257
|
-
|
|
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":
|
|
248
|
+
"expiresAt": expirationDate.timeIntervalSince1970
|
|
270
249
|
])
|
|
271
250
|
}
|
|
272
251
|
}
|
|
273
252
|
}
|
|
274
253
|
|
|
275
254
|
Function("isTemporarilyUnlocked") { () -> Bool in
|
|
276
|
-
|
|
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
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
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
|
-
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
///
|
|
522
|
-
///
|
|
523
|
-
///
|
|
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
|
-
///
|
|
534
|
-
///
|
|
535
|
-
///
|
|
536
|
-
///
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
let
|
|
552
|
-
let
|
|
553
|
-
let
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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:
|
|
625
|
-
///
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
return max(0,
|
|
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.
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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() {
|