expo-app-blocker 0.1.25 → 0.1.26
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/AppBlockerPrefs.kt +25 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +27 -4
- package/android/src/main/java/expo/modules/appblocker/ExpoAppBlockerModule.kt +10 -0
- package/android/src/main/java/expo/modules/appblocker/OverlayManager.kt +44 -20
- package/package.json +1 -1
- package/plugin/src/index.js +69 -1
- package/src/ExpoAppBlocker.types.ts +16 -7
- package/src/index.ts +7 -0
|
@@ -6,6 +6,9 @@ import android.content.SharedPreferences
|
|
|
6
6
|
object AppBlockerPrefs {
|
|
7
7
|
const val PREFS_NAME = "expo_app_blocker_prefs"
|
|
8
8
|
const val KEY_BLOCKED_PACKAGES = "blocked_packages"
|
|
9
|
+
private const val KEY_OVERLAY_TEXT = "overlay_text"
|
|
10
|
+
private const val KEY_NOTIFICATION_TITLE = "notification_title"
|
|
11
|
+
private const val KEY_NOTIFICATION_TEXT = "notification_text"
|
|
9
12
|
|
|
10
13
|
fun get(context: Context): SharedPreferences =
|
|
11
14
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
@@ -18,4 +21,26 @@ object AppBlockerPrefs {
|
|
|
18
21
|
.putStringSet(KEY_BLOCKED_PACKAGES, packages.toSet())
|
|
19
22
|
.apply()
|
|
20
23
|
}
|
|
24
|
+
|
|
25
|
+
fun setAndroidConfig(
|
|
26
|
+
context: Context,
|
|
27
|
+
overlayText: String?,
|
|
28
|
+
notificationTitle: String?,
|
|
29
|
+
notificationText: String?,
|
|
30
|
+
) {
|
|
31
|
+
get(context).edit()
|
|
32
|
+
.putString(KEY_OVERLAY_TEXT, overlayText)
|
|
33
|
+
.putString(KEY_NOTIFICATION_TITLE, notificationTitle)
|
|
34
|
+
.putString(KEY_NOTIFICATION_TEXT, notificationText)
|
|
35
|
+
.apply()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fun getOverlayText(context: Context): String =
|
|
39
|
+
get(context).getString(KEY_OVERLAY_TEXT, null) ?: "{appName} is blocked."
|
|
40
|
+
|
|
41
|
+
fun getNotificationTitle(context: Context): String =
|
|
42
|
+
get(context).getString(KEY_NOTIFICATION_TITLE, null) ?: "App Blocked"
|
|
43
|
+
|
|
44
|
+
fun getNotificationText(context: Context): String =
|
|
45
|
+
get(context).getString(KEY_NOTIFICATION_TEXT, null) ?: "{appName} is blocked. Tap to manage."
|
|
21
46
|
}
|
|
@@ -65,7 +65,10 @@ class AppBlockerService : Service() {
|
|
|
65
65
|
packageName
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
val
|
|
68
|
+
val title = AppBlockerPrefs.getNotificationTitle(this).replace("{appName}", appName)
|
|
69
|
+
val text = AppBlockerPrefs.getNotificationText(this).replace("{appName}", appName)
|
|
70
|
+
|
|
71
|
+
val scheme = getAppScheme()
|
|
69
72
|
val deepLinkIntent = Intent(
|
|
70
73
|
Intent.ACTION_VIEW,
|
|
71
74
|
Uri.parse("${scheme}://blocked?app=${Uri.encode(appName)}&package=${Uri.encode(packageName)}")
|
|
@@ -73,14 +76,23 @@ class AppBlockerService : Service() {
|
|
|
73
76
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
val launchIntent = packageManager.getLaunchIntentForPackage(this.packageName)
|
|
80
|
+
?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) }
|
|
81
|
+
|
|
82
|
+
val resolvedIntent = try {
|
|
83
|
+
deepLinkIntent.resolveActivity(packageManager)?.let { deepLinkIntent } ?: launchIntent
|
|
84
|
+
} catch (e: Exception) {
|
|
85
|
+
launchIntent
|
|
86
|
+
} ?: deepLinkIntent
|
|
87
|
+
|
|
76
88
|
val pendingIntent = PendingIntent.getActivity(
|
|
77
|
-
this,
|
|
89
|
+
this, packageName.hashCode(), resolvedIntent,
|
|
78
90
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
79
91
|
)
|
|
80
92
|
|
|
81
93
|
val notification = NotificationCompat.Builder(this, BLOCKED_CHANNEL_ID)
|
|
82
|
-
.setContentTitle(
|
|
83
|
-
.setContentText(
|
|
94
|
+
.setContentTitle(title)
|
|
95
|
+
.setContentText(text)
|
|
84
96
|
.setSmallIcon(applicationInfo.icon)
|
|
85
97
|
.setAutoCancel(true)
|
|
86
98
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
@@ -91,6 +103,17 @@ class AppBlockerService : Service() {
|
|
|
91
103
|
manager.notify(BLOCKED_NOTIFICATION_ID, notification)
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
private fun getAppScheme(): String {
|
|
107
|
+
val resId = resources.getIdentifier("expo_app_blocker_scheme", "string", packageName)
|
|
108
|
+
if (resId != 0) return getString(resId)
|
|
109
|
+
return try {
|
|
110
|
+
packageManager.getLaunchIntentForPackage(packageName)?.data?.scheme
|
|
111
|
+
?: packageName.replace(".", "-")
|
|
112
|
+
} catch (e: Exception) {
|
|
113
|
+
packageName.replace(".", "-")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
95
118
|
Log.d(TAG, "AppBlockerService onStartCommand")
|
|
96
119
|
return START_STICKY
|
|
@@ -61,6 +61,16 @@ class ExpoAppBlockerModule : Module() {
|
|
|
61
61
|
context.startActivity(intent)
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
Function("setAndroidConfig") { config: Map<String, Any?> ->
|
|
65
|
+
AppBlockerPrefs.setAndroidConfig(
|
|
66
|
+
context,
|
|
67
|
+
overlayText = config["overlayText"] as? String,
|
|
68
|
+
notificationTitle = config["notificationTitle"] as? String,
|
|
69
|
+
notificationText = config["notificationText"] as? String,
|
|
70
|
+
)
|
|
71
|
+
Log.d(TAG, "setAndroidConfig: $config")
|
|
72
|
+
}
|
|
73
|
+
|
|
64
74
|
Function("setBlockedApps") { packageNames: List<String> ->
|
|
65
75
|
AppBlockerPrefs.setBlockedPackages(context, packageNames)
|
|
66
76
|
Log.d(TAG, "setBlockedApps: $packageNames")
|
|
@@ -4,12 +4,15 @@ import android.content.Context
|
|
|
4
4
|
import android.content.Intent
|
|
5
5
|
import android.graphics.Color
|
|
6
6
|
import android.graphics.PixelFormat
|
|
7
|
+
import android.graphics.Typeface
|
|
7
8
|
import android.net.Uri
|
|
8
9
|
import android.os.Build
|
|
9
10
|
import android.util.Log
|
|
11
|
+
import android.util.TypedValue
|
|
10
12
|
import android.view.Gravity
|
|
11
13
|
import android.view.View
|
|
12
14
|
import android.view.WindowManager
|
|
15
|
+
import android.widget.LinearLayout
|
|
13
16
|
import android.widget.TextView
|
|
14
17
|
|
|
15
18
|
class OverlayManager(private val context: Context) {
|
|
@@ -29,7 +32,8 @@ class OverlayManager(private val context: Context) {
|
|
|
29
32
|
return
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
val
|
|
35
|
+
val appName = blockedPackageName?.let { resolveAppName(it) } ?: ""
|
|
36
|
+
val view = buildOverlayView(appName)
|
|
33
37
|
try {
|
|
34
38
|
windowManager.addView(view, buildLayoutParams())
|
|
35
39
|
overlayView = view
|
|
@@ -57,14 +61,16 @@ class OverlayManager(private val context: Context) {
|
|
|
57
61
|
overlayView = null
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
private fun resolveAppName(packageName: String): String = try {
|
|
65
|
+
val pm = context.packageManager
|
|
66
|
+
val appInfo = pm.getApplicationInfo(packageName, 0)
|
|
67
|
+
pm.getApplicationLabel(appInfo).toString()
|
|
68
|
+
} catch (e: Exception) {
|
|
69
|
+
packageName
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
private fun navigateToApp(blockedPackageName: String) {
|
|
61
|
-
val appName =
|
|
62
|
-
val pm = context.packageManager
|
|
63
|
-
val appInfo = pm.getApplicationInfo(blockedPackageName, 0)
|
|
64
|
-
pm.getApplicationLabel(appInfo).toString()
|
|
65
|
-
} catch (e: Exception) {
|
|
66
|
-
blockedPackageName
|
|
67
|
-
}
|
|
73
|
+
val appName = resolveAppName(blockedPackageName)
|
|
68
74
|
|
|
69
75
|
// Use the app's own scheme for deep linking
|
|
70
76
|
val scheme = getAppScheme()
|
|
@@ -102,22 +108,40 @@ class OverlayManager(private val context: Context) {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
private fun getAppScheme(): String {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
intent?.data?.scheme ?: context.packageName.replace(".", "-")
|
|
109
|
-
} catch (e: Exception) {
|
|
110
|
-
context.packageName.replace(".", "-")
|
|
111
|
-
}
|
|
111
|
+
val resId = context.resources.getIdentifier("expo_app_blocker_scheme", "string", context.packageName)
|
|
112
|
+
if (resId != 0) return context.getString(resId)
|
|
113
|
+
return context.packageName.replace(".", "-")
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
private fun buildOverlayView(): View {
|
|
115
|
-
val
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
private fun buildOverlayView(appName: String): View {
|
|
117
|
+
val density = context.resources.displayMetrics.density
|
|
118
|
+
fun dp(value: Int) = (value * density).toInt()
|
|
119
|
+
|
|
120
|
+
val overlayText = AppBlockerPrefs.getOverlayText(context)
|
|
121
|
+
.replace("{appName}", appName)
|
|
122
|
+
|
|
123
|
+
return LinearLayout(context).apply {
|
|
124
|
+
orientation = LinearLayout.VERTICAL
|
|
118
125
|
gravity = Gravity.CENTER
|
|
126
|
+
setBackgroundColor(Color.WHITE)
|
|
127
|
+
setPadding(dp(32), dp(32), dp(32), dp(32))
|
|
128
|
+
|
|
129
|
+
addView(TextView(context).apply {
|
|
130
|
+
text = "App Blocked"
|
|
131
|
+
setTextColor(Color.parseColor("#111111"))
|
|
132
|
+
setTextSize(TypedValue.COMPLEX_UNIT_SP, 24f)
|
|
133
|
+
setTypeface(typeface, Typeface.BOLD)
|
|
134
|
+
gravity = Gravity.CENTER
|
|
135
|
+
setPadding(0, 0, 0, dp(12))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
addView(TextView(context).apply {
|
|
139
|
+
text = overlayText
|
|
140
|
+
setTextColor(Color.parseColor("#737373"))
|
|
141
|
+
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
|
142
|
+
gravity = Gravity.CENTER
|
|
143
|
+
})
|
|
119
144
|
}
|
|
120
|
-
return textView
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
private fun buildLayoutParams(): WindowManager.LayoutParams {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-app-blocker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
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/plugin/src/index.js
CHANGED
|
@@ -18,8 +18,20 @@ const path = require("path");
|
|
|
18
18
|
// Android
|
|
19
19
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
20
20
|
|
|
21
|
+
function getAndroidScheme(config, pluginConfig) {
|
|
22
|
+
if (pluginConfig?.android?.scheme) return pluginConfig.android.scheme;
|
|
23
|
+
const configScheme = Array.isArray(config.scheme) ? config.scheme[0] : config.scheme;
|
|
24
|
+
if (configScheme) return configScheme;
|
|
25
|
+
const pkg = config.android?.package;
|
|
26
|
+
if (pkg) return pkg.replace(/\./g, "-");
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
function withAppBlockerAndroid(config, pluginConfig) {
|
|
22
|
-
|
|
31
|
+
const scheme = getAndroidScheme(config, pluginConfig);
|
|
32
|
+
|
|
33
|
+
// Manifest: permissions, service, receiver, and deep-link intent filter
|
|
34
|
+
config = withAndroidManifest(config, (config) => {
|
|
23
35
|
const manifest = config.modResults;
|
|
24
36
|
const mainApplication = manifest.manifest.application?.[0];
|
|
25
37
|
if (!mainApplication) return config;
|
|
@@ -75,8 +87,64 @@ function withAppBlockerAndroid(config, pluginConfig) {
|
|
|
75
87
|
});
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
// Add deep-link intent filter to MainActivity so notification taps route back to the app
|
|
91
|
+
if (scheme) {
|
|
92
|
+
const activities = mainApplication.activity || [];
|
|
93
|
+
const mainActivity = activities.find(
|
|
94
|
+
(a) => a.$?.["android:name"] === ".MainActivity" || a.$?.["android:name"]?.endsWith(".MainActivity")
|
|
95
|
+
);
|
|
96
|
+
if (mainActivity) {
|
|
97
|
+
if (!mainActivity["intent-filter"]) mainActivity["intent-filter"] = [];
|
|
98
|
+
const alreadyHasScheme = mainActivity["intent-filter"].some((f) =>
|
|
99
|
+
(f.data || []).some((d) => d.$?.["android:scheme"] === scheme)
|
|
100
|
+
);
|
|
101
|
+
if (!alreadyHasScheme) {
|
|
102
|
+
mainActivity["intent-filter"].push({
|
|
103
|
+
action: [{ $: { "android:name": "android.intent.action.VIEW" } }],
|
|
104
|
+
category: [
|
|
105
|
+
{ $: { "android:name": "android.intent.category.DEFAULT" } },
|
|
106
|
+
{ $: { "android:name": "android.intent.category.BROWSABLE" } },
|
|
107
|
+
],
|
|
108
|
+
data: [{ $: { "android:scheme": scheme } }],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
return config;
|
|
79
115
|
});
|
|
116
|
+
|
|
117
|
+
// Write scheme to strings.xml so AppBlockerService can read it at runtime
|
|
118
|
+
if (scheme) {
|
|
119
|
+
config = withDangerousMod(config, [
|
|
120
|
+
"android",
|
|
121
|
+
(config) => {
|
|
122
|
+
const platformRoot = config.modRequest.platformProjectRoot;
|
|
123
|
+
const valuesDir = path.join(platformRoot, "app", "src", "main", "res", "values");
|
|
124
|
+
const stringsPath = path.join(valuesDir, "strings.xml");
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(valuesDir)) {
|
|
127
|
+
fs.mkdirSync(valuesDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let xml = fs.existsSync(stringsPath)
|
|
131
|
+
? fs.readFileSync(stringsPath, "utf-8")
|
|
132
|
+
: '<?xml version="1.0" encoding="utf-8"?>\n<resources>\n</resources>';
|
|
133
|
+
|
|
134
|
+
const tag = `<string name="expo_app_blocker_scheme">${scheme}</string>`;
|
|
135
|
+
if (!xml.includes('name="expo_app_blocker_scheme"')) {
|
|
136
|
+
xml = xml.replace("</resources>", ` ${tag}\n</resources>`);
|
|
137
|
+
} else {
|
|
138
|
+
xml = xml.replace(/<string name="expo_app_blocker_scheme">.*?<\/string>/, tag);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fs.writeFileSync(stringsPath, xml);
|
|
142
|
+
return config;
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return config;
|
|
80
148
|
}
|
|
81
149
|
|
|
82
150
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -141,6 +141,15 @@ export interface ShieldConfig {
|
|
|
141
141
|
icon?: string;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
export interface AndroidConfig {
|
|
145
|
+
/** Text shown on the blocking overlay. Use {appName} as placeholder. Default: "{appName} is blocked." */
|
|
146
|
+
overlayText?: string;
|
|
147
|
+
/** Notification title when app is blocked. Use {appName} as placeholder. Default: "App Blocked" */
|
|
148
|
+
notificationTitle?: string;
|
|
149
|
+
/** Notification text when app is blocked. Use {appName} as placeholder. */
|
|
150
|
+
notificationText?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
144
153
|
export interface PluginConfig {
|
|
145
154
|
ios?: {
|
|
146
155
|
/** App Group identifier for shared data between app and extensions. Required. */
|
|
@@ -148,12 +157,12 @@ export interface PluginConfig {
|
|
|
148
157
|
/** Shield overlay customization */
|
|
149
158
|
shield?: ShieldConfig;
|
|
150
159
|
};
|
|
151
|
-
android?: {
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
android?: AndroidConfig & {
|
|
161
|
+
/**
|
|
162
|
+
* URL scheme used for deep-linking back into your app when a blocked app is detected.
|
|
163
|
+
* Defaults to your app's `scheme` from app.json, or the package name with dots replaced by hyphens.
|
|
164
|
+
* Must match the scheme registered in your AndroidManifest intent-filter.
|
|
165
|
+
*/
|
|
166
|
+
scheme?: string;
|
|
158
167
|
};
|
|
159
168
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
AndroidPermissions,
|
|
12
12
|
IOSPermissions,
|
|
13
13
|
AndroidBlockableApp,
|
|
14
|
+
AndroidConfig,
|
|
14
15
|
IOSBlockedItem,
|
|
15
16
|
IOSBlockConfiguration,
|
|
16
17
|
TemporaryUnlockResult,
|
|
@@ -30,6 +31,7 @@ export type {
|
|
|
30
31
|
TemporaryUnlockResult,
|
|
31
32
|
RelockResult,
|
|
32
33
|
ShieldConfig,
|
|
34
|
+
AndroidConfig,
|
|
33
35
|
PluginConfig,
|
|
34
36
|
FamilyActivityPickerSelectionEvent,
|
|
35
37
|
FamilyActivityPickerViewProps,
|
|
@@ -113,6 +115,11 @@ export function getBlockedApps(): string[] {
|
|
|
113
115
|
return NativeModule.getBlockedApps();
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
export function configureAndroid(config: AndroidConfig): void {
|
|
119
|
+
if (Platform.OS !== "android") return;
|
|
120
|
+
NativeModule.setAndroidConfig(config);
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
export function startMonitoring(): void {
|
|
117
124
|
if (Platform.OS !== "android") return;
|
|
118
125
|
NativeModule.startMonitoring();
|