expo-app-blocker 0.1.24 → 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/README.md CHANGED
@@ -13,7 +13,9 @@ https://github.com/user-attachments/assets/37f34797-6b92-40d5-911a-90c40e9ffaaa
13
13
  > **iOS requires Apple Developer Portal setup before building.** See [Prerequisites](#prerequisites) for details.
14
14
 
15
15
  > [!IMPORTANT]
16
- > **Submit your Family Controls distribution approval request now.** App Store distribution requires Apple approval per bundle ID — it can take days to weeks and you can't ship without it. [Request here](https://developer.apple.com/contact/request/family-controls-distribution) (you'll need to submit once per bundle ID — 4 total). You can develop and test locally without waiting.
16
+ > **Submit your Family Controls distribution approval request now.** App Store distribution requires Apple approval per bundle ID — it can take days to weeks and you can't ship without it. [Request here](https://developer.apple.com/contact/request/family-controls-distribution) (you'll need to submit once per bundle ID — 4 total).
17
+ >
18
+ > **While waiting for approval**, use the **Family Controls (Development)** capability in Xcode instead of the standard "Family Controls" — it's marked "Development only" in Xcode's Signing & Capabilities tab and works without Apple's approval. Development builds with this entitlement are fully functional on device but cannot be submitted to TestFlight or the App Store.
17
19
 
18
20
  <details>
19
21
  <summary><strong>Table of Contents</strong></summary>
@@ -188,8 +190,9 @@ npx expo run:android # Android works on emulator
188
190
 
189
191
  3. Assign the App Group to all 4 App IDs
190
192
 
191
- 4. Request **Family Controls** capability approval (required for App Store/TestFlight — works in local dev builds without it)
193
+ 4. Request **Family Controls** capability approval (required for App Store/TestFlight distribution)
192
194
  - Submit the form **once per bundle ID** (4 total): [developer.apple.com/contact/request/family-controls-distribution](https://developer.apple.com/contact/request/family-controls-distribution)
195
+ - **While waiting for approval**: use **Family Controls (Development)** in Xcode's Signing & Capabilities tab — fully functional in dev builds, just not distributable
193
196
  - Incomplete capability setup causes cryptic provisioning errors — make sure all 4 App IDs have Family Controls + App Groups enabled
194
197
 
195
198
  ### Android
@@ -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.24",
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();