expo-app-blocker 0.1.67 → 0.1.69

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.
@@ -39,6 +39,7 @@ public class ExpoAppBlockerModule: Module {
39
39
  private let maxUsageSteps = 60
40
40
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
41
41
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
42
+ private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
42
43
  private let minimumTemporaryUnlockMinutes = 1
43
44
  private var didLoadPersistedConfig = false
44
45
 
@@ -206,15 +207,40 @@ public class ExpoAppBlockerModule: Module {
206
207
  // Refresh the cached suite so writes made by the (separate) shield
207
208
  // extension process are visible to this long-lived app process.
208
209
  defaults.synchronize()
209
- let queue = defaults.array(forKey: self.pendingInterceptsKey) as? [[String: Any]] ?? []
210
- if !queue.isEmpty {
210
+ var queue: [[String: Any]] = []
211
+ if let json = defaults.string(forKey: self.pendingInterceptsKey),
212
+ let data = json.data(using: .utf8),
213
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
214
+ queue = parsed
215
+ }
216
+ if defaults.string(forKey: self.pendingInterceptsKey) != nil {
211
217
  defaults.removeObject(forKey: self.pendingInterceptsKey)
212
218
  defaults.synchronize()
213
219
  }
214
- NSLog("[appblocker] drainPendingIntercepts: returning \(queue.count)")
220
+ let invokes = defaults.integer(forKey: self.shieldInvokeCountKey)
221
+ NSLog("[appblocker] drainPendingIntercepts: returning \(queue.count) shieldInvokes=\(invokes)")
215
222
  return queue
216
223
  }
217
224
 
225
+ // TEMP diagnostic: surfaces whether the shield extension is being
226
+ // invoked at all (shieldInvokeCount) and the current queue depth,
227
+ // without clearing anything.
228
+ Function("debugInterceptState") { () -> [String: Any] in
229
+ guard let defaults = self.sharedDefaults else { return ["available": false] }
230
+ defaults.synchronize()
231
+ var depth = 0
232
+ if let json = defaults.string(forKey: self.pendingInterceptsKey),
233
+ let data = json.data(using: .utf8),
234
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
235
+ depth = parsed.count
236
+ }
237
+ return [
238
+ "available": true,
239
+ "shieldInvokeCount": defaults.integer(forKey: self.shieldInvokeCountKey),
240
+ "queueDepth": depth,
241
+ ]
242
+ }
243
+
218
244
  Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
219
245
  self.ensureLoadedPersistedConfig()
220
246
  guard let config = self.currentBlockConfig else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
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
@@ -244,6 +244,16 @@ export function drainPendingIntercepts(): PendingIntercept[] {
244
244
  return NativeModule.drainPendingIntercepts() ?? [];
245
245
  }
246
246
 
247
+ /**
248
+ * TEMP diagnostic: returns `{ shieldInvokeCount, queueDepth }` (iOS) without
249
+ * clearing the queue. `shieldInvokeCount` proves whether the shield
250
+ * extension is being invoked by the OS at all.
251
+ */
252
+ export function debugInterceptState(): Record<string, unknown> {
253
+ if (Platform.OS !== "ios" && Platform.OS !== "android") return {};
254
+ return (NativeModule.debugInterceptState?.() as Record<string, unknown>) ?? {};
255
+ }
256
+
247
257
  export function addPendingUnlockListener(
248
258
  handler: () => void
249
259
  ): { remove: () => void } | null {
@@ -6,6 +6,11 @@ import UserNotifications
6
6
  class ShieldActionExtension: ShieldActionDelegate {
7
7
  private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
8
8
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
9
+ private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
10
+ private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
11
+ private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
12
+ private let interceptDebounceMs: Double = 2_000
13
+ private let maxPendingIntercepts = 200
9
14
  private let pendingUnlockNotificationIdentifier = "expo.appblocker.pendingUnlock.local"
10
15
  // Notification copy + behavior — configurable via plugin options so apps
11
16
  // can localize without forking. Defaults preserve the original English
@@ -27,6 +32,11 @@ class ShieldActionExtension: ShieldActionDelegate {
27
32
  }
28
33
 
29
34
  private func handleAction(_ action: ShieldAction, completionHandler: @escaping (ShieldActionResponse) -> Void) {
35
+ // Any interaction with the shield is a confirmed block event. The
36
+ // ShieldConfiguration data source is cached by the system and not
37
+ // re-invoked per open, so this — the action handler, which fires every
38
+ // time — is the reliable place to record the block.
39
+ recordIntercept()
30
40
  switch action {
31
41
  case .primaryButtonPressed:
32
42
  setPendingUnlockFlag()
@@ -54,6 +64,41 @@ class ShieldActionExtension: ShieldActionDelegate {
54
64
  }
55
65
  }
56
66
 
67
+ /// Queue a block event (JSON-string queue in the App Group), debounced,
68
+ /// for the app to drain into `blocker_intercepts`.
69
+ private func recordIntercept() {
70
+ guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
71
+ defaults.synchronize()
72
+
73
+ let invokes = defaults.integer(forKey: shieldInvokeCountKey) + 1
74
+ defaults.set(invokes, forKey: shieldInvokeCountKey)
75
+
76
+ let nowMs = Date().timeIntervalSince1970 * 1000.0
77
+ let lastMs = defaults.double(forKey: lastInterceptTsKey)
78
+ if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs {
79
+ defaults.synchronize()
80
+ return
81
+ }
82
+
83
+ var queue: [[String: Any]] = []
84
+ if let json = defaults.string(forKey: pendingInterceptsKey),
85
+ let data = json.data(using: .utf8),
86
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
87
+ queue = parsed
88
+ }
89
+ queue.append(["appName": NSNull(), "interceptedAt": nowMs])
90
+ if queue.count > maxPendingIntercepts {
91
+ queue = Array(queue.suffix(maxPendingIntercepts))
92
+ }
93
+ if let data = try? JSONSerialization.data(withJSONObject: queue),
94
+ let json = String(data: data, encoding: .utf8) {
95
+ defaults.set(json, forKey: pendingInterceptsKey)
96
+ }
97
+ defaults.set(nowMs, forKey: lastInterceptTsKey)
98
+ defaults.synchronize()
99
+ NSLog("[appblocker] ShieldAction recordIntercept queued depth=\(queue.count) invokes=\(invokes)")
100
+ }
101
+
57
102
  private func setPendingUnlockFlag() {
58
103
  guard let sharedDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
59
104
  sharedDefaults.set(true, forKey: pendingUnlockKey)
@@ -49,6 +49,7 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
49
49
  // those bursts into one logical block event.
50
50
  private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
51
51
  private let lastInterceptTsKey = "appBlocker.lastInterceptTs.v1"
52
+ private let shieldInvokeCountKey = "appBlocker.shieldInvokeCount.v1"
52
53
  private let interceptDebounceMs: Double = 2_000
53
54
  private let maxPendingIntercepts = 200
54
55
 
@@ -60,24 +61,42 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
60
61
  // The extension is a short-lived process; pull a fresh view before
61
62
  // read-modify-write so we don't clobber the app's drain.
62
63
  defaults.synchronize()
64
+
65
+ // Diagnostic: count every shield render, before any debounce/branch,
66
+ // so the app can confirm the extension is actually being invoked.
67
+ let invokes = defaults.integer(forKey: shieldInvokeCountKey) + 1
68
+ defaults.set(invokes, forKey: shieldInvokeCountKey)
69
+
63
70
  let nowMs = Date().timeIntervalSince1970 * 1000.0
64
71
  let lastMs = defaults.double(forKey: lastInterceptTsKey)
65
72
  if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs {
66
- NSLog("[appblocker] recordIntercept: debounced")
73
+ defaults.synchronize()
74
+ NSLog("[appblocker] recordIntercept: debounced (invokes=\(invokes))")
67
75
  return
68
76
  }
69
77
 
70
- var queue = defaults.array(forKey: pendingInterceptsKey) as? [[String: Any]] ?? []
78
+ // Persist as a JSON string the most robust representation to carry a
79
+ // nested array across the extension→app process boundary via the App
80
+ // Group (a raw [[String: Any]] does not reliably survive).
81
+ var queue: [[String: Any]] = []
82
+ if let json = defaults.string(forKey: pendingInterceptsKey),
83
+ let data = json.data(using: .utf8),
84
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
85
+ queue = parsed
86
+ }
71
87
  queue.append(["appName": appName, "interceptedAt": nowMs])
72
88
  if queue.count > maxPendingIntercepts {
73
89
  queue = Array(queue.suffix(maxPendingIntercepts))
74
90
  }
75
- defaults.set(queue, forKey: pendingInterceptsKey)
91
+ if let data = try? JSONSerialization.data(withJSONObject: queue),
92
+ let json = String(data: data, encoding: .utf8) {
93
+ defaults.set(json, forKey: pendingInterceptsKey)
94
+ }
76
95
  defaults.set(nowMs, forKey: lastInterceptTsKey)
77
96
  // Force a flush to cfprefsd — the OS may suspend/kill the extension
78
97
  // immediately after rendering, before an unflushed write persists.
79
98
  defaults.synchronize()
80
- NSLog("[appblocker] recordIntercept: queued \(appName), depth=\(queue.count)")
99
+ NSLog("[appblocker] recordIntercept: queued \(appName), depth=\(queue.count) invokes=\(invokes)")
81
100
  }
82
101
 
83
102
  private func makeConfig(appName: String) -> ShieldConfiguration {