expo-app-blocker 0.1.64 → 0.1.66
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 +3 -3
- package/android/src/main/java/expo/modules/appblocker/AppBlockerPrefs.kt +59 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +12 -0
- package/android/src/main/java/expo/modules/appblocker/ExpoAppBlockerModule.kt +4 -0
- package/ios/ExpoAppBlockerModule.swift +115 -35
- package/package.json +1 -1
- package/src/index.ts +22 -0
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +52 -7
- package/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +28 -0
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
|
|
@@ -2,9 +2,15 @@ package expo.modules.appblocker
|
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
4
|
import android.content.SharedPreferences
|
|
5
|
+
import org.json.JSONArray
|
|
6
|
+
import org.json.JSONObject
|
|
5
7
|
|
|
6
8
|
object AppBlockerPrefs {
|
|
7
9
|
const val PREFS_NAME = "expo_app_blocker_prefs"
|
|
10
|
+
private const val KEY_PENDING_INTERCEPTS = "pending_intercepts"
|
|
11
|
+
private const val KEY_LAST_INTERCEPT_TS = "last_intercept_ts"
|
|
12
|
+
private const val INTERCEPT_DEBOUNCE_MS = 2_000L
|
|
13
|
+
private const val MAX_PENDING_INTERCEPTS = 200
|
|
8
14
|
const val KEY_BLOCKED_PACKAGES = "blocked_packages"
|
|
9
15
|
private const val KEY_OVERLAY_TITLE = "overlay_title"
|
|
10
16
|
private const val KEY_OVERLAY_TEXT = "overlay_text"
|
|
@@ -149,6 +155,59 @@ object AppBlockerPrefs {
|
|
|
149
155
|
fun getNotificationText(context: Context): String =
|
|
150
156
|
get(context).getString(KEY_NOTIFICATION_TEXT, null) ?: "{appName} is blocked. Tap to manage."
|
|
151
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Queue one OS-level block event for the app to drain into
|
|
160
|
+
* `blocker_intercepts`. Debounced globally so the poll loop can't emit
|
|
161
|
+
* duplicates for a single block, and capped to bound storage.
|
|
162
|
+
*/
|
|
163
|
+
fun appendIntercept(context: Context, appName: String, interceptedAtMs: Long) {
|
|
164
|
+
val prefs = get(context)
|
|
165
|
+
val lastTs = prefs.getLong(KEY_LAST_INTERCEPT_TS, 0L)
|
|
166
|
+
if (lastTs > 0L && interceptedAtMs - lastTs < INTERCEPT_DEBOUNCE_MS) return
|
|
167
|
+
|
|
168
|
+
val arr = try {
|
|
169
|
+
JSONArray(prefs.getString(KEY_PENDING_INTERCEPTS, "[]"))
|
|
170
|
+
} catch (e: Exception) {
|
|
171
|
+
JSONArray()
|
|
172
|
+
}
|
|
173
|
+
arr.put(JSONObject().put("appName", appName).put("interceptedAt", interceptedAtMs))
|
|
174
|
+
|
|
175
|
+
val trimmed = if (arr.length() > MAX_PENDING_INTERCEPTS) {
|
|
176
|
+
JSONArray().also { t ->
|
|
177
|
+
for (i in (arr.length() - MAX_PENDING_INTERCEPTS) until arr.length()) t.put(arr.get(i))
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
arr
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
prefs.edit()
|
|
184
|
+
.putString(KEY_PENDING_INTERCEPTS, trimmed.toString())
|
|
185
|
+
.putLong(KEY_LAST_INTERCEPT_TS, interceptedAtMs)
|
|
186
|
+
.apply()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Return and clear the queued block events. */
|
|
190
|
+
fun drainIntercepts(context: Context): List<Map<String, Any>> {
|
|
191
|
+
val prefs = get(context)
|
|
192
|
+
val arr = try {
|
|
193
|
+
JSONArray(prefs.getString(KEY_PENDING_INTERCEPTS, "[]"))
|
|
194
|
+
} catch (e: Exception) {
|
|
195
|
+
JSONArray()
|
|
196
|
+
}
|
|
197
|
+
val out = ArrayList<Map<String, Any>>(arr.length())
|
|
198
|
+
for (i in 0 until arr.length()) {
|
|
199
|
+
val o = arr.getJSONObject(i)
|
|
200
|
+
out.add(
|
|
201
|
+
mapOf(
|
|
202
|
+
"appName" to o.optString("appName", ""),
|
|
203
|
+
"interceptedAt" to o.optDouble("interceptedAt"),
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
if (arr.length() > 0) prefs.edit().remove(KEY_PENDING_INTERCEPTS).apply()
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
152
211
|
private fun putNullableFloat(editor: SharedPreferences.Editor, key: String, value: Float?) {
|
|
153
212
|
if (value != null) editor.putFloat(key, value) else editor.remove(key)
|
|
154
213
|
}
|
|
@@ -97,10 +97,22 @@ class AppBlockerService : Service() {
|
|
|
97
97
|
private fun enforceBlock(packageName: String, reason: BlockReason) {
|
|
98
98
|
overlayManager.show(packageName, reason)
|
|
99
99
|
showBlockedNotification(packageName, reason)
|
|
100
|
+
recordIntercept(packageName)
|
|
100
101
|
blocking = true
|
|
101
102
|
consumingSinceMs = 0L
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Queue this block event for the app to drain (debounced in prefs). */
|
|
106
|
+
private fun recordIntercept(packageName: String) {
|
|
107
|
+
val appName = try {
|
|
108
|
+
val pm = this.packageManager
|
|
109
|
+
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
|
|
110
|
+
} catch (e: Exception) {
|
|
111
|
+
packageName
|
|
112
|
+
}
|
|
113
|
+
AppBlockerPrefs.appendIntercept(this, appName, System.currentTimeMillis())
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
private fun showBlockedNotification(packageName: String, reason: BlockReason) {
|
|
105
117
|
val appName = try {
|
|
106
118
|
val pm = this.packageManager
|
|
@@ -103,6 +103,10 @@ class ExpoAppBlockerModule : Module() {
|
|
|
103
103
|
AppBlockerPrefs.getBlockedPackages(context).toList()
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
Function("drainPendingIntercepts") {
|
|
107
|
+
AppBlockerPrefs.drainIntercepts(context)
|
|
108
|
+
}
|
|
109
|
+
|
|
106
110
|
Function("startMonitoring") {
|
|
107
111
|
AppBlockerService.start(context)
|
|
108
112
|
Log.d(TAG, "startMonitoring called")
|
|
@@ -18,10 +18,27 @@ 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"
|
|
41
|
+
private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
|
|
25
42
|
private let minimumTemporaryUnlockMinutes = 1
|
|
26
43
|
private var didLoadPersistedConfig = false
|
|
27
44
|
|
|
@@ -170,7 +187,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
170
187
|
self.currentBlockConfig = nil
|
|
171
188
|
self.userDefaults.removeObject(forKey: self.blockConfigStorageKey)
|
|
172
189
|
self.sharedDefaults?.removeObject(forKey: self.blockConfigStorageKey)
|
|
173
|
-
self.
|
|
190
|
+
self.clearUnlockState()
|
|
174
191
|
}
|
|
175
192
|
}
|
|
176
193
|
|
|
@@ -184,6 +201,16 @@ public class ExpoAppBlockerModule: Module {
|
|
|
184
201
|
return hasPending
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
Function("drainPendingIntercepts") { () -> [[String: Any]] in
|
|
205
|
+
guard let defaults = self.sharedDefaults else { return [] }
|
|
206
|
+
let queue = defaults.array(forKey: self.pendingInterceptsKey) as? [[String: Any]] ?? []
|
|
207
|
+
if !queue.isEmpty {
|
|
208
|
+
defaults.removeObject(forKey: self.pendingInterceptsKey)
|
|
209
|
+
defaults.synchronize()
|
|
210
|
+
}
|
|
211
|
+
return queue
|
|
212
|
+
}
|
|
213
|
+
|
|
187
214
|
Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
|
|
188
215
|
self.ensureLoadedPersistedConfig()
|
|
189
216
|
guard let config = self.currentBlockConfig else {
|
|
@@ -206,6 +233,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
206
233
|
|
|
207
234
|
let budgetSeconds = sanitizedDurationMinutes * 60
|
|
208
235
|
self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
|
|
236
|
+
self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
|
|
209
237
|
|
|
210
238
|
DispatchQueue.main.async {
|
|
211
239
|
self.store.shield.applications = nil
|
|
@@ -216,7 +244,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
216
244
|
// Re-block once cumulative *usage* of the blocked apps hits the budget.
|
|
217
245
|
// iOS only counts foreground time, so the budget pauses/resumes for free.
|
|
218
246
|
do {
|
|
219
|
-
try self.startUsageBasedRelock(
|
|
247
|
+
try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
|
|
220
248
|
} catch {
|
|
221
249
|
// Monitoring failed to start (e.g. threshold below Apple's granularity).
|
|
222
250
|
// The unlock still happens; the shield falls back to re-applying on the
|
|
@@ -237,15 +265,16 @@ public class ExpoAppBlockerModule: Module {
|
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
Function("isTemporarilyUnlocked") { () -> Bool in
|
|
240
|
-
return self.
|
|
268
|
+
return self.remainingUnlockSeconds() > 0
|
|
241
269
|
}
|
|
242
270
|
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
271
|
+
// Remaining = granted budget minus the consumed minutes the monitor extension
|
|
272
|
+
// has written to the App Group as usage accrued. Steps down by ~1 minute per
|
|
273
|
+
// minute of actual blocked-app usage and freezes while the apps aren't used.
|
|
274
|
+
// It's minute-granular (not smooth seconds) and may lag real usage slightly —
|
|
275
|
+
// Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
|
|
247
276
|
Function("getRemainingUnlockTime") { () -> Int in
|
|
248
|
-
return self.
|
|
277
|
+
return self.remainingUnlockSeconds()
|
|
249
278
|
}
|
|
250
279
|
|
|
251
280
|
AsyncFunction("relockApps") { (promise: Promise) in
|
|
@@ -341,7 +370,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
341
370
|
|
|
342
371
|
ensureLoadedPersistedConfig()
|
|
343
372
|
|
|
344
|
-
if
|
|
373
|
+
if remainingUnlockSeconds() > 0 {
|
|
345
374
|
// Unlock still active — keep the shield off. The usage-threshold monitor
|
|
346
375
|
// started in `temporaryUnlock` persists across app launches and will
|
|
347
376
|
// re-apply the shield once the usage budget is spent.
|
|
@@ -465,7 +494,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
465
494
|
}
|
|
466
495
|
|
|
467
496
|
private func relockApps() {
|
|
468
|
-
|
|
497
|
+
clearUnlockState()
|
|
469
498
|
cancelRelockActivity()
|
|
470
499
|
ensureLoadedPersistedConfig()
|
|
471
500
|
|
|
@@ -482,19 +511,26 @@ public class ExpoAppBlockerModule: Module {
|
|
|
482
511
|
// MARK: - Activity Scheduling
|
|
483
512
|
|
|
484
513
|
/// 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.
|
|
514
|
+
/// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
|
|
515
|
+
/// so the budget naturally pauses when the apps aren't in use and resumes on return.
|
|
516
|
+
///
|
|
517
|
+
/// We register a series of threshold events stepping by `usageStepSeconds` (auto-
|
|
518
|
+
/// coarsened so the count stays under `maxUsageSteps`). The event name carries its
|
|
519
|
+
/// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
|
|
520
|
+
/// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
|
|
521
|
+
/// consumed-second count to the App Group — giving the host app a sub-minute,
|
|
522
|
+
/// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
|
|
523
|
+
/// (== budget) is where the monitor re-applies the shield.
|
|
487
524
|
///
|
|
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).
|
|
525
|
+
/// Uses an all-day repeating schedule purely as a container for the events.
|
|
526
|
+
/// `repeats: true` keeps the monitor alive across days so apps are never left
|
|
527
|
+
/// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
|
|
528
|
+
/// unspent budget at the day boundary (earned time does not carry across midnight).
|
|
494
529
|
///
|
|
495
|
-
/// Note: Apple's usage thresholds are unreliable below a
|
|
496
|
-
///
|
|
497
|
-
|
|
530
|
+
/// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
|
|
531
|
+
/// finest steps may fire late or be skipped — later steps and the final threshold
|
|
532
|
+
/// still re-block, bounding overshoot to roughly one step.
|
|
533
|
+
private func startUsageBasedRelock(budgetSeconds: Int) throws {
|
|
498
534
|
scheduleLock.lock()
|
|
499
535
|
defer { scheduleLock.unlock() }
|
|
500
536
|
|
|
@@ -513,23 +549,46 @@ public class ExpoAppBlockerModule: Module {
|
|
|
513
549
|
return
|
|
514
550
|
}
|
|
515
551
|
|
|
516
|
-
let
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
552
|
+
let budget = max(1, budgetSeconds)
|
|
553
|
+
// Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
|
|
554
|
+
let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
|
|
555
|
+
var thresholds: [Int] = []
|
|
556
|
+
var t = step
|
|
557
|
+
while t < budget {
|
|
558
|
+
thresholds.append(t)
|
|
559
|
+
t += step
|
|
560
|
+
}
|
|
561
|
+
thresholds.append(budget) // always include the exact budget as the final re-block
|
|
562
|
+
|
|
563
|
+
var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
|
|
564
|
+
for seconds in thresholds {
|
|
565
|
+
events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
|
|
566
|
+
applications: appTokens,
|
|
567
|
+
categories: categoryTokens,
|
|
568
|
+
webDomains: webDomainTokens,
|
|
569
|
+
threshold: dateComponents(fromSeconds: seconds)
|
|
570
|
+
)
|
|
571
|
+
}
|
|
522
572
|
|
|
573
|
+
// CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
|
|
574
|
+
// thresholds measure usage accumulated *within the interval, from its start*. An
|
|
575
|
+
// all-day [00:00, 23:59] interval would count usage since midnight — so any prior
|
|
576
|
+
// blocked-app use today would have already crossed the thresholds before monitoring
|
|
577
|
+
// began, and the system never fires a (new) crossing → the shield never re-applies.
|
|
578
|
+
// Starting the interval at the current time makes thresholds count from the unlock
|
|
579
|
+
// moment. repeats:false because we re-register on every unlock.
|
|
580
|
+
let now = Date()
|
|
581
|
+
let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
|
|
523
582
|
let schedule = DeviceActivitySchedule(
|
|
524
|
-
intervalStart:
|
|
583
|
+
intervalStart: startComps,
|
|
525
584
|
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
526
|
-
repeats:
|
|
585
|
+
repeats: false
|
|
527
586
|
)
|
|
528
587
|
|
|
529
588
|
try activityCenter.startMonitoring(
|
|
530
589
|
DeviceActivityName(unlockActivityName),
|
|
531
590
|
during: schedule,
|
|
532
|
-
events:
|
|
591
|
+
events: events
|
|
533
592
|
)
|
|
534
593
|
}
|
|
535
594
|
|
|
@@ -545,12 +604,33 @@ public class ExpoAppBlockerModule: Module {
|
|
|
545
604
|
}
|
|
546
605
|
|
|
547
606
|
private func isTemporarilyUnlockedInternal() -> Bool {
|
|
548
|
-
return
|
|
607
|
+
return remainingUnlockSeconds() > 0
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/// Clear all persisted unlock state (budget + consumed counter).
|
|
611
|
+
private func clearUnlockState() {
|
|
612
|
+
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
613
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Seconds of earned time still available: the granted budget minus the seconds
|
|
617
|
+
/// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
|
|
618
|
+
/// or the budget is fully consumed. Clamped at 0.
|
|
619
|
+
private func remainingUnlockSeconds() -> Int {
|
|
620
|
+
let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
|
|
621
|
+
if budgetSeconds <= 0 { return 0 }
|
|
622
|
+
let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
|
|
623
|
+
return max(0, budgetSeconds - consumedSeconds)
|
|
549
624
|
}
|
|
550
625
|
|
|
551
|
-
///
|
|
552
|
-
|
|
553
|
-
|
|
626
|
+
/// Build a DateComponents threshold from a total number of seconds (normalized
|
|
627
|
+
/// into hour/minute/second so the system reads it cleanly).
|
|
628
|
+
private func dateComponents(fromSeconds total: Int) -> DateComponents {
|
|
629
|
+
return DateComponents(
|
|
630
|
+
hour: total / 3600,
|
|
631
|
+
minute: (total % 3600) / 60,
|
|
632
|
+
second: total % 60
|
|
633
|
+
)
|
|
554
634
|
}
|
|
555
635
|
|
|
556
636
|
// 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.66",
|
|
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",
|
package/src/index.ts
CHANGED
|
@@ -222,6 +222,28 @@ export function checkAndClearPendingUnlock(): boolean {
|
|
|
222
222
|
return NativeModule.checkAndClearPendingUnlock();
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* One OS-level block event: the blocker intercepted a blocked app (iOS
|
|
227
|
+
* shield render / Android foreground block). `interceptedAt` is epoch
|
|
228
|
+
* milliseconds; `appName` is the localized app name when the platform
|
|
229
|
+
* can resolve it (null otherwise).
|
|
230
|
+
*/
|
|
231
|
+
export interface PendingIntercept {
|
|
232
|
+
appName: string | null;
|
|
233
|
+
interceptedAt: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Drain and clear the queue of block events recorded natively since the
|
|
238
|
+
* last call. Implemented on both platforms (iOS App Group queue / Android
|
|
239
|
+
* SharedPreferences queue). The app calls this on foreground and persists
|
|
240
|
+
* the results to power the "blocks" counter.
|
|
241
|
+
*/
|
|
242
|
+
export function drainPendingIntercepts(): PendingIntercept[] {
|
|
243
|
+
if (Platform.OS !== "ios" && Platform.OS !== "android") return [];
|
|
244
|
+
return NativeModule.drainPendingIntercepts() ?? [];
|
|
245
|
+
}
|
|
246
|
+
|
|
225
247
|
export function addPendingUnlockListener(
|
|
226
248
|
handler: () => void
|
|
227
249
|
): { remove: () => void } | null {
|
|
@@ -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() {
|
|
@@ -43,6 +43,30 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
|
|
43
43
|
return Date() < expiration
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Block-event queue, drained by the app into `blocker_intercepts` to power
|
|
47
|
+
// the "blocks" counter. The system re-renders the shield often (app
|
|
48
|
+
// switcher previews, re-foreground), so a short global debounce collapses
|
|
49
|
+
// those bursts into one logical block event.
|
|
50
|
+
private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
|
|
51
|
+
private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
|
|
52
|
+
private let interceptDebounceMs: Double = 2_000
|
|
53
|
+
private let maxPendingIntercepts = 200
|
|
54
|
+
|
|
55
|
+
private func recordIntercept(appName: String) {
|
|
56
|
+
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
|
|
57
|
+
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
58
|
+
let lastMs = defaults.double(forKey: lastInterceptTsKey)
|
|
59
|
+
if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
|
|
60
|
+
|
|
61
|
+
var queue = defaults.array(forKey: pendingInterceptsKey) as? [[String: Any]] ?? []
|
|
62
|
+
queue.append(["appName": appName, "interceptedAt": nowMs])
|
|
63
|
+
if queue.count > maxPendingIntercepts {
|
|
64
|
+
queue = Array(queue.suffix(maxPendingIntercepts))
|
|
65
|
+
}
|
|
66
|
+
defaults.set(queue, forKey: pendingInterceptsKey)
|
|
67
|
+
defaults.set(nowMs, forKey: lastInterceptTsKey)
|
|
68
|
+
}
|
|
69
|
+
|
|
46
70
|
private func makeConfig(appName: String) -> ShieldConfiguration {
|
|
47
71
|
if isTemporarilyUnlocked() {
|
|
48
72
|
return ShieldConfiguration(
|
|
@@ -57,6 +81,10 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
|
|
57
81
|
)
|
|
58
82
|
}
|
|
59
83
|
|
|
84
|
+
// A blocked app is being shielded — this is a block event. Record it
|
|
85
|
+
// (debounced) for the app to drain.
|
|
86
|
+
recordIntercept(appName: appName)
|
|
87
|
+
|
|
60
88
|
let count = getBlockedAppCount()
|
|
61
89
|
// The plugin replaces this placeholder with a Swift string literal
|
|
62
90
|
// containing `\(count)` interpolation, or `""` when the user opted out.
|