expo-app-blocker 0.1.60 → 0.1.62

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.
@@ -21,13 +21,24 @@ class AppBlockerService : Service() {
21
21
  private val handler = Handler(Looper.getMainLooper())
22
22
  private var lastForegroundPackage: String? = null
23
23
  private lateinit var overlayManager: OverlayManager
24
- private val unlockController = TemporaryUnlockController(handler)
24
+ private val unlockController by lazy { TemporaryUnlockController(this, handler) }
25
+ private var wasUnlocked = false
25
26
 
26
27
  private val pollRunnable = object : Runnable {
27
28
  override fun run() {
28
- if (!unlockController.isUnlocked) {
29
+ if (unlockController.isUnlocked) {
30
+ wasUnlocked = true
31
+ } else {
29
32
  val foregroundPackage = getCurrentForegroundPackage()
30
- if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
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) {
31
42
  Log.d(TAG, "Foreground changed: $foregroundPackage")
32
43
  lastForegroundPackage = foregroundPackage
33
44
  handleForegroundChange(foregroundPackage)
@@ -48,18 +59,24 @@ class AppBlockerService : Service() {
48
59
  handler.post(pollRunnable)
49
60
  }
50
61
 
62
+ private fun isBlocked(packageName: String): Boolean =
63
+ packageName in AppBlockerPrefs.getBlockedPackages(this)
64
+
51
65
  private fun handleForegroundChange(foregroundPackage: String) {
52
- val blocked = AppBlockerPrefs.getBlockedPackages(this)
53
- if (foregroundPackage in blocked) {
66
+ if (isBlocked(foregroundPackage)) {
54
67
  Log.d(TAG, "Blocked app in foreground: $foregroundPackage")
55
- overlayManager.show(foregroundPackage)
56
- showBlockedNotification(foregroundPackage)
68
+ block(foregroundPackage, BlockReason.OPENED)
57
69
  } else {
58
70
  overlayManager.hide()
59
71
  }
60
72
  }
61
73
 
62
- private fun showBlockedNotification(packageName: String) {
74
+ private fun block(packageName: String, reason: BlockReason) {
75
+ overlayManager.show(packageName, reason)
76
+ showBlockedNotification(packageName, reason)
77
+ }
78
+
79
+ private fun showBlockedNotification(packageName: String, reason: BlockReason) {
63
80
  val appName = try {
64
81
  val pm = this.packageManager
65
82
  val appInfo = pm.getApplicationInfo(packageName, 0)
@@ -74,7 +91,10 @@ class AppBlockerService : Service() {
74
91
  val scheme = getAppScheme()
75
92
  val deepLinkIntent = Intent(
76
93
  Intent.ACTION_VIEW,
77
- Uri.parse("${scheme}://blocked?app=${Uri.encode(appName)}&package=${Uri.encode(packageName)}")
94
+ Uri.parse(
95
+ "${scheme}://blocked?app=${Uri.encode(appName)}" +
96
+ "&package=${Uri.encode(packageName)}&reason=${reason.slug}"
97
+ )
78
98
  ).apply {
79
99
  addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
80
100
  }
@@ -0,0 +1,14 @@
1
+ package expo.modules.appblocker
2
+
3
+ /**
4
+ * Why a blocked app is being intercepted right now. Carried into the deep link
5
+ * so the JS side can branch (e.g. a softer "time's up" interstitial when an
6
+ * earned-time window expires while the user is still inside the app).
7
+ */
8
+ enum class BlockReason(val slug: String) {
9
+ /** A blocked app was freshly brought to the foreground. */
10
+ OPENED("opened"),
11
+
12
+ /** A temporary unlock expired while a blocked app was already in the foreground. */
13
+ EXPIRED("expired"),
14
+ }
@@ -123,6 +123,10 @@ class ExpoAppBlockerModule : Module() {
123
123
  Log.d(TAG, "relockAndroid")
124
124
  }
125
125
 
126
+ Function("getRemainingUnlockTimeAndroid") {
127
+ TemporaryUnlockController.remainingSeconds(context)
128
+ }
129
+
126
130
  AsyncFunction("getInstalledApps") {
127
131
  val pm = context.packageManager
128
132
  val intent = Intent(Intent.ACTION_MAIN).apply {
@@ -24,11 +24,11 @@ class OverlayManager(private val context: Context) {
24
24
 
25
25
  private var overlayView: View? = null
26
26
 
27
- fun show(blockedPackageName: String? = null) {
27
+ fun show(blockedPackageName: String? = null, reason: BlockReason = BlockReason.OPENED) {
28
28
  if (overlayView != null) {
29
29
  Log.d(TAG, "Overlay already visible")
30
30
  if (blockedPackageName != null) {
31
- navigateToApp(blockedPackageName)
31
+ navigateToApp(blockedPackageName, reason)
32
32
  } else {
33
33
  bringAppToFront()
34
34
  }
@@ -47,7 +47,7 @@ class OverlayManager(private val context: Context) {
47
47
  }
48
48
 
49
49
  if (blockedPackageName != null) {
50
- navigateToApp(blockedPackageName)
50
+ navigateToApp(blockedPackageName, reason)
51
51
  } else {
52
52
  bringAppToFront()
53
53
  }
@@ -72,14 +72,17 @@ class OverlayManager(private val context: Context) {
72
72
  packageName
73
73
  }
74
74
 
75
- private fun navigateToApp(blockedPackageName: String) {
75
+ private fun navigateToApp(blockedPackageName: String, reason: BlockReason) {
76
76
  val appName = resolveAppName(blockedPackageName)
77
77
 
78
78
  // Use the app's own scheme for deep linking
79
79
  val scheme = getAppScheme()
80
80
  val deepLinkIntent = Intent(
81
81
  Intent.ACTION_VIEW,
82
- Uri.parse("${scheme}://blocked?app=${Uri.encode(appName)}&package=${Uri.encode(blockedPackageName)}")
82
+ Uri.parse(
83
+ "${scheme}://blocked?app=${Uri.encode(appName)}" +
84
+ "&package=${Uri.encode(blockedPackageName)}&reason=${reason.slug}"
85
+ )
83
86
  ).apply {
84
87
  addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
85
88
  }
@@ -1,37 +1,64 @@
1
1
  package expo.modules.appblocker
2
2
 
3
+ import android.content.Context
3
4
  import android.os.Handler
4
5
 
5
6
  /**
6
- * Owns the "temporary unlock" state for Android blocking.
7
+ * Single source of truth for the Android "temporary unlock" state.
7
8
  *
8
9
  * While unlocked, the monitor should not block — see [isUnlocked]. An unlock
9
- * auto-expires after the requested duration; [relock] ends it early. All timer
10
- * bookkeeping is hidden here so the service only asks a single yes/no question.
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.
11
14
  *
12
- * Not thread-safe beyond the [isUnlocked] read: drive [unlock] / [relock] from
13
- * the same (main) thread the [Handler] is bound to.
15
+ * Drive [unlock] / [relock] from the same (main) thread the [Handler] is bound to.
14
16
  */
15
- class TemporaryUnlockController(private val handler: Handler) {
16
- @Volatile private var unlocked = false
17
-
18
- private val expireRunnable = Runnable { unlocked = false }
17
+ class TemporaryUnlockController(
18
+ private val context: Context,
19
+ private val handler: Handler,
20
+ ) {
21
+ private val expireRunnable = Runnable { Store.clear(context) }
19
22
 
20
23
  /** True while a temporary unlock is in effect (blocking should be suppressed). */
21
24
  val isUnlocked: Boolean
22
- get() = unlocked
25
+ get() = Store.remainingSeconds(context) > 0
23
26
 
24
27
  /** Suppress blocking for [durationMinutes], replacing any pending expiry. No-op if <= 0. */
25
28
  fun unlock(durationMinutes: Int) {
26
29
  if (durationMinutes <= 0) return
30
+ val durationMs = durationMinutes * 60_000L
27
31
  handler.removeCallbacks(expireRunnable)
28
- unlocked = true
29
- handler.postDelayed(expireRunnable, durationMinutes * 60_000L)
32
+ Store.setExpiry(context, System.currentTimeMillis() + durationMs)
33
+ handler.postDelayed(expireRunnable, durationMs)
30
34
  }
31
35
 
32
36
  /** End any active unlock immediately, restoring blocking. */
33
37
  fun relock() {
34
38
  handler.removeCallbacks(expireRunnable)
35
- unlocked = false
39
+ Store.clear(context)
40
+ }
41
+
42
+ /**
43
+ * Stateless persistence for the unlock expiry. Readable from anywhere with a
44
+ * [Context] — the running service is not required.
45
+ */
46
+ companion object Store {
47
+ private const val KEY_EXPIRES_AT = "temporary_unlock_expires_at"
48
+
49
+ private fun setExpiry(context: Context, expiresAtMs: Long) {
50
+ AppBlockerPrefs.get(context).edit().putLong(KEY_EXPIRES_AT, expiresAtMs).apply()
51
+ }
52
+
53
+ private fun clear(context: Context) {
54
+ AppBlockerPrefs.get(context).edit().remove(KEY_EXPIRES_AT).apply()
55
+ }
56
+
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
+ }
36
63
  }
37
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
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
@@ -181,8 +181,9 @@ export function isTemporarilyUnlocked(): boolean {
181
181
  return NativeModule.isTemporarilyUnlocked();
182
182
  }
183
183
 
184
+ /** Seconds remaining on the active temporary unlock, or 0 if none. */
184
185
  export function getRemainingUnlockTime(): number {
185
- if (Platform.OS !== "ios") return 0;
186
+ if (Platform.OS === "android") return NativeModule.getRemainingUnlockTimeAndroid();
186
187
  return NativeModule.getRemainingUnlockTime();
187
188
  }
188
189