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.
- 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 +11 -0
- package/package.json +1 -1
- package/src/index.ts +22 -0
- package/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +28 -0
|
@@ -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.
|
|
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.
|