expo-app-blocker 0.1.63 → 0.1.64
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 +80 -93
- package/package.json +1 -1
- package/src/index.ts +9 -1
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +12 -0
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 event re-applies the Family Controls shield after N minutes of measured usage |
|
|
517
|
+
| `getRemainingUnlockTime()` | Live — ticks down while inside a blocked app, freezes otherwise | Returns the **granted** budget; iOS can't expose live usage, so it stays flat until the threshold fires (then `0`) |
|
|
518
|
+
| `isTemporarilyUnlocked()` | Returns `false` (use `getRemainingUnlockTime() > 0`) | `true` while budget remains |
|
|
519
|
+
| Caveats | — | Apple thresholds are unreliable below a few minutes; unspent budget is cleared at the daily schedule 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,13 @@ 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`).
|
|
17
21
|
private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
|
|
18
22
|
private let unlockActivityName = "appBlocker.temporaryUnlock"
|
|
23
|
+
private let usageEventName = "appBlocker.usageThreshold"
|
|
19
24
|
private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
|
|
20
25
|
private let minimumTemporaryUnlockMinutes = 1
|
|
21
26
|
private var didLoadPersistedConfig = false
|
|
@@ -199,8 +204,8 @@ public class ExpoAppBlockerModule: Module {
|
|
|
199
204
|
return
|
|
200
205
|
}
|
|
201
206
|
|
|
202
|
-
let
|
|
203
|
-
self.sharedDefaults?.set(
|
|
207
|
+
let budgetSeconds = sanitizedDurationMinutes * 60
|
|
208
|
+
self.sharedDefaults?.set(budgetSeconds, forKey: self.temporaryUnlockKey)
|
|
204
209
|
|
|
205
210
|
DispatchQueue.main.async {
|
|
206
211
|
self.store.shield.applications = nil
|
|
@@ -208,51 +213,39 @@ public class ExpoAppBlockerModule: Module {
|
|
|
208
213
|
self.store.shield.webDomains = nil
|
|
209
214
|
}
|
|
210
215
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
216
|
+
// Re-block once cumulative *usage* of the blocked apps hits the budget.
|
|
217
|
+
// iOS only counts foreground time, so the budget pauses/resumes for free.
|
|
213
218
|
do {
|
|
214
|
-
try self.
|
|
219
|
+
try self.startUsageBasedRelock(minutes: sanitizedDurationMinutes)
|
|
215
220
|
} catch {
|
|
216
|
-
//
|
|
217
|
-
// The
|
|
218
|
-
//
|
|
219
|
-
print("[AppBlocker]
|
|
221
|
+
// Monitoring failed to start (e.g. threshold below Apple's granularity).
|
|
222
|
+
// The unlock still happens; the shield falls back to re-applying on the
|
|
223
|
+
// next app launch via checkAndApplyUnlockState.
|
|
224
|
+
print("[AppBlocker] Usage-based relock failed to start: \(error.localizedDescription)")
|
|
220
225
|
}
|
|
221
226
|
|
|
227
|
+
// `expiresAt` is a best-effort wall-clock hint only — actual relock is
|
|
228
|
+
// usage-based, so the real expiry depends on how much the apps are used.
|
|
229
|
+
let approxExpiresAt = Date().addingTimeInterval(TimeInterval(budgetSeconds)).timeIntervalSince1970
|
|
222
230
|
DispatchQueue.main.async {
|
|
223
231
|
promise.resolve([
|
|
224
232
|
"unlocked": true,
|
|
225
|
-
"expiresAt":
|
|
233
|
+
"expiresAt": approxExpiresAt
|
|
226
234
|
])
|
|
227
235
|
}
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
|
|
231
239
|
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
|
|
240
|
+
return self.remainingUnlockBudgetSeconds() > 0
|
|
242
241
|
}
|
|
243
242
|
|
|
243
|
+
// NOTE: On iOS this returns the *granted* budget, not a live remaining count —
|
|
244
|
+
// Apple does not expose cumulative usage to the app, so the value does not tick
|
|
245
|
+
// down as the blocked apps are used. It drops to 0 once the usage threshold
|
|
246
|
+
// re-applies the shield (or on relock).
|
|
244
247
|
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
|
|
248
|
+
return self.remainingUnlockBudgetSeconds()
|
|
256
249
|
}
|
|
257
250
|
|
|
258
251
|
AsyncFunction("relockApps") { (promise: Promise) in
|
|
@@ -348,23 +341,14 @@ public class ExpoAppBlockerModule: Module {
|
|
|
348
341
|
|
|
349
342
|
ensureLoadedPersistedConfig()
|
|
350
343
|
|
|
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()
|
|
344
|
+
if remainingUnlockBudgetSeconds() > 0 {
|
|
345
|
+
// Unlock still active — keep the shield off. The usage-threshold monitor
|
|
346
|
+
// started in `temporaryUnlock` persists across app launches and will
|
|
347
|
+
// re-apply the shield once the usage budget is spent.
|
|
348
|
+
DispatchQueue.main.async {
|
|
349
|
+
self.store.shield.applications = nil
|
|
350
|
+
self.store.shield.applicationCategories = nil
|
|
351
|
+
self.store.shield.webDomains = nil
|
|
368
352
|
}
|
|
369
353
|
} else if let config = currentBlockConfig {
|
|
370
354
|
do {
|
|
@@ -497,49 +481,56 @@ public class ExpoAppBlockerModule: Module {
|
|
|
497
481
|
|
|
498
482
|
// MARK: - Activity Scheduling
|
|
499
483
|
|
|
500
|
-
|
|
484
|
+
/// Start usage-based monitoring: re-apply the shield once cumulative foreground
|
|
485
|
+
/// usage of the blocked apps reaches `minutes`. iOS counts only active usage, so
|
|
486
|
+
/// the budget naturally pauses when the apps aren't in use and resumes on return.
|
|
487
|
+
///
|
|
488
|
+
/// Uses an all-day repeating schedule purely as a container for the usage event;
|
|
489
|
+
/// the `eventDidReachThreshold` callback (in the DeviceActivityMonitor extension)
|
|
490
|
+
/// does the actual relock. `repeats: true` keeps the monitor alive across days so
|
|
491
|
+
/// apps are never left unlocked without enforcement; the tradeoff is that
|
|
492
|
+
/// `intervalDidEnd` clears any unspent budget at the day boundary (earned time
|
|
493
|
+
/// does not carry across midnight).
|
|
494
|
+
///
|
|
495
|
+
/// Note: Apple's usage thresholds are unreliable below a few minutes — very small
|
|
496
|
+
/// budgets may relock late or only at the daily interval boundary.
|
|
497
|
+
private func startUsageBasedRelock(minutes: Int) throws {
|
|
501
498
|
scheduleLock.lock()
|
|
502
499
|
defer { scheduleLock.unlock() }
|
|
503
500
|
|
|
504
501
|
cancelRelockActivityLocked()
|
|
505
502
|
|
|
506
|
-
let
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
let
|
|
511
|
-
let
|
|
512
|
-
let
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
schedule = DeviceActivitySchedule(
|
|
518
|
-
intervalStart: DateComponents(
|
|
519
|
-
hour: startComponents.hour,
|
|
520
|
-
minute: startComponents.minute,
|
|
521
|
-
second: startComponents.second
|
|
522
|
-
),
|
|
523
|
-
intervalEnd: DateComponents(
|
|
524
|
-
hour: endComponents.hour,
|
|
525
|
-
minute: endComponents.minute,
|
|
526
|
-
second: endComponents.second
|
|
527
|
-
),
|
|
528
|
-
repeats: false
|
|
529
|
-
)
|
|
530
|
-
} else {
|
|
531
|
-
schedule = DeviceActivitySchedule(
|
|
532
|
-
intervalStart: DateComponents(
|
|
533
|
-
hour: startComponents.hour,
|
|
534
|
-
minute: startComponents.minute,
|
|
535
|
-
second: startComponents.second
|
|
536
|
-
),
|
|
537
|
-
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
538
|
-
repeats: false
|
|
539
|
-
)
|
|
503
|
+
guard let config = currentBlockConfig else {
|
|
504
|
+
print("[AppBlocker] startUsageBasedRelock: no active config, monitoring not started")
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
let appTokens = Set(config.items.compactMap { $0.appToken })
|
|
508
|
+
let categoryTokens = Set(config.items.compactMap { $0.categoryToken })
|
|
509
|
+
let webDomainTokens = Set(config.items.compactMap { $0.webDomainToken })
|
|
510
|
+
|
|
511
|
+
guard !appTokens.isEmpty || !categoryTokens.isEmpty || !webDomainTokens.isEmpty else {
|
|
512
|
+
print("[AppBlocker] startUsageBasedRelock: no blockable tokens, monitoring not started")
|
|
513
|
+
return
|
|
540
514
|
}
|
|
541
515
|
|
|
542
|
-
|
|
516
|
+
let event = DeviceActivityEvent(
|
|
517
|
+
applications: appTokens,
|
|
518
|
+
categories: categoryTokens,
|
|
519
|
+
webDomains: webDomainTokens,
|
|
520
|
+
threshold: DateComponents(minute: max(1, minutes))
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
let schedule = DeviceActivitySchedule(
|
|
524
|
+
intervalStart: DateComponents(hour: 0, minute: 0, second: 0),
|
|
525
|
+
intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
|
|
526
|
+
repeats: true
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
try activityCenter.startMonitoring(
|
|
530
|
+
DeviceActivityName(unlockActivityName),
|
|
531
|
+
during: schedule,
|
|
532
|
+
events: [DeviceActivityEvent.Name(usageEventName): event]
|
|
533
|
+
)
|
|
543
534
|
}
|
|
544
535
|
|
|
545
536
|
private func cancelRelockActivity() {
|
|
@@ -554,16 +545,12 @@ public class ExpoAppBlockerModule: Module {
|
|
|
554
545
|
}
|
|
555
546
|
|
|
556
547
|
private func isTemporarilyUnlockedInternal() -> Bool {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if Date() < expirationDate {
|
|
562
|
-
return true
|
|
563
|
-
}
|
|
548
|
+
return remainingUnlockBudgetSeconds() > 0
|
|
549
|
+
}
|
|
564
550
|
|
|
565
|
-
|
|
566
|
-
|
|
551
|
+
/// Granted earned-time budget in seconds (0 if no active unlock). See `temporaryUnlockKey`.
|
|
552
|
+
private func remainingUnlockBudgetSeconds() -> Int {
|
|
553
|
+
return (sharedDefaults?.object(forKey: temporaryUnlockKey) as? Int) ?? 0
|
|
567
554
|
}
|
|
568
555
|
|
|
569
556
|
// 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.64",
|
|
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();
|
|
@@ -20,6 +20,7 @@ class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
|
|
|
20
20
|
|
|
21
21
|
override func intervalDidEnd(for activity: DeviceActivityName) {
|
|
22
22
|
super.intervalDidEnd(for: activity)
|
|
23
|
+
// Daily safety net: clear any active unlock budget at the schedule boundary.
|
|
23
24
|
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
24
25
|
reapplyBlockConfiguration()
|
|
25
26
|
}
|
|
@@ -28,6 +29,17 @@ class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
|
|
|
28
29
|
super.intervalDidStart(for: activity)
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
override func eventDidReachThreshold(
|
|
33
|
+
_ event: DeviceActivityEvent.Name,
|
|
34
|
+
activity: DeviceActivityName
|
|
35
|
+
) {
|
|
36
|
+
super.eventDidReachThreshold(event, activity: activity)
|
|
37
|
+
// The user has spent their earned usage budget on the blocked apps —
|
|
38
|
+
// clear the unlock and re-apply the shield.
|
|
39
|
+
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
40
|
+
reapplyBlockConfiguration()
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
private func reapplyBlockConfiguration() {
|
|
32
44
|
let userDefaults = sharedDefaults ?? UserDefaults.standard
|
|
33
45
|
|