expo-app-blocker 0.1.61 → 0.1.63
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 +56 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +28 -8
- package/android/src/main/java/expo/modules/appblocker/BlockReason.kt +14 -0
- package/android/src/main/java/expo/modules/appblocker/OverlayManager.kt +8 -5
- package/package.json +1 -1
- package/src/index.ts +14 -0
package/README.md
CHANGED
|
@@ -388,6 +388,31 @@ startMonitoring(); // Start foreground service (auto-started on init)
|
|
|
388
388
|
stopMonitoring(); // Stop monitoring
|
|
389
389
|
```
|
|
390
390
|
|
|
391
|
+
#### Deep-link contract (how your app is launched)
|
|
392
|
+
|
|
393
|
+
When the foreground service detects a blocked app, it brings **your** app to the
|
|
394
|
+
front via a deep link using your app's own URL scheme:
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
<yourScheme>://blocked?app=<AppName>&package=<package.name>&reason=<reason>
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
| Param | Description |
|
|
401
|
+
|---|---|
|
|
402
|
+
| `app` | Human-readable label of the blocked app (URL-encoded), e.g. `Instagram` |
|
|
403
|
+
| `package` | Android package name of the blocked app, e.g. `com.instagram.android` |
|
|
404
|
+
| `reason` | Why the block fired — see below. Lets you branch your UI |
|
|
405
|
+
|
|
406
|
+
`reason` values:
|
|
407
|
+
|
|
408
|
+
| Value | Meaning |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `opened` | A blocked app was freshly brought to the foreground. |
|
|
411
|
+
| `expired` | A [temporary unlock](#android-temporary-unlock) expired while the user was still **inside** the blocked app. Handle this if you want a softer "time's up" interstitial instead of jumping straight into your gate. |
|
|
412
|
+
|
|
413
|
+
Handle the deep link with `expo-linking` / `expo-router` like any other route.
|
|
414
|
+
Your scheme is auto-detected from the app config; no extra setup required.
|
|
415
|
+
|
|
391
416
|
### iOS: App Selection
|
|
392
417
|
|
|
393
418
|
Two ways to let users pick which apps to block:
|
|
@@ -480,6 +505,37 @@ const seconds = getRemainingUnlockTime(); // seconds remaining
|
|
|
480
505
|
await relockApps(); // re-lock immediately
|
|
481
506
|
```
|
|
482
507
|
|
|
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.
|
|
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)).
|
|
538
|
+
|
|
483
539
|
### iOS: Shield Button Events
|
|
484
540
|
|
|
485
541
|
When a user taps the primary button on the shield overlay, your app receives an event:
|
|
@@ -22,12 +22,23 @@ class AppBlockerService : Service() {
|
|
|
22
22
|
private var lastForegroundPackage: String? = null
|
|
23
23
|
private lateinit var overlayManager: OverlayManager
|
|
24
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 (
|
|
29
|
+
if (unlockController.isUnlocked) {
|
|
30
|
+
wasUnlocked = true
|
|
31
|
+
} else {
|
|
29
32
|
val foregroundPackage = getCurrentForegroundPackage()
|
|
30
|
-
|
|
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
|
-
|
|
53
|
-
if (foregroundPackage in blocked) {
|
|
66
|
+
if (isBlocked(foregroundPackage)) {
|
|
54
67
|
Log.d(TAG, "Blocked app in foreground: $foregroundPackage")
|
|
55
|
-
|
|
56
|
-
showBlockedNotification(foregroundPackage)
|
|
68
|
+
block(foregroundPackage, BlockReason.OPENED)
|
|
57
69
|
} else {
|
|
58
70
|
overlayManager.hide()
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
|
|
62
|
-
private fun
|
|
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(
|
|
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
|
+
}
|
|
@@ -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(
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-app-blocker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
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
|
@@ -168,6 +168,13 @@ export function isAppBlocked(bundleIdentifier: string): boolean {
|
|
|
168
168
|
// iOS-specific: Temporary unlock
|
|
169
169
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Suppress blocking for `durationMinutes`, then auto-resume.
|
|
173
|
+
*
|
|
174
|
+
* iOS removes the Family Controls shields; Android pauses the foreground-service
|
|
175
|
+
* poll (the timer lives in the service, so it survives app backgrounding).
|
|
176
|
+
* Calling again replaces any active unlock. Android rounds to a whole minute (min 1).
|
|
177
|
+
*/
|
|
171
178
|
export async function temporaryUnlock(durationMinutes: number = 15): Promise<TemporaryUnlockResult> {
|
|
172
179
|
if (Platform.OS === "android") {
|
|
173
180
|
NativeModule.temporaryUnlockAndroid(Math.max(1, Math.round(durationMinutes)));
|
|
@@ -176,6 +183,7 @@ export async function temporaryUnlock(durationMinutes: number = 15): Promise<Tem
|
|
|
176
183
|
return NativeModule.temporaryUnlock(durationMinutes);
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
/** iOS only — returns `false` on Android. On Android use `getRemainingUnlockTime() > 0`. */
|
|
179
187
|
export function isTemporarilyUnlocked(): boolean {
|
|
180
188
|
if (Platform.OS !== "ios") return false;
|
|
181
189
|
return NativeModule.isTemporarilyUnlocked();
|
|
@@ -187,6 +195,12 @@ export function getRemainingUnlockTime(): number {
|
|
|
187
195
|
return NativeModule.getRemainingUnlockTime();
|
|
188
196
|
}
|
|
189
197
|
|
|
198
|
+
/**
|
|
199
|
+
* End an active temporary unlock immediately and re-block.
|
|
200
|
+
*
|
|
201
|
+
* iOS restores the shields; Android cancels the unlock and re-blocks the
|
|
202
|
+
* foreground app on the next poll. Safe to call when nothing is unlocked.
|
|
203
|
+
*/
|
|
190
204
|
export async function relockApps(): Promise<RelockResult> {
|
|
191
205
|
if (Platform.OS === "android") {
|
|
192
206
|
NativeModule.relockAndroid();
|