expo-app-blocker 0.1.59 → 0.1.61

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,16 +21,11 @@ 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
- @Volatile private var paused = false
25
-
26
- private val resumeRunnable = Runnable {
27
- Log.d(TAG, "Temporary unlock expired, resuming blocking")
28
- paused = false
29
- }
24
+ private val unlockController by lazy { TemporaryUnlockController(this, handler) }
30
25
 
31
26
  private val pollRunnable = object : Runnable {
32
27
  override fun run() {
33
- if (!paused) {
28
+ if (!unlockController.isUnlocked) {
34
29
  val foregroundPackage = getCurrentForegroundPackage()
35
30
  if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
36
31
  Log.d(TAG, "Foreground changed: $foregroundPackage")
@@ -123,15 +118,19 @@ class AppBlockerService : Service() {
123
118
  }
124
119
 
125
120
  override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
126
- val action = intent?.action
127
- if (action == ACTION_TEMPORARY_UNLOCK) {
128
- val minutes = intent.getIntExtra(EXTRA_DURATION_MINUTES, 0)
129
- if (minutes > 0) {
121
+ when (intent?.action) {
122
+ ACTION_TEMPORARY_UNLOCK -> {
123
+ val minutes = intent.getIntExtra(EXTRA_DURATION_MINUTES, 0)
130
124
  Log.d(TAG, "Temporary unlock for $minutes minutes")
131
- handler.removeCallbacks(resumeRunnable)
132
- paused = true
125
+ unlockController.unlock(minutes)
133
126
  overlayManager.hide()
134
- handler.postDelayed(resumeRunnable, minutes * 60_000L)
127
+ }
128
+ ACTION_RELOCK -> {
129
+ Log.d(TAG, "Relock: ending temporary unlock")
130
+ unlockController.relock()
131
+ // Forget the last-seen app so a blocked app already in the foreground
132
+ // is re-detected and re-blocked on the next poll.
133
+ lastForegroundPackage = null
135
134
  }
136
135
  }
137
136
  return START_STICKY
@@ -200,15 +199,11 @@ class AppBlockerService : Service() {
200
199
  private const val POLL_INTERVAL_MS = 500L
201
200
  private const val LOOKBACK_WINDOW_MS = 10_000L
202
201
  private const val ACTION_TEMPORARY_UNLOCK = "expo.modules.appblocker.TEMPORARY_UNLOCK"
202
+ private const val ACTION_RELOCK = "expo.modules.appblocker.RELOCK"
203
203
  private const val EXTRA_DURATION_MINUTES = "duration_minutes"
204
204
 
205
205
  fun start(context: Context) {
206
- val intent = Intent(context, AppBlockerService::class.java)
207
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
208
- context.startForegroundService(intent)
209
- } else {
210
- context.startService(intent)
211
- }
206
+ startCommand(context, Intent(context, AppBlockerService::class.java))
212
207
  }
213
208
 
214
209
  fun stop(context: Context) {
@@ -221,6 +216,17 @@ class AppBlockerService : Service() {
221
216
  action = ACTION_TEMPORARY_UNLOCK
222
217
  putExtra(EXTRA_DURATION_MINUTES, durationMinutes)
223
218
  }
219
+ startCommand(context, intent)
220
+ }
221
+
222
+ fun relock(context: Context) {
223
+ val intent = Intent(context, AppBlockerService::class.java).apply {
224
+ action = ACTION_RELOCK
225
+ }
226
+ startCommand(context, intent)
227
+ }
228
+
229
+ private fun startCommand(context: Context, intent: Intent) {
224
230
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
225
231
  context.startForegroundService(intent)
226
232
  } else {
@@ -118,6 +118,15 @@ class ExpoAppBlockerModule : Module() {
118
118
  Log.d(TAG, "temporaryUnlockAndroid: $durationMinutes minutes")
119
119
  }
120
120
 
121
+ Function("relockAndroid") {
122
+ AppBlockerService.relock(context)
123
+ Log.d(TAG, "relockAndroid")
124
+ }
125
+
126
+ Function("getRemainingUnlockTimeAndroid") {
127
+ TemporaryUnlockController.remainingSeconds(context)
128
+ }
129
+
121
130
  AsyncFunction("getInstalledApps") {
122
131
  val pm = context.packageManager
123
132
  val intent = Intent(Intent.ACTION_MAIN).apply {
@@ -0,0 +1,64 @@
1
+ package expo.modules.appblocker
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+
6
+ /**
7
+ * Single source of truth for the Android "temporary unlock" state.
8
+ *
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.
14
+ *
15
+ * Drive [unlock] / [relock] from the same (main) thread the [Handler] is bound to.
16
+ */
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
26
+
27
+ /** Suppress blocking for [durationMinutes], replacing any pending expiry. No-op if <= 0. */
28
+ fun unlock(durationMinutes: Int) {
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)
34
+ }
35
+
36
+ /** End any active unlock immediately, restoring blocking. */
37
+ fun relock() {
38
+ handler.removeCallbacks(expireRunnable)
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
+ }
63
+ }
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
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,14 +181,16 @@ 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
 
189
190
  export async function relockApps(): Promise<RelockResult> {
190
- if (Platform.OS !== "ios") {
191
- throw new Error("Relock is only available on iOS");
191
+ if (Platform.OS === "android") {
192
+ NativeModule.relockAndroid();
193
+ return { locked: true };
192
194
  }
193
195
  return NativeModule.relockApps();
194
196
  }