expo-app-blocker 0.1.65 → 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.
@@ -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")
@@ -38,6 +38,7 @@ public class ExpoAppBlockerModule: Module {
38
38
  // event count stays under this (Apple degrades with too many events).
39
39
  private let maxUsageSteps = 60
40
40
  private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
41
+ private let pendingInterceptsKey = "appBlocker.pendingIntercepts.v1"
41
42
  private let minimumTemporaryUnlockMinutes = 1
42
43
  private var didLoadPersistedConfig = false
43
44
 
@@ -200,6 +201,16 @@ public class ExpoAppBlockerModule: Module {
200
201
  return hasPending
201
202
  }
202
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
+
203
214
  Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
204
215
  self.ensureLoadedPersistedConfig()
205
216
  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.65",
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 {
@@ -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.