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.
@@ -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 scheme = this.packageName.replace(".", "-")
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, 0, deepLinkIntent,
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("App Blocked")
83
- .setContentText("$appName is blocked. Tap to earn free time!")
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 view = buildOverlayView()
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 = try {
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
- return try {
106
- val pm = context.packageManager
107
- val intent = pm.getLaunchIntentForPackage(context.packageName)
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 textView = TextView(context).apply {
116
- text = ""
117
- setBackgroundColor(Color.parseColor("#F0000000"))
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.25",
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",
@@ -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
- return withAndroidManifest(config, (config) => {
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
- /** Notification title when app is blocked. Use {appName} as placeholder. */
153
- notificationTitle?: string;
154
- /** Notification text when app is blocked. Use {appName} as placeholder. */
155
- notificationText?: string;
156
- /** Text shown on the blocking overlay. Default: "" (empty) */
157
- overlayText?: string;
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();