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
|
|
517
|
-
| `getRemainingUnlockTime()` | Live — ticks down while inside a blocked app, freezes otherwise |
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
257
|
+
return self.remainingUnlockSeconds() > 0
|
|
241
258
|
}
|
|
242
259
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
489
|
-
///
|
|
490
|
-
///
|
|
491
|
-
///
|
|
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
|
|
496
|
-
///
|
|
497
|
-
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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:
|
|
572
|
+
intervalStart: startComps,
|
|
525
573
|
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
526
|
-
repeats:
|
|
574
|
+
repeats: false
|
|
527
575
|
)
|
|
528
576
|
|
|
529
577
|
try activityCenter.startMonitoring(
|
|
530
578
|
DeviceActivityName(unlockActivityName),
|
|
531
579
|
during: schedule,
|
|
532
|
-
events:
|
|
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
|
|
596
|
+
return remainingUnlockSeconds() > 0
|
|
549
597
|
}
|
|
550
598
|
|
|
551
|
-
///
|
|
552
|
-
private func
|
|
553
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
85
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
41
86
|
}
|
|
42
87
|
|
|
43
88
|
private func reapplyBlockConfiguration() {
|