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 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 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, 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,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 expirationDate = Date().addingTimeInterval(TimeInterval(sanitizedDurationMinutes * 60))
203
- self.sharedDefaults?.set(expirationDate, forKey: self.temporaryUnlockKey)
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
- // Try to schedule relock, but don't fail if schedule is too short
212
- // (Apple requires minimum ~15 min for DeviceActivitySchedule)
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.scheduleRelockActivity(expirationDate: expirationDate)
219
+ try self.startUsageBasedRelock(minutes: sanitizedDurationMinutes)
215
220
  } 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)")
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": expirationDate.timeIntervalSince1970
233
+ "expiresAt": approxExpiresAt
226
234
  ])
227
235
  }
228
236
  }
229
237
  }
230
238
 
231
239
  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
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
- 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
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 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()
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
- private func scheduleRelockActivity(expirationDate: Date) throws {
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 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
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
- try activityCenter.startMonitoring(activityName, during: schedule)
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
- guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
558
- return false
559
- }
560
-
561
- if Date() < expirationDate {
562
- return true
563
- }
548
+ return remainingUnlockBudgetSeconds() > 0
549
+ }
564
550
 
565
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
566
- return false
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.63",
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
- /** 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();
@@ -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