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.
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +29 -9
- package/android/src/main/java/expo/modules/appblocker/BlockReason.kt +14 -0
- package/android/src/main/java/expo/modules/appblocker/ExpoAppBlockerModule.kt +4 -0
- package/android/src/main/java/expo/modules/appblocker/OverlayManager.kt +8 -5
- package/android/src/main/java/expo/modules/appblocker/TemporaryUnlockController.kt +40 -13
- package/package.json +1 -1
- package/src/index.ts +2 -1
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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(
|
|
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
|
-
*
|
|
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.
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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() =
|
|
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
|
-
|
|
29
|
-
handler.postDelayed(expireRunnable,
|
|
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
|
-
|
|
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.
|
|
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
|
|
186
|
+
if (Platform.OS === "android") return NativeModule.getRemainingUnlockTimeAndroid();
|
|
186
187
|
return NativeModule.getRemainingUnlockTime();
|
|
187
188
|
}
|
|
188
189
|
|