expo-app-blocker 0.1.63 → 0.1.65
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 +22 -35
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +66 -36
- package/android/src/main/java/expo/modules/appblocker/TemporaryUnlockController.kt +39 -40
- package/ios/ExpoAppBlockerModule.swift +149 -93
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +60 -3
package/README.md
CHANGED
|
@@ -486,7 +486,12 @@ const config = getBlockConfiguration();
|
|
|
486
486
|
clearAllBlocks();
|
|
487
487
|
```
|
|
488
488
|
|
|
489
|
-
###
|
|
489
|
+
### Temporary Unlock (usage-based)
|
|
490
|
+
|
|
491
|
+
`temporaryUnlock(minutes)` grants a budget of earned minutes that is consumed
|
|
492
|
+
**only while a blocked app is actually in the foreground** — leaving the blocked
|
|
493
|
+
app pauses the budget; returning resumes it. When the budget is spent, the apps
|
|
494
|
+
re-block.
|
|
490
495
|
|
|
491
496
|
```typescript
|
|
492
497
|
import {
|
|
@@ -496,45 +501,27 @@ import {
|
|
|
496
501
|
relockApps,
|
|
497
502
|
} from 'expo-app-blocker';
|
|
498
503
|
|
|
499
|
-
//
|
|
504
|
+
// Grant N minutes of usage budget for the blocked apps
|
|
500
505
|
const result = await temporaryUnlock(15);
|
|
501
|
-
// { unlocked:
|
|
506
|
+
// { unlocked: true, expiresAt: number } (expiresAt is a best-effort hint only)
|
|
502
507
|
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
await relockApps(); // re-lock immediately
|
|
508
|
+
const seconds = getRemainingUnlockTime(); // seconds of budget remaining, 0 if none
|
|
509
|
+
await relockApps(); // drop the budget now, re-block immediately
|
|
506
510
|
```
|
|
507
511
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
The same temporary-unlock API works on Android. The foreground service pauses
|
|
511
|
-
blocking for the requested duration and auto-resumes when it expires — the timer
|
|
512
|
-
lives in the service, so it survives your app being backgrounded.
|
|
512
|
+
**How each platform enforces it:**
|
|
513
513
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const seconds = getRemainingUnlockTime(); // seconds remaining, 0 if none
|
|
526
|
-
await relockApps(); // end the unlock now, re-block immediately
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
| Function | Android behavior |
|
|
530
|
-
|---|---|
|
|
531
|
-
| `temporaryUnlock(minutes)` | Suppresses blocking for `minutes` (min 1, rounded). Replaces any active unlock. |
|
|
532
|
-
| `getRemainingUnlockTime()` | Seconds left on the active unlock, or `0`. Backed by a persisted expiry, so it's accurate without the app holding service state. |
|
|
533
|
-
| `relockApps()` | Ends the unlock immediately and re-blocks the foreground app on the next poll. |
|
|
534
|
-
| `isTemporarilyUnlocked()` | iOS only — returns `false` on Android. Use `getRemainingUnlockTime() > 0` instead. |
|
|
535
|
-
|
|
536
|
-
> When an unlock expires while the user is still inside a blocked app, the
|
|
537
|
-
> deep link fires with `reason=expired` (see [Deep-link contract](#deep-link-contract-how-your-app-is-launched)).
|
|
514
|
+
| | Android | iOS |
|
|
515
|
+
|---|---|---|
|
|
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
|
+
| `isTemporarilyUnlocked()` | Returns `false` (use `getRemainingUnlockTime() > 0`) | `true` while budget remains |
|
|
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
|
+
|
|
521
|
+
> **Android only:** when the budget runs out while the user is still inside a
|
|
522
|
+
> blocked app, the deep link fires with `reason=expired` (see
|
|
523
|
+
> [Deep-link contract](#deep-link-contract-how-your-app-is-launched)) so you can
|
|
524
|
+
> show a "time's up" screen instead of a cold block.
|
|
538
525
|
|
|
539
526
|
### iOS: Shield Button Events
|
|
540
527
|
|
|
@@ -20,31 +20,63 @@ import androidx.core.app.NotificationCompat
|
|
|
20
20
|
class AppBlockerService : Service() {
|
|
21
21
|
private val handler = Handler(Looper.getMainLooper())
|
|
22
22
|
private var lastForegroundPackage: String? = null
|
|
23
|
+
// Last *known* foreground app. UsageStats only reports recent transitions, so a
|
|
24
|
+
// poll can momentarily read null while the user sits in one app — we retain the
|
|
25
|
+
// last non-null reading so earned-time consumption and re-blocking stay reliable.
|
|
26
|
+
private var currentForeground: String? = null
|
|
23
27
|
private lateinit var overlayManager: OverlayManager
|
|
24
|
-
private val unlockController by lazy { TemporaryUnlockController(this
|
|
25
|
-
|
|
28
|
+
private val unlockController by lazy { TemporaryUnlockController(this) }
|
|
29
|
+
// Timestamp of the last tick spent consuming earned time; 0 when not consuming.
|
|
30
|
+
private var consumingSinceMs = 0L
|
|
31
|
+
// Whether a block is currently being enforced (overlay shown / app redirected).
|
|
32
|
+
private var blocking = false
|
|
26
33
|
|
|
27
34
|
private val pollRunnable = object : Runnable {
|
|
28
35
|
override fun run() {
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
tick()
|
|
37
|
+
handler.postDelayed(this, POLL_INTERVAL_MS)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun tick() {
|
|
42
|
+
getCurrentForegroundPackage()?.let { currentForeground = it }
|
|
43
|
+
val foreground = currentForeground
|
|
44
|
+
|
|
45
|
+
if (foreground == null || !isBlocked(foreground)) {
|
|
46
|
+
// Outside any blocked app: pause consumption and drop any active block.
|
|
47
|
+
consumingSinceMs = 0L
|
|
48
|
+
clearBlock()
|
|
49
|
+
lastForegroundPackage = foreground
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (unlockController.hasTimeLeft) {
|
|
54
|
+
// Inside a blocked app with earned time — spend it and keep the app usable.
|
|
55
|
+
val now = System.currentTimeMillis()
|
|
56
|
+
if (consumingSinceMs > 0L) unlockController.consume(now - consumingSinceMs)
|
|
57
|
+
consumingSinceMs = now
|
|
58
|
+
if (unlockController.hasTimeLeft) {
|
|
59
|
+
clearBlock()
|
|
31
60
|
} else {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Log.d(TAG, "Foreground changed: $foregroundPackage")
|
|
43
|
-
lastForegroundPackage = foregroundPackage
|
|
44
|
-
handleForegroundChange(foregroundPackage)
|
|
45
|
-
}
|
|
61
|
+
// Earned time ran out while still inside the app.
|
|
62
|
+
Log.d(TAG, "Earned time exhausted in foreground app: $foreground")
|
|
63
|
+
enforceBlock(foreground, BlockReason.EXPIRED)
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
// Inside a blocked app with no earned time — block on entry.
|
|
67
|
+
consumingSinceMs = 0L
|
|
68
|
+
if (!blocking || foreground != lastForegroundPackage) {
|
|
69
|
+
Log.d(TAG, "Blocked app in foreground: $foreground")
|
|
70
|
+
enforceBlock(foreground, BlockReason.OPENED)
|
|
46
71
|
}
|
|
47
|
-
|
|
72
|
+
}
|
|
73
|
+
lastForegroundPackage = foreground
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun clearBlock() {
|
|
77
|
+
if (blocking) {
|
|
78
|
+
overlayManager.hide()
|
|
79
|
+
blocking = false
|
|
48
80
|
}
|
|
49
81
|
}
|
|
50
82
|
|
|
@@ -62,18 +94,11 @@ class AppBlockerService : Service() {
|
|
|
62
94
|
private fun isBlocked(packageName: String): Boolean =
|
|
63
95
|
packageName in AppBlockerPrefs.getBlockedPackages(this)
|
|
64
96
|
|
|
65
|
-
private fun
|
|
66
|
-
if (isBlocked(foregroundPackage)) {
|
|
67
|
-
Log.d(TAG, "Blocked app in foreground: $foregroundPackage")
|
|
68
|
-
block(foregroundPackage, BlockReason.OPENED)
|
|
69
|
-
} else {
|
|
70
|
-
overlayManager.hide()
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private fun block(packageName: String, reason: BlockReason) {
|
|
97
|
+
private fun enforceBlock(packageName: String, reason: BlockReason) {
|
|
75
98
|
overlayManager.show(packageName, reason)
|
|
76
99
|
showBlockedNotification(packageName, reason)
|
|
100
|
+
blocking = true
|
|
101
|
+
consumingSinceMs = 0L
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
private fun showBlockedNotification(packageName: String, reason: BlockReason) {
|
|
@@ -141,16 +166,21 @@ class AppBlockerService : Service() {
|
|
|
141
166
|
when (intent?.action) {
|
|
142
167
|
ACTION_TEMPORARY_UNLOCK -> {
|
|
143
168
|
val minutes = intent.getIntExtra(EXTRA_DURATION_MINUTES, 0)
|
|
144
|
-
Log.d(TAG, "
|
|
145
|
-
unlockController.
|
|
146
|
-
|
|
169
|
+
Log.d(TAG, "Granting $minutes minutes of earned time")
|
|
170
|
+
unlockController.grant(minutes)
|
|
171
|
+
consumingSinceMs = 0L
|
|
172
|
+
clearBlock()
|
|
147
173
|
}
|
|
148
174
|
ACTION_RELOCK -> {
|
|
149
|
-
Log.d(TAG, "Relock:
|
|
150
|
-
unlockController.
|
|
151
|
-
|
|
152
|
-
//
|
|
175
|
+
Log.d(TAG, "Relock: dropping earned time")
|
|
176
|
+
unlockController.clear()
|
|
177
|
+
consumingSinceMs = 0L
|
|
178
|
+
// Forget the last-seen app so a blocked app already in the foreground is
|
|
179
|
+
// re-blocked on the next poll. Clearing currentForeground too avoids a
|
|
180
|
+
// stale reading wrongly blocking a non-blocked app if the next poll reads null.
|
|
153
181
|
lastForegroundPackage = null
|
|
182
|
+
currentForeground = null
|
|
183
|
+
clearBlock()
|
|
154
184
|
}
|
|
155
185
|
}
|
|
156
186
|
return START_STICKY
|
|
@@ -1,64 +1,63 @@
|
|
|
1
1
|
package expo.modules.appblocker
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
-
import android.os.Handler
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
* Single source of truth for the Android "
|
|
6
|
+
* Single source of truth for the Android "earned time" budget.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Unlock time is a *budget of seconds* that is consumed only while a blocked app
|
|
9
|
+
* is actually in the foreground — see [AppBlockerService], which calls [consume]
|
|
10
|
+
* on each poll tick spent inside a blocked app and leaves the budget untouched
|
|
11
|
+
* otherwise. This gives pause/resume semantics: leaving the blocked app freezes
|
|
12
|
+
* the remaining time; returning resumes it.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
14
|
+
* The budget is persisted (see [Store]) so the JS module can read it without
|
|
15
|
+
* holding a reference to the running service, mirroring the iOS approach.
|
|
16
|
+
*
|
|
17
|
+
* [consume] and [grant] do a read-modify-write on the persisted budget and are
|
|
18
|
+
* NOT atomic; the service drives both from the main thread (the poll Handler and
|
|
19
|
+
* onStartCommand share that thread), which serializes them. Don't call these from
|
|
20
|
+
* another thread without adding synchronization.
|
|
16
21
|
*/
|
|
17
|
-
class TemporaryUnlockController(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
)
|
|
21
|
-
private val expireRunnable = Runnable { Store.clear(context) }
|
|
22
|
-
|
|
23
|
-
/** True while a temporary unlock is in effect (blocking should be suppressed). */
|
|
24
|
-
val isUnlocked: Boolean
|
|
25
|
-
get() = Store.remainingSeconds(context) > 0
|
|
22
|
+
class TemporaryUnlockController(private val context: Context) {
|
|
23
|
+
/** True while earned time remains (blocking should be suppressed inside blocked apps). */
|
|
24
|
+
val hasTimeLeft: Boolean
|
|
25
|
+
get() = Store.remainingMs(context) > 0
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
fun
|
|
27
|
+
/** Grant a fresh budget of [durationMinutes], replacing any existing balance. No-op if <= 0. */
|
|
28
|
+
fun grant(durationMinutes: Int) {
|
|
29
29
|
if (durationMinutes <= 0) return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
Store.setRemaining(context, durationMinutes * 60_000L)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Spend [elapsedMs] of the budget (clamped at 0). Called while inside a blocked app. */
|
|
34
|
+
fun consume(elapsedMs: Long) {
|
|
35
|
+
if (elapsedMs <= 0) return
|
|
36
|
+
val remaining = Store.remainingMs(context)
|
|
37
|
+
if (remaining <= 0) return
|
|
38
|
+
Store.setRemaining(context, (remaining - elapsedMs).coerceAtLeast(0))
|
|
34
39
|
}
|
|
35
40
|
|
|
36
|
-
/**
|
|
37
|
-
fun
|
|
38
|
-
|
|
39
|
-
Store.clear(context)
|
|
41
|
+
/** Drop the entire budget immediately, restoring blocking. */
|
|
42
|
+
fun clear() {
|
|
43
|
+
Store.setRemaining(context, 0)
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
|
-
* Stateless persistence for the
|
|
47
|
+
* Stateless persistence for the remaining budget. Readable from anywhere with a
|
|
44
48
|
* [Context] — the running service is not required.
|
|
45
49
|
*/
|
|
46
50
|
companion object Store {
|
|
47
|
-
private const val
|
|
51
|
+
private const val KEY_REMAINING_MS = "temporary_unlock_remaining_ms"
|
|
48
52
|
|
|
49
|
-
private fun
|
|
50
|
-
AppBlockerPrefs.get(context).edit().putLong(
|
|
53
|
+
private fun setRemaining(context: Context, remainingMs: Long) {
|
|
54
|
+
AppBlockerPrefs.get(context).edit().putLong(KEY_REMAINING_MS, remainingMs).apply()
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
private fun
|
|
54
|
-
AppBlockerPrefs.get(context).
|
|
55
|
-
}
|
|
57
|
+
private fun remainingMs(context: Context): Long =
|
|
58
|
+
AppBlockerPrefs.get(context).getLong(KEY_REMAINING_MS, 0L).coerceAtLeast(0L)
|
|
56
59
|
|
|
57
|
-
/** Seconds
|
|
58
|
-
fun remainingSeconds(context: Context): Int
|
|
59
|
-
val expiresAtMs = AppBlockerPrefs.get(context).getLong(KEY_EXPIRES_AT, 0L)
|
|
60
|
-
val remainingMs = expiresAtMs - System.currentTimeMillis()
|
|
61
|
-
return if (remainingMs > 0) (remainingMs / 1000).toInt() else 0
|
|
62
|
-
}
|
|
60
|
+
/** Seconds of earned time remaining, or 0 if none. */
|
|
61
|
+
fun remainingSeconds(context: Context): Int = (remainingMs(context) / 1000).toInt()
|
|
63
62
|
}
|
|
64
63
|
}
|
|
@@ -14,8 +14,29 @@ public class ExpoAppBlockerModule: Module {
|
|
|
14
14
|
private var sharedDefaults: UserDefaults?
|
|
15
15
|
private let userDefaults = UserDefaults.standard
|
|
16
16
|
private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
|
|
17
|
+
// Stores the granted earned-time budget in **seconds** (Int). Presence with a
|
|
18
|
+
// value > 0 means a temporary unlock is active. Enforcement is usage-based: the
|
|
19
|
+
// shield is re-applied by the DeviceActivityMonitor once cumulative foreground
|
|
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.
|
|
17
23
|
private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
|
|
18
24
|
private let unlockActivityName = "appBlocker.temporaryUnlock"
|
|
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
|
|
19
40
|
private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
|
|
20
41
|
private let minimumTemporaryUnlockMinutes = 1
|
|
21
42
|
private var didLoadPersistedConfig = false
|
|
@@ -165,7 +186,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
165
186
|
self.currentBlockConfig = nil
|
|
166
187
|
self.userDefaults.removeObject(forKey: self.blockConfigStorageKey)
|
|
167
188
|
self.sharedDefaults?.removeObject(forKey: self.blockConfigStorageKey)
|
|
168
|
-
self.
|
|
189
|
+
self.clearUnlockState()
|
|
169
190
|
}
|
|
170
191
|
}
|
|
171
192
|
|
|
@@ -199,8 +220,9 @@ public class ExpoAppBlockerModule: Module {
|
|
|
199
220
|
return
|
|
200
221
|
}
|
|
201
222
|
|
|
202
|
-
let
|
|
203
|
-
self.sharedDefaults?.set(
|
|
223
|
+
let budgetSeconds = sanitizedDurationMinutes * 60
|
|
224
|
+
self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
|
|
225
|
+
self.sharedDefaults?.set(0, forKey: self.usageConsumedKey)
|
|
204
226
|
|
|
205
227
|
DispatchQueue.main.async {
|
|
206
228
|
self.store.shield.applications = nil
|
|
@@ -208,51 +230,40 @@ public class ExpoAppBlockerModule: Module {
|
|
|
208
230
|
self.store.shield.webDomains = nil
|
|
209
231
|
}
|
|
210
232
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
233
|
+
// Re-block once cumulative *usage* of the blocked apps hits the budget.
|
|
234
|
+
// iOS only counts foreground time, so the budget pauses/resumes for free.
|
|
213
235
|
do {
|
|
214
|
-
try self.
|
|
236
|
+
try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
|
|
215
237
|
} catch {
|
|
216
|
-
//
|
|
217
|
-
// The
|
|
218
|
-
//
|
|
219
|
-
print("[AppBlocker]
|
|
238
|
+
// Monitoring failed to start (e.g. threshold below Apple's granularity).
|
|
239
|
+
// The unlock still happens; the shield falls back to re-applying on the
|
|
240
|
+
// next app launch via checkAndApplyUnlockState.
|
|
241
|
+
print("[AppBlocker] Usage-based relock failed to start: \(error.localizedDescription)")
|
|
220
242
|
}
|
|
221
243
|
|
|
244
|
+
// `expiresAt` is a best-effort wall-clock hint only — actual relock is
|
|
245
|
+
// usage-based, so the real expiry depends on how much the apps are used.
|
|
246
|
+
let approxExpiresAt = Date().addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
|
|
222
247
|
DispatchQueue.main.async {
|
|
223
248
|
promise.resolve([
|
|
224
249
|
"unlocked": true,
|
|
225
|
-
"expiresAt":
|
|
250
|
+
"expiresAt": approxExpiresAt
|
|
226
251
|
])
|
|
227
252
|
}
|
|
228
253
|
}
|
|
229
254
|
}
|
|
230
255
|
|
|
231
256
|
Function("isTemporarilyUnlocked") { () -> Bool in
|
|
232
|
-
|
|
233
|
-
return false
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if Date() < expirationDate {
|
|
237
|
-
return true
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
self.relockApps()
|
|
241
|
-
return false
|
|
257
|
+
return self.remainingUnlockSeconds() > 0
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
// Remaining = granted budget minus the consumed minutes the monitor extension
|
|
261
|
+
// has written to the App Group as usage accrued. Steps down by ~1 minute per
|
|
262
|
+
// minute of actual blocked-app usage and freezes while the apps aren't used.
|
|
263
|
+
// It's minute-granular (not smooth seconds) and may lag real usage slightly —
|
|
264
|
+
// Apple's thresholds are coarse. Drops to 0 once the budget is fully spent.
|
|
244
265
|
Function("getRemainingUnlockTime") { () -> Int in
|
|
245
|
-
|
|
246
|
-
return 0
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
let remaining = expirationDate.timeIntervalSince(Date())
|
|
250
|
-
if remaining > 0 {
|
|
251
|
-
return Int(remaining)
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
self.relockApps()
|
|
255
|
-
return 0
|
|
266
|
+
return self.remainingUnlockSeconds()
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
AsyncFunction("relockApps") { (promise: Promise) in
|
|
@@ -348,23 +359,14 @@ public class ExpoAppBlockerModule: Module {
|
|
|
348
359
|
|
|
349
360
|
ensureLoadedPersistedConfig()
|
|
350
361
|
|
|
351
|
-
if
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
do {
|
|
362
|
-
try scheduleRelockActivity(expirationDate: expirationDate)
|
|
363
|
-
} catch {
|
|
364
|
-
relockApps()
|
|
365
|
-
}
|
|
366
|
-
} else {
|
|
367
|
-
relockApps()
|
|
362
|
+
if remainingUnlockSeconds() > 0 {
|
|
363
|
+
// Unlock still active — keep the shield off. The usage-threshold monitor
|
|
364
|
+
// started in `temporaryUnlock` persists across app launches and will
|
|
365
|
+
// re-apply the shield once the usage budget is spent.
|
|
366
|
+
DispatchQueue.main.async {
|
|
367
|
+
self.store.shield.applications = nil
|
|
368
|
+
self.store.shield.applicationCategories = nil
|
|
369
|
+
self.store.shield.webDomains = nil
|
|
368
370
|
}
|
|
369
371
|
} else if let config = currentBlockConfig {
|
|
370
372
|
do {
|
|
@@ -481,7 +483,7 @@ public class ExpoAppBlockerModule: Module {
|
|
|
481
483
|
}
|
|
482
484
|
|
|
483
485
|
private func relockApps() {
|
|
484
|
-
|
|
486
|
+
clearUnlockState()
|
|
485
487
|
cancelRelockActivity()
|
|
486
488
|
ensureLoadedPersistedConfig()
|
|
487
489
|
|
|
@@ -497,49 +499,86 @@ public class ExpoAppBlockerModule: Module {
|
|
|
497
499
|
|
|
498
500
|
// MARK: - Activity Scheduling
|
|
499
501
|
|
|
500
|
-
|
|
502
|
+
/// Start usage-based monitoring: re-apply the shield once cumulative foreground
|
|
503
|
+
/// usage of the blocked apps reaches `budgetSeconds`. iOS counts only active usage,
|
|
504
|
+
/// so the budget naturally pauses when the apps aren't in use and resumes on return.
|
|
505
|
+
///
|
|
506
|
+
/// We register a series of threshold events stepping by `usageStepSeconds` (auto-
|
|
507
|
+
/// coarsened so the count stays under `maxUsageSteps`). The event name carries its
|
|
508
|
+
/// threshold in seconds (`usageStepEventPrefix + <seconds>`). Each step's
|
|
509
|
+
/// `eventDidReachThreshold` (in the DeviceActivityMonitor extension) writes the
|
|
510
|
+
/// consumed-second count to the App Group — giving the host app a sub-minute,
|
|
511
|
+
/// pause-when-away consumed counter (`getRemainingUnlockTime`). The final step
|
|
512
|
+
/// (== budget) is where the monitor re-applies the shield.
|
|
513
|
+
///
|
|
514
|
+
/// Uses an all-day repeating schedule purely as a container for the events.
|
|
515
|
+
/// `repeats: true` keeps the monitor alive across days so apps are never left
|
|
516
|
+
/// unlocked without enforcement; the tradeoff is that `intervalDidEnd` clears any
|
|
517
|
+
/// unspent budget at the day boundary (earned time does not carry across midnight).
|
|
518
|
+
///
|
|
519
|
+
/// Note: Apple's usage thresholds are coarse/unreliable below ~a minute, so the
|
|
520
|
+
/// finest steps may fire late or be skipped — later steps and the final threshold
|
|
521
|
+
/// still re-block, bounding overshoot to roughly one step.
|
|
522
|
+
private func startUsageBasedRelock(budgetSeconds: Int) throws {
|
|
501
523
|
scheduleLock.lock()
|
|
502
524
|
defer { scheduleLock.unlock() }
|
|
503
525
|
|
|
504
526
|
cancelRelockActivityLocked()
|
|
505
527
|
|
|
506
|
-
let
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
let
|
|
511
|
-
let
|
|
512
|
-
let
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
)
|
|
537
|
-
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
538
|
-
repeats: false
|
|
528
|
+
guard let config = currentBlockConfig else {
|
|
529
|
+
print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
let appTokens = Set(config.items.compactMap { $0.appToken })
|
|
533
|
+
let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
|
|
534
|
+
let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
|
|
535
|
+
|
|
536
|
+
guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
|
|
537
|
+
print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let budget = max(1, budgetSeconds)
|
|
542
|
+
// Step by usageStepSeconds, but coarsen so we never exceed maxUsageSteps events.
|
|
543
|
+
let step = max(usageStepSeconds, Int(ceil(Double(budget) / Double(maxUsageSteps))))
|
|
544
|
+
var thresholds: [Int] = []
|
|
545
|
+
var t = step
|
|
546
|
+
while t < budget {
|
|
547
|
+
thresholds.append(t)
|
|
548
|
+
t += step
|
|
549
|
+
}
|
|
550
|
+
thresholds.append(budget) // always include the exact budget as the final re-block
|
|
551
|
+
|
|
552
|
+
var events: [DeviceActivityEvent.Name: DeviceActivityEvent] = [:]
|
|
553
|
+
for seconds in thresholds {
|
|
554
|
+
events[DeviceActivityEvent.Name("\(usageStepEventPrefix)\(seconds)")] = DeviceActivityEvent(
|
|
555
|
+
applications: appTokens,
|
|
556
|
+
categories: categoryTokens,
|
|
557
|
+
webDomains: webDomainTokens,
|
|
558
|
+
threshold: dateComponents(fromSeconds: seconds)
|
|
539
559
|
)
|
|
540
560
|
}
|
|
541
561
|
|
|
542
|
-
|
|
562
|
+
// CRITICAL: the interval must start ~now, not at midnight. DeviceActivityEvent
|
|
563
|
+
// thresholds measure usage accumulated *within the interval, from its start*. An
|
|
564
|
+
// all-day [00:00, 23:59] interval would count usage since midnight — so any prior
|
|
565
|
+
// blocked-app use today would have already crossed the thresholds before monitoring
|
|
566
|
+
// began, and the system never fires a (new) crossing → the shield never re-applies.
|
|
567
|
+
// Starting the interval at the current time makes thresholds count from the unlock
|
|
568
|
+
// moment. repeats:false because we re-register on every unlock.
|
|
569
|
+
let now = Date()
|
|
570
|
+
let startComps = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
|
|
571
|
+
let schedule = DeviceActivitySchedule(
|
|
572
|
+
intervalStart: startComps,
|
|
573
|
+
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
574
|
+
repeats: false
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
try activityCenter.startMonitoring(
|
|
578
|
+
DeviceActivityName(unlockActivityName),
|
|
579
|
+
during: schedule,
|
|
580
|
+
events: events
|
|
581
|
+
)
|
|
543
582
|
}
|
|
544
583
|
|
|
545
584
|
private func cancelRelockActivity() {
|
|
@@ -554,16 +593,33 @@ public class ExpoAppBlockerModule: Module {
|
|
|
554
593
|
}
|
|
555
594
|
|
|
556
595
|
private func isTemporarilyUnlockedInternal() -> Bool {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if Date() < expirationDate {
|
|
562
|
-
return true
|
|
563
|
-
}
|
|
596
|
+
return remainingUnlockSeconds() > 0
|
|
597
|
+
}
|
|
564
598
|
|
|
599
|
+
/// Clear all persisted unlock state (budget + consumed counter).
|
|
600
|
+
private func clearUnlockState() {
|
|
565
601
|
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
566
|
-
|
|
602
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Seconds of earned time still available: the granted budget minus the seconds
|
|
606
|
+
/// of blocked-app usage the monitor extension has recorded. 0 if no active unlock
|
|
607
|
+
/// or the budget is fully consumed. Clamped at 0.
|
|
608
|
+
private func remainingUnlockSeconds() -> Int {
|
|
609
|
+
let budgetSeconds = (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
|
|
610
|
+
if budgetSeconds <= 0 { return 0 }
|
|
611
|
+
let consumedSeconds = (sharedDefaults?.object(forKey: usageConsumedKey) as? Int) ?? 0
|
|
612
|
+
return max(0, budgetSeconds - consumedSeconds)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Build a DateComponents threshold from a total number of seconds (normalized
|
|
616
|
+
/// into hour/minute/second so the system reads it cleanly).
|
|
617
|
+
private func dateComponents(fromSeconds total: Int) -> DateComponents {
|
|
618
|
+
return DateComponents(
|
|
619
|
+
hour: total / 3600,
|
|
620
|
+
minute: (total % 3600) / 60,
|
|
621
|
+
second: total % 60
|
|
622
|
+
)
|
|
567
623
|
}
|
|
568
624
|
|
|
569
625
|
// 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.65",
|
|
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
|
@@ -189,7 +189,15 @@ export function isTemporarilyUnlocked(): boolean {
|
|
|
189
189
|
return NativeModule.isTemporarilyUnlocked();
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Seconds remaining on the active temporary unlock, or 0 if none.
|
|
194
|
+
*
|
|
195
|
+
* Platform divergence: on **Android** this ticks down live as the budget is spent
|
|
196
|
+
* inside blocked apps (and freezes when you leave). On **iOS** Apple does not expose
|
|
197
|
+
* live cumulative usage, so this returns the *granted* budget and stays flat until
|
|
198
|
+
* the usage threshold re-applies the shield (then drops to 0). Don't rely on a
|
|
199
|
+
* smooth iOS countdown.
|
|
200
|
+
*/
|
|
193
201
|
export function getRemainingUnlockTime(): number {
|
|
194
202
|
if (Platform.OS === "android") return NativeModule.getRemainingUnlockTimeAndroid();
|
|
195
203
|
return NativeModule.getRemainingUnlockTime();
|
|
@@ -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,14 +28,63 @@ class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
|
|
|
20
28
|
|
|
21
29
|
override func intervalDidEnd(for activity: DeviceActivityName) {
|
|
22
30
|
super.intervalDidEnd(for: activity)
|
|
23
|
-
|
|
24
|
-
|
|
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.)
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
override func intervalDidStart(for activity: DeviceActivityName) {
|
|
28
40
|
super.intervalDidStart(for: activity)
|
|
29
41
|
}
|
|
30
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.
|
|
47
|
+
override func eventDidReachThreshold(
|
|
48
|
+
_ event: DeviceActivityEvent.Name,
|
|
49
|
+
activity: DeviceActivityName
|
|
50
|
+
) {
|
|
51
|
+
super.eventDidReachThreshold(event, activity: activity)
|
|
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() {
|
|
84
|
+
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
85
|
+
sharedDefaults?.removeObject(forKey: usageConsumedKey)
|
|
86
|
+
}
|
|
87
|
+
|
|
31
88
|
private func reapplyBlockConfiguration() {
|
|
32
89
|
let userDefaults = sharedDefaults ?? UserDefaults.standard
|
|
33
90
|
|