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 CHANGED
@@ -486,7 +486,12 @@ const config = getBlockConfiguration();
486
486
  clearAllBlocks();
487
487
  ```
488
488
 
489
- ### iOS: Temporary Unlock
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
- // Unlock for N minutes (removes shields temporarily)
504
+ // Grant N minutes of usage budget for the blocked apps
500
505
  const result = await temporaryUnlock(15);
501
- // { unlocked: boolean, expiresAt: number }
506
+ // { unlocked: true, expiresAt: number } (expiresAt is a best-effort hint only)
502
507
 
503
- const unlocked = isTemporarilyUnlocked(); // boolean
504
- const seconds = getRemainingUnlockTime(); // seconds remaining
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
- ### Android: Temporary Unlock
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
- ```typescript
515
- import {
516
- temporaryUnlock,
517
- getRemainingUnlockTime,
518
- relockApps,
519
- } from 'expo-app-blocker';
520
-
521
- // Suppress blocking for N minutes; auto-resumes on expiry
522
- await temporaryUnlock(15);
523
- // { unlocked: true, expiresAt: number }
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, handler) }
25
- private var wasUnlocked = false
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
- if (unlockController.isUnlocked) {
30
- wasUnlocked = true
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
- val foregroundPackage = getCurrentForegroundPackage()
33
- val unlockJustExpired = wasUnlocked
34
- wasUnlocked = false
35
-
36
- if (unlockJustExpired && foregroundPackage != null && isBlocked(foregroundPackage)) {
37
- // Earned time ran out while the user was still inside a blocked app.
38
- Log.d(TAG, "Unlock expired in foreground app: $foregroundPackage")
39
- lastForegroundPackage = foregroundPackage
40
- block(foregroundPackage, BlockReason.EXPIRED)
41
- } else if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
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
- handler.postDelayed(this, POLL_INTERVAL_MS)
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 handleForegroundChange(foregroundPackage: String) {
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, "Temporary unlock for $minutes minutes")
145
- unlockController.unlock(minutes)
146
- overlayManager.hide()
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: ending temporary unlock")
150
- unlockController.relock()
151
- // Forget the last-seen app so a blocked app already in the foreground
152
- // is re-detected and re-blocked on the next poll.
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 "temporary unlock" state.
6
+ * Single source of truth for the Android "earned time" budget.
8
7
  *
9
- * While unlocked, the monitor should not block see [isUnlocked]. An unlock
10
- * auto-expires after the requested duration; [relock] ends it early. The expiry
11
- * timestamp is persisted (see [Store]) so the JS module can read the remaining
12
- * time without holding a reference to the running service, mirroring the iOS
13
- * shared-defaults approach.
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
- * Drive [unlock] / [relock] from the same (main) thread the [Handler] is bound to.
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
- private val context: Context,
19
- private val handler: Handler,
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
- /** Suppress blocking for [durationMinutes], replacing any pending expiry. No-op if <= 0. */
28
- fun unlock(durationMinutes: Int) {
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
- val durationMs = durationMinutes * 60_000L
31
- handler.removeCallbacks(expireRunnable)
32
- Store.setExpiry(context, System.currentTimeMillis() + durationMs)
33
- handler.postDelayed(expireRunnable, durationMs)
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
- /** End any active unlock immediately, restoring blocking. */
37
- fun relock() {
38
- handler.removeCallbacks(expireRunnable)
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 unlock expiry. Readable from anywhere with a
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 KEY_EXPIRES_AT = "temporary_unlock_expires_at"
51
+ private const val KEY_REMAINING_MS = "temporary_unlock_remaining_ms"
48
52
 
49
- private fun setExpiry(context: Context, expiresAtMs: Long) {
50
- AppBlockerPrefs.get(context).edit().putLong(KEY_EXPIRES_AT, expiresAtMs).apply()
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 clear(context: Context) {
54
- AppBlockerPrefs.get(context).edit().remove(KEY_EXPIRES_AT).apply()
55
- }
57
+ private fun remainingMs(context: Context): Long =
58
+ AppBlockerPrefs.get(context).getLong(KEY_REMAINING_MS, 0L).coerceAtLeast(0L)
56
59
 
57
- /** Seconds remaining on the active unlock, or 0 if none / already expired. */
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.sharedDefaults?.removeObject(forKey: self.temporaryUnlockKey)
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 expirationDate = Date().addingTimeInterval(TimeInterval(sanitizedDurationMinutes * 60))
203
- self.sharedDefaults?.set(expirationDate, forKey: self.temporaryUnlockKey)
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
- // Try to schedule relock, but don't fail if schedule is too short
212
- // (Apple requires minimum ~15 min for DeviceActivitySchedule)
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.scheduleRelockActivity(expirationDate: expirationDate)
236
+ try self.startUsageBasedRelock(budgetSeconds: budgetSeconds)
215
237
  } catch {
216
- // Schedule failed (too short) - that's OK, we still unlock.
217
- // The app will re-check and relock via checkAndApplyUnlockState
218
- // when the expiration passes and user returns to the app.
219
- print("[AppBlocker] Schedule relock failed (duration may be too short): \(error.localizedDescription)")
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": expirationDate.timeIntervalSince1970
250
+ "expiresAt": approxExpiresAt
226
251
  ])
227
252
  }
228
253
  }
229
254
  }
230
255
 
231
256
  Function("isTemporarilyUnlocked") { () -> Bool in
232
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
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
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
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 let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date {
352
- let remaining = expirationDate.timeIntervalSince(Date())
353
-
354
- if remaining > 0 {
355
- DispatchQueue.main.async {
356
- self.store.shield.applications = nil
357
- self.store.shield.applicationCategories = nil
358
- self.store.shield.webDomains = nil
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
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
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
- private func scheduleRelockActivity(expirationDate: Date) throws {
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 activityName = DeviceActivityName(unlockActivityName)
507
- let calendar = Calendar.current
508
- let now = Date()
509
- let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
510
- let endComponents = calendar.dateComponents([.hour, .minute, .second], from: expirationDate)
511
- let nowDay = calendar.startOfDay(for: now)
512
- let expirationDay = calendar.startOfDay(for: expirationDate)
513
-
514
- let schedule: DeviceActivitySchedule
515
-
516
- if nowDay == expirationDay {
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
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
- try activityCenter.startMonitoring(activityName, during: schedule)
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
- guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
558
- return false
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
- return false
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.63",
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
- /** Seconds remaining on the active temporary unlock, or 0 if none. */
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
- class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
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
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
24
- reapplyBlockConfiguration()
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