expo-app-blocker 0.1.74 → 0.1.76
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.
|
@@ -8,6 +8,8 @@ jobs:
|
|
|
8
8
|
publish:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
if: "!startsWith(github.event.head_commit.message, 'v')"
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write
|
|
11
13
|
|
|
12
14
|
steps:
|
|
13
15
|
- uses: actions/checkout@v6
|
|
@@ -43,12 +45,17 @@ jobs:
|
|
|
43
45
|
npm version $NPM_VERSION --no-git-tag-version --allow-same-version
|
|
44
46
|
# Then bump
|
|
45
47
|
npm version ${{ steps.version.outputs.bump }} --no-git-tag-version
|
|
46
|
-
|
|
48
|
+
NEW_VERSION=$(node -p 'require("./package.json").version')
|
|
49
|
+
echo "New version: $NEW_VERSION"
|
|
50
|
+
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
|
51
|
+
id: bump
|
|
47
52
|
|
|
48
53
|
- name: Publish to npm
|
|
49
54
|
run: npm publish --access public
|
|
50
55
|
env:
|
|
51
56
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
52
57
|
|
|
53
|
-
- name:
|
|
54
|
-
|
|
58
|
+
- name: Create GitHub Release
|
|
59
|
+
env:
|
|
60
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
61
|
+
run: gh release create "v${{ steps.bump.outputs.new_version }}" --target "$GITHUB_SHA" --generate-notes
|
|
@@ -14,10 +14,31 @@ 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
|
-
// a temporary unlock is active
|
|
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 budget (see `startUsageBasedRelock`). The
|
|
21
|
+
// budget pauses when no blocked app is in use and resumes on return.
|
|
19
22
|
private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
|
|
23
|
+
// Consumed seconds of the active unlock, written by the monitor extension as
|
|
24
|
+
// blocked-app usage accrues. Remaining = budget − consumed.
|
|
25
|
+
private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
|
|
26
|
+
// Wall-clock instant the active budget was granted (Date). Used for the daily
|
|
27
|
+
// reset (budget is cleared when the calendar day changes) and as the upper bound
|
|
28
|
+
// for the monitor's premature-fire guard (usage can't exceed elapsed wall-clock).
|
|
29
|
+
private let unlockGrantedAtKey = "appBlocker.unlockGrantedAt.v1"
|
|
20
30
|
private let unlockActivityName = "appBlocker.temporaryUnlock"
|
|
31
|
+
// Sub-minute usage steps. We register one DeviceActivityEvent per `usageStepSeconds`
|
|
32
|
+
// of the budget (threshold = k×step seconds of measured usage). Each step's
|
|
33
|
+
// eventDidReachThreshold lets the monitor write consumed SECONDS back to the App
|
|
34
|
+
// Group. The event name carries its threshold (`appBlocker.usageStep.<seconds>`).
|
|
35
|
+
private let usageStepEventPrefix = "appBlocker.usageStep."
|
|
36
|
+
// Apple's usage thresholds are coarse/unreliable below ~a minute; 30s is a best-
|
|
37
|
+
// effort finer grain backstopped by later steps and the final (== budget) event.
|
|
38
|
+
private let usageStepSeconds = 30
|
|
39
|
+
// Cap on registered step events; the step auto-coarsens for large budgets so the
|
|
40
|
+
// event count stays under this (Apple degrades with too many events).
|
|
41
|
+
private let maxUsageSteps = 60
|
|
21
42
|
private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
|
|
22
43
|
private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
|
|
23
44
|
private let minimumTemporaryUnlockMinutes = 1
|
|
@@ -220,11 +241,14 @@ public class ExpoAppBlockerModule: Module {
|
|
|
220
241
|
return
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
let
|
|
227
|
-
|
|
244
|
+
// Usage-based budget is the source of truth: the grant is spent only while a
|
|
245
|
+
// blocked app is in the foreground, so it pauses on leave and resumes on
|
|
246
|
+
// return. (See `startUsageBasedRelock`.) The budget is cleared at midnight.
|
|
247
|
+
let budgetSeconds = sanitizedDurationMinutes * 60
|
|
248
|
+
let grantedAt = Date()
|
|
249
|
+
self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
|
|
250
|
+
self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
|
|
251
|
+
self.sharedDefaults?.set(grantedAt, forKey: self.unlockGrantedAtKey)
|
|
228
252
|
|
|
229
253
|
DispatchQueue.main.async {
|
|
230
254
|
self.store.shield.applications = nil
|
|
@@ -232,49 +256,43 @@ public class ExpoAppBlockerModule: Module {
|
|
|
232
256
|
self.store.shield.webDomains = nil
|
|
233
257
|
}
|
|
234
258
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
// getRemainingUnlockTime
|
|
238
|
-
// once the
|
|
259
|
+
// Arm usage-threshold monitoring so the monitor re-blocks once measured
|
|
260
|
+
// blocked-app usage reaches the budget. Non-fatal if it can't start — the
|
|
261
|
+
// host-side relock (getRemainingUnlockTime poll / foreground check) is a
|
|
262
|
+
// backstop once the monitor reports consumption.
|
|
239
263
|
do {
|
|
240
|
-
try self.
|
|
264
|
+
try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
|
|
241
265
|
} catch {
|
|
242
|
-
print("[AppBlocker]
|
|
266
|
+
print("[AppBlocker] startUsageBasedRelock failed: \(error.localizedDescription)")
|
|
243
267
|
}
|
|
244
268
|
|
|
245
269
|
DispatchQueue.main.async {
|
|
246
270
|
promise.resolve([
|
|
247
271
|
"unlocked": true,
|
|
248
|
-
"expiresAt":
|
|
272
|
+
"expiresAt": grantedAt.addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
|
|
249
273
|
])
|
|
250
274
|
}
|
|
251
275
|
}
|
|
252
276
|
}
|
|
253
277
|
|
|
254
278
|
Function("isTemporarilyUnlocked") { () -> Bool in
|
|
255
|
-
|
|
256
|
-
return false
|
|
257
|
-
}
|
|
258
|
-
if Date() < expirationDate {
|
|
259
|
-
return true
|
|
260
|
-
}
|
|
261
|
-
self.relockApps()
|
|
262
|
-
return false
|
|
279
|
+
return self.remainingUnlockSeconds() > 0
|
|
263
280
|
}
|
|
264
281
|
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
282
|
+
// Remaining earned-time seconds = granted budget − measured blocked-app usage.
|
|
283
|
+
// Stays flat while no blocked app is in use (the budget pauses on leave) and
|
|
284
|
+
// drops as the monitor reports consumption. When the host polls this (e.g. the
|
|
285
|
+
// blocking-status banner) and it has reached 0 — budget spent or the day rolled
|
|
286
|
+
// over — the shield is re-applied as a backstop to the monitor.
|
|
269
287
|
Function("getRemainingUnlockTime") { () -> Int in
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
let remaining = expirationDate.timeIntervalSince(Date())
|
|
288
|
+
let hadBudget = ((self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Int) ?? 0) > 0
|
|
289
|
+
let remaining = self.remainingUnlockSeconds()
|
|
274
290
|
if remaining > 0 {
|
|
275
|
-
return
|
|
291
|
+
return remaining
|
|
292
|
+
}
|
|
293
|
+
if hadBudget {
|
|
294
|
+
self.relockApps()
|
|
276
295
|
}
|
|
277
|
-
self.relockApps()
|
|
278
296
|
return 0
|
|
279
297
|
}
|
|
280
298
|
|
|
@@ -371,24 +389,23 @@ public class ExpoAppBlockerModule: Module {
|
|
|
371
389
|
|
|
372
390
|
ensureLoadedPersistedConfig()
|
|
373
391
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
//
|
|
392
|
+
let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
|
|
393
|
+
if budgetSeconds > 0 {
|
|
394
|
+
if remainingUnlockSeconds() > 0 {
|
|
395
|
+
// Budget still available (and the day hasn't rolled over) — keep the shield
|
|
396
|
+
// off. Do NOT re-arm monitoring here: DeviceActivity monitoring is system-
|
|
397
|
+
// level and survives app termination, so the schedule registered at grant
|
|
398
|
+
// time is still running. Re-registering would restart the usage interval at
|
|
399
|
+
// "now" and discard accrued usage — letting a user reset their budget by
|
|
400
|
+
// bouncing back to this app. The monitor re-blocks on its own once usage
|
|
401
|
+
// reaches the budget; this branch only ensures the shield stays off.
|
|
380
402
|
DispatchQueue.main.async {
|
|
381
403
|
self.store.shield.applications = nil
|
|
382
404
|
self.store.shield.applicationCategories = nil
|
|
383
405
|
self.store.shield.webDomains = nil
|
|
384
406
|
}
|
|
385
|
-
|
|
386
|
-
do {
|
|
387
|
-
try scheduleRelockActivity(expirationDate: expirationDate)
|
|
388
|
-
} catch {
|
|
389
|
-
relockApps()
|
|
390
|
-
}
|
|
391
407
|
} else {
|
|
408
|
+
// Budget spent or cleared by the daily reset.
|
|
392
409
|
relockApps()
|
|
393
410
|
}
|
|
394
411
|
} else if let config = currentBlockConfig {
|
|
@@ -522,68 +539,85 @@ public class ExpoAppBlockerModule: Module {
|
|
|
522
539
|
|
|
523
540
|
// MARK: - Activity Scheduling
|
|
524
541
|
|
|
525
|
-
///
|
|
526
|
-
/// `
|
|
527
|
-
///
|
|
528
|
-
///
|
|
542
|
+
/// Start usage-based monitoring: re-apply the shield once cumulative foreground
|
|
543
|
+
/// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
|
|
544
|
+
/// so the budget naturally pauses when the apps aren't in use and resumes on return.
|
|
545
|
+
///
|
|
546
|
+
/// We register a series of threshold events stepping by `usageStepSeconds` (auto-
|
|
547
|
+
/// coarsened so the count stays under `maxUsageSteps`). The event name carries its
|
|
548
|
+
/// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
|
|
549
|
+
/// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
|
|
550
|
+
/// consumed-second count to the App Group — giving the host app a sub-minute,
|
|
551
|
+
/// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
|
|
552
|
+
/// (== budget) is where the monitor re-applies the shield.
|
|
529
553
|
///
|
|
530
|
-
///
|
|
531
|
-
///
|
|
532
|
-
///
|
|
533
|
-
///
|
|
534
|
-
|
|
554
|
+
/// The interval ends at 23:59:59 so the monitor's `intervalDidEnd` clears any
|
|
555
|
+
/// unspent budget at the day boundary (earned time does not carry across midnight).
|
|
556
|
+
/// `repeats: false` because we re-register on every unlock.
|
|
557
|
+
///
|
|
558
|
+
/// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
|
|
559
|
+
/// finest steps may fire late or be skipped — later steps and the final threshold
|
|
560
|
+
/// still re-block, bounding overshoot to roughly one step.
|
|
561
|
+
private func startUsageBasedRelock(budgetSeconds: Int) throws {
|
|
535
562
|
scheduleLock.lock()
|
|
536
563
|
defer { scheduleLock.unlock() }
|
|
537
564
|
|
|
538
565
|
cancelRelockActivityLocked()
|
|
539
566
|
|
|
540
|
-
let
|
|
541
|
-
|
|
542
|
-
|
|
567
|
+
guard let config = currentBlockConfig else {
|
|
568
|
+
print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
let appTokens = Set(config.items.compactMap { $0.appToken })
|
|
572
|
+
let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
|
|
573
|
+
let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
|
|
543
574
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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())
|
|
575
|
+
guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
|
|
576
|
+
print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
|
|
577
|
+
return
|
|
560
578
|
}
|
|
561
579
|
|
|
562
|
-
let
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
intervalStart: startComponents,
|
|
581
|
-
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
582
|
-
repeats: false
|
|
580
|
+
let budget = max(1, budgetSeconds)
|
|
581
|
+
// Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
|
|
582
|
+
let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
|
|
583
|
+
var thresholds: [Int] = []
|
|
584
|
+
var t = step
|
|
585
|
+
while t < budget {
|
|
586
|
+
thresholds.append(t)
|
|
587
|
+
t += step
|
|
588
|
+
}
|
|
589
|
+
thresholds.append(budget) // always include the exact budget as the final re-block
|
|
590
|
+
|
|
591
|
+
var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
|
|
592
|
+
for seconds in thresholds {
|
|
593
|
+
events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
|
|
594
|
+
applications: appTokens,
|
|
595
|
+
categories: categoryTokens,
|
|
596
|
+
webDomains: webDomainTokens,
|
|
597
|
+
threshold: dateComponents(fromSeconds: seconds)
|
|
583
598
|
)
|
|
584
599
|
}
|
|
585
600
|
|
|
586
|
-
|
|
601
|
+
// CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
|
|
602
|
+
// thresholds measure usage accumulated *within the interval, from its start*. An
|
|
603
|
+
// all-day [00:00, 23:59] interval would count usage since midnight — so any prior
|
|
604
|
+
// blocked-app use today would have already crossed the thresholds before monitoring
|
|
605
|
+
// began, and the system never fires a (new) crossing → the shield never re-applies.
|
|
606
|
+
// Starting the interval at the current time makes thresholds count from the unlock
|
|
607
|
+
// moment. repeats:false because we re-register on every unlock.
|
|
608
|
+
let now = Date()
|
|
609
|
+
let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
|
|
610
|
+
let schedule = DeviceActivitySchedule(
|
|
611
|
+
intervalStart: startComps,
|
|
612
|
+
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
613
|
+
repeats: false
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
try activityCenter.startMonitoring(
|
|
617
|
+
DeviceActivityName(unlockActivityName),
|
|
618
|
+
during: schedule,
|
|
619
|
+
events: events
|
|
620
|
+
)
|
|
587
621
|
}
|
|
588
622
|
|
|
589
623
|
private func cancelRelockActivity() {
|
|
@@ -601,18 +635,39 @@ public class ExpoAppBlockerModule: Module {
|
|
|
601
635
|
return remainingUnlockSeconds() > 0
|
|
602
636
|
}
|
|
603
637
|
|
|
604
|
-
/// Clear all persisted unlock state (budget + consumed counter).
|
|
638
|
+
/// Clear all persisted unlock state (budget + consumed counter + grant time).
|
|
605
639
|
private func clearUnlockState() {
|
|
606
640
|
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
641
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
642
|
+
sharedDefaults?.removeObject(forKey: unlockGrantedAtKey)
|
|
607
643
|
}
|
|
608
644
|
|
|
609
|
-
/// Seconds of earned time still available:
|
|
610
|
-
///
|
|
645
|
+
/// Seconds of earned time still available: the granted budget minus the seconds of
|
|
646
|
+
/// blocked-app usage the monitor extension has recorded. Returns 0 if there is no
|
|
647
|
+
/// active unlock, the budget is fully consumed, or the grant is from a previous day
|
|
648
|
+
/// (earned time does not carry across midnight). Clamped at 0.
|
|
611
649
|
private func remainingUnlockSeconds() -> Int {
|
|
612
|
-
|
|
650
|
+
let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
|
|
651
|
+
if budgetSeconds <= 0 { return 0 }
|
|
652
|
+
|
|
653
|
+
// Daily reset: a grant made on an earlier calendar day is stale.
|
|
654
|
+
if let grantedAt = sharedDefaults?.object(forKey: unlockGrantedAtKey) as? Date,
|
|
655
|
+
!Calendar.current.isDate(grantedAt, inSameDayAs: Date()) {
|
|
613
656
|
return 0
|
|
614
657
|
}
|
|
615
|
-
|
|
658
|
+
|
|
659
|
+
let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
|
|
660
|
+
return max(0, budgetSeconds - consumedSeconds)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/// Build a DateComponents threshold from a total number of seconds (normalized
|
|
664
|
+
/// into hour/minute/second so the system reads it cleanly).
|
|
665
|
+
private func dateComponents(fromSeconds total: Int) -> DateComponents {
|
|
666
|
+
return DateComponents(
|
|
667
|
+
hour: total / 3600,
|
|
668
|
+
minute: (total % 3600) / 60,
|
|
669
|
+
second: total % 60
|
|
670
|
+
)
|
|
616
671
|
}
|
|
617
672
|
|
|
618
673
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-app-blocker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.76",
|
|
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,9 +11,16 @@ 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
|
-
//
|
|
15
|
-
// ExpoAppBlockerModule.swift. Presence
|
|
14
|
+
// Granted earned-time budget in SECONDS (Int); kept in sync with
|
|
15
|
+
// ExpoAppBlockerModule.swift. Presence with value > 0 means an unlock is active.
|
|
16
16
|
private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
|
|
17
|
+
// Consumed seconds, written here as blocked-app usage thresholds fire.
|
|
18
|
+
private let usageConsumedKey = "appBlocker.usageConsumedSeconds.v1"
|
|
19
|
+
// Wall-clock instant the budget was granted (Date) — upper bound for the
|
|
20
|
+
// premature-fire guard (measured usage can't exceed elapsed wall-clock).
|
|
21
|
+
private let unlockGrantedAtKey = "appBlocker.unlockGrantedAt.v1"
|
|
22
|
+
// Usage-step event-name prefix; the suffix is the threshold in seconds.
|
|
23
|
+
private let usageStepEventPrefix = "appBlocker.usageStep."
|
|
17
24
|
private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
|
|
18
25
|
|
|
19
26
|
private let store = ManagedSettingsStore()
|
|
@@ -24,36 +31,83 @@ class DeviceActivityMonitorExtension: DeviceActivityMonitor {
|
|
|
24
31
|
sharedDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
override func
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
/// Fires once per usage step (threshold = N seconds of measured blocked-app use).
|
|
35
|
+
/// Records consumed seconds back to the App Group so the host can show a paused,
|
|
36
|
+
/// pause-when-away countdown; once consumption reaches the budget, re-applies the
|
|
37
|
+
/// shield. This is the primary pause-on-leave relock path.
|
|
38
|
+
override func eventDidReachThreshold(
|
|
39
|
+
_ event: DeviceActivityEvent.Name,
|
|
40
|
+
activity: DeviceActivityName
|
|
41
|
+
) {
|
|
42
|
+
super.eventDidReachThreshold(event, activity: activity)
|
|
43
|
+
|
|
44
|
+
let stepSeconds = parseStepSeconds(from: event.rawValue)
|
|
45
|
+
guard stepSeconds > 0 else {
|
|
46
|
+
// Unknown event — treat as a full relock to stay safe.
|
|
47
|
+
clearUnlockState()
|
|
48
|
+
reapplyBlockConfiguration()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Premature-fire guard (iOS-26 bug + clock skew): measured usage can never
|
|
53
|
+
// exceed the wall-clock elapsed since the grant. If a step claims more usage
|
|
54
|
+
// than has physically elapsed (+30s tolerance), it's spurious — ignore it.
|
|
55
|
+
if let grantedAt = sharedDefaults?.object(forKey: unlockGrantedAtKey) as? Date {
|
|
56
|
+
let elapsed = Date().timeIntervalSince(grantedAt)
|
|
57
|
+
if Double(stepSeconds) > elapsed + 30 {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Record consumed seconds monotonically (steps can arrive out of order).
|
|
63
|
+
let prev = sharedDefaults?.integer(forKey: usageConsumedKey) ?? 0
|
|
64
|
+
if stepSeconds > prev {
|
|
65
|
+
sharedDefaults?.set(stepSeconds, forKey: usageConsumedKey)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let budgetSeconds = sharedDefaults?.integer(forKey: temporaryUnlockKey) ?? 0
|
|
69
|
+
if budgetSeconds <= 0 || stepSeconds >= budgetSeconds {
|
|
70
|
+
// Budget fully spent — re-block.
|
|
71
|
+
clearUnlockState()
|
|
72
|
+
reapplyBlockConfiguration()
|
|
73
|
+
}
|
|
34
74
|
}
|
|
35
75
|
|
|
76
|
+
/// Fires at the schedule's interval end (23:59:59) — the daily reset. Clears any
|
|
77
|
+
/// unspent budget and re-applies the shield so earned time does not carry across
|
|
78
|
+
/// midnight.
|
|
79
|
+
///
|
|
80
|
+
/// Guards against the spurious callback that `stopMonitoring()` fires during a
|
|
81
|
+
/// re-grant: that fire happens whenever the user earns time, not at the day
|
|
82
|
+
/// boundary, so only honor it in the last couple of minutes before midnight.
|
|
36
83
|
override func intervalDidEnd(for activity: DeviceActivityName) {
|
|
37
84
|
super.intervalDidEnd(for: activity)
|
|
38
|
-
|
|
85
|
+
|
|
86
|
+
let comps = Calendar.current.dateComponents([.hour, .minute], from: Date())
|
|
87
|
+
guard comps.hour == 23, (comps.minute ?? 0) >= 58 else {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
clearUnlockState()
|
|
91
|
+
reapplyBlockConfiguration()
|
|
39
92
|
}
|
|
40
93
|
|
|
41
94
|
override func intervalDidStart(for activity: DeviceActivityName) {
|
|
42
95
|
super.intervalDidStart(for: activity)
|
|
43
96
|
}
|
|
44
97
|
|
|
45
|
-
///
|
|
46
|
-
///
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
98
|
+
/// Extract the threshold seconds from an event name like `appBlocker.usageStep.90`;
|
|
99
|
+
/// 0 if the name is not a usage step.
|
|
100
|
+
private func parseStepSeconds(from rawName: String) -> Int {
|
|
101
|
+
guard rawName.hasPrefix(usageStepEventPrefix) else { return 0 }
|
|
102
|
+
let suffix = rawName.dropFirst(usageStepEventPrefix.count)
|
|
103
|
+
return Int(suffix) ?? 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Clear all persisted unlock state (budget + consumed counter + grant time).
|
|
107
|
+
private func clearUnlockState() {
|
|
55
108
|
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
56
|
-
|
|
109
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
110
|
+
sharedDefaults?.removeObject(forKey: unlockGrantedAtKey)
|
|
57
111
|
}
|
|
58
112
|
|
|
59
113
|
private func reapplyBlockConfiguration() {
|