expo-app-blocker 0.1.68 → 0.1.70
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,7 +39,6 @@ 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"
|
|
43
42
|
private let minimumTemporaryUnlockMinutes = 1
|
|
44
43
|
private var didLoadPersistedConfig = false
|
|
45
44
|
|
|
@@ -217,30 +216,9 @@ public class ExpoAppBlockerModule: Module {
|
|
|
217
216
|
defaults.removeObject(forKey: self.pendingInterceptsKey)
|
|
218
217
|
defaults.synchronize()
|
|
219
218
|
}
|
|
220
|
-
let invokes = defaults.integer(forKey: self.shieldInvokeCountKey)
|
|
221
|
-
NSLog("[appblocker] drainPendingIntercepts: returning \(queue.count) shieldInvokes=\(invokes)")
|
|
222
219
|
return queue
|
|
223
220
|
}
|
|
224
221
|
|
|
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
|
-
|
|
244
222
|
Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
|
|
245
223
|
self.ensureLoadedPersistedConfig()
|
|
246
224
|
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.
|
|
3
|
+
"version": "0.1.70",
|
|
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,16 +244,6 @@ 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
|
-
|
|
257
247
|
export function addPendingUnlockListener(
|
|
258
248
|
handler: () => void
|
|
259
249
|
): { remove: () => void } | null {
|
|
@@ -6,6 +6,10 @@ 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 interceptDebounceMs: Double = 2_000
|
|
12
|
+
private let maxPendingIntercepts = 200
|
|
9
13
|
private let pendingUnlockNotificationIdentifier = "expo.appblocker.pendingUnlock.local"
|
|
10
14
|
// Notification copy + behavior — configurable via plugin options so apps
|
|
11
15
|
// can localize without forking. Defaults preserve the original English
|
|
@@ -27,6 +31,11 @@ class ShieldActionExtension: ShieldActionDelegate {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
private func handleAction(_ action: ShieldAction, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
34
|
+
// Any interaction with the shield is a confirmed block event. The
|
|
35
|
+
// ShieldConfiguration data source is cached by the system and not
|
|
36
|
+
// re-invoked per open, so this — the action handler, which fires every
|
|
37
|
+
// time — is the reliable place to record the block.
|
|
38
|
+
recordIntercept()
|
|
30
39
|
switch action {
|
|
31
40
|
case .primaryButtonPressed:
|
|
32
41
|
setPendingUnlockFlag()
|
|
@@ -54,6 +63,34 @@ class ShieldActionExtension: ShieldActionDelegate {
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
66
|
+
/// Queue a block event (JSON-string queue in the App Group), debounced,
|
|
67
|
+
/// for the app to drain into `blocker_intercepts`.
|
|
68
|
+
private func recordIntercept() {
|
|
69
|
+
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
|
|
70
|
+
defaults.synchronize()
|
|
71
|
+
|
|
72
|
+
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
73
|
+
let lastMs = defaults.double(forKey: lastInterceptTsKey)
|
|
74
|
+
if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
|
|
75
|
+
|
|
76
|
+
var queue: [[String: Any]] = []
|
|
77
|
+
if let json = defaults.string(forKey: pendingInterceptsKey),
|
|
78
|
+
let data = json.data(using: .utf8),
|
|
79
|
+
let parsed = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
|
|
80
|
+
queue = parsed
|
|
81
|
+
}
|
|
82
|
+
queue.append(["appName": NSNull(), "interceptedAt": nowMs])
|
|
83
|
+
if queue.count > maxPendingIntercepts {
|
|
84
|
+
queue = Array(queue.suffix(maxPendingIntercepts))
|
|
85
|
+
}
|
|
86
|
+
if let data = try? JSONSerialization.data(withJSONObject: queue),
|
|
87
|
+
let json = String(data: data, encoding: .utf8) {
|
|
88
|
+
defaults.set(json, forKey: pendingInterceptsKey)
|
|
89
|
+
}
|
|
90
|
+
defaults.set(nowMs, forKey: lastInterceptTsKey)
|
|
91
|
+
defaults.synchronize()
|
|
92
|
+
}
|
|
93
|
+
|
|
57
94
|
private func setPendingUnlockFlag() {
|
|
58
95
|
guard let sharedDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
|
|
59
96
|
sharedDefaults.set(true, forKey: pendingUnlockKey)
|
|
@@ -49,35 +49,22 @@ 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"
|
|
53
52
|
private let interceptDebounceMs: Double = 2_000
|
|
54
53
|
private let maxPendingIntercepts = 200
|
|
55
54
|
|
|
55
|
+
// Best-effort block recording. iOS caches the shield configuration and
|
|
56
|
+
// does NOT reliably re-invoke this data source per open, so the action
|
|
57
|
+
// handler (ShieldAction) is the primary recorder; this is a bonus path
|
|
58
|
+
// for the cases the system does re-invoke. Writes share the same App
|
|
59
|
+
// Group JSON queue + debounce as ShieldAction.
|
|
56
60
|
private func recordIntercept(appName: String) {
|
|
57
|
-
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
|
58
|
-
NSLog("[appblocker] recordIntercept: no app-group defaults")
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
// The extension is a short-lived process; pull a fresh view before
|
|
62
|
-
// read-modify-write so we don't clobber the app's drain.
|
|
61
|
+
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
|
|
63
62
|
defaults.synchronize()
|
|
64
63
|
|
|
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
|
-
|
|
70
64
|
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
|
71
65
|
let lastMs = defaults.double(forKey: lastInterceptTsKey)
|
|
72
|
-
if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs {
|
|
73
|
-
defaults.synchronize()
|
|
74
|
-
NSLog("[appblocker] recordIntercept: debounced (invokes=\(invokes))")
|
|
75
|
-
return
|
|
76
|
-
}
|
|
66
|
+
if lastMs > 0, (nowMs - lastMs) < interceptDebounceMs { return }
|
|
77
67
|
|
|
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
68
|
var queue: [[String: Any]] = []
|
|
82
69
|
if let json = defaults.string(forKey: pendingInterceptsKey),
|
|
83
70
|
let data = json.data(using: .utf8),
|
|
@@ -93,10 +80,7 @@ class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
|
|
93
80
|
defaults.set(json, forKey: pendingInterceptsKey)
|
|
94
81
|
}
|
|
95
82
|
defaults.set(nowMs, forKey: lastInterceptTsKey)
|
|
96
|
-
// Force a flush to cfprefsd — the OS may suspend/kill the extension
|
|
97
|
-
// immediately after rendering, before an unflushed write persists.
|
|
98
83
|
defaults.synchronize()
|
|
99
|
-
NSLog("[appblocker] recordIntercept: queued \(appName), depth=\(queue.count) invokes=\(invokes)")
|
|
100
84
|
}
|
|
101
85
|
|
|
102
86
|
private func makeConfig(appName: String) -> ShieldConfiguration {
|