expo-app-blocker 0.1.0

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.
@@ -0,0 +1,116 @@
1
+ package expo.modules.appblocker
2
+
3
+ import android.Manifest
4
+ import android.app.AppOpsManager
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.pm.ApplicationInfo
8
+ import android.content.pm.PackageManager
9
+ import android.net.Uri
10
+ import android.os.Build
11
+ import android.os.Process
12
+ import android.provider.Settings
13
+ import android.util.Log
14
+ import androidx.core.app.ActivityCompat
15
+ import androidx.core.content.ContextCompat
16
+ import expo.modules.kotlin.exception.Exceptions
17
+ import expo.modules.kotlin.modules.Module
18
+ import expo.modules.kotlin.modules.ModuleDefinition
19
+
20
+ private const val TAG = "ExpoAppBlocker"
21
+ private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 9003
22
+
23
+ class ExpoAppBlockerModule : Module() {
24
+ private val context: Context
25
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
26
+
27
+ override fun definition() = ModuleDefinition {
28
+ Name("ExpoAppBlocker")
29
+
30
+ OnCreate {
31
+ requestNotificationPermissionIfNeeded()
32
+ AppBlockerService.start(context)
33
+ Log.d(TAG, "Module OnCreate: started AppBlockerService")
34
+ }
35
+
36
+ AsyncFunction("checkOverlayPermission") {
37
+ Settings.canDrawOverlays(context)
38
+ }
39
+
40
+ AsyncFunction("checkUsageStatsPermission") {
41
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
42
+ val mode = appOps.unsafeCheckOpNoThrow(
43
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
44
+ Process.myUid(),
45
+ context.packageName
46
+ )
47
+ mode == AppOpsManager.MODE_ALLOWED
48
+ }
49
+
50
+ Function("openOverlaySettings") {
51
+ val intent = Intent(
52
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
53
+ Uri.parse("package:${context.packageName}")
54
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
55
+ context.startActivity(intent)
56
+ }
57
+
58
+ Function("openUsageStatsSettings") {
59
+ val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
60
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
61
+ context.startActivity(intent)
62
+ }
63
+
64
+ Function("setBlockedApps") { packageNames: List<String> ->
65
+ AppBlockerPrefs.setBlockedPackages(context, packageNames)
66
+ Log.d(TAG, "setBlockedApps: $packageNames")
67
+ }
68
+
69
+ Function("getBlockedApps") {
70
+ AppBlockerPrefs.getBlockedPackages(context).toList()
71
+ }
72
+
73
+ Function("startMonitoring") {
74
+ AppBlockerService.start(context)
75
+ Log.d(TAG, "startMonitoring called")
76
+ }
77
+
78
+ Function("stopMonitoring") {
79
+ AppBlockerService.stop(context)
80
+ Log.d(TAG, "stopMonitoring called")
81
+ }
82
+
83
+ AsyncFunction("getInstalledApps") {
84
+ val pm = context.packageManager
85
+ val intent = Intent(Intent.ACTION_MAIN).apply {
86
+ addCategory(Intent.CATEGORY_LAUNCHER)
87
+ }
88
+ val apps = pm.queryIntentActivities(intent, 0)
89
+ apps.mapNotNull { resolveInfo ->
90
+ val appInfo = resolveInfo.activityInfo.applicationInfo
91
+ if (appInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) return@mapNotNull null
92
+ if (appInfo.packageName == context.packageName) return@mapNotNull null
93
+
94
+ mapOf(
95
+ "packageName" to appInfo.packageName,
96
+ "name" to (pm.getApplicationLabel(appInfo)?.toString() ?: appInfo.packageName)
97
+ )
98
+ }.sortedBy { it["name"]?.lowercase() }
99
+ }
100
+ }
101
+
102
+ private fun requestNotificationPermissionIfNeeded() {
103
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
104
+ val granted = ContextCompat.checkSelfPermission(
105
+ context,
106
+ Manifest.permission.POST_NOTIFICATIONS
107
+ ) == PackageManager.PERMISSION_GRANTED
108
+ if (granted) return
109
+ val activity = appContext.currentActivity ?: return
110
+ ActivityCompat.requestPermissions(
111
+ activity,
112
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
113
+ NOTIFICATION_PERMISSION_REQUEST_CODE
114
+ )
115
+ }
116
+ }
@@ -0,0 +1,145 @@
1
+ package expo.modules.appblocker
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.graphics.Color
6
+ import android.graphics.PixelFormat
7
+ import android.net.Uri
8
+ import android.os.Build
9
+ import android.util.Log
10
+ import android.view.Gravity
11
+ import android.view.View
12
+ import android.view.WindowManager
13
+ import android.widget.TextView
14
+
15
+ class OverlayManager(private val context: Context) {
16
+ private val windowManager: WindowManager =
17
+ context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
18
+
19
+ private var overlayView: View? = null
20
+
21
+ fun show(blockedPackageName: String? = null) {
22
+ if (overlayView != null) {
23
+ Log.d(TAG, "Overlay already visible")
24
+ if (blockedPackageName != null) {
25
+ navigateToApp(blockedPackageName)
26
+ } else {
27
+ bringAppToFront()
28
+ }
29
+ return
30
+ }
31
+
32
+ val view = buildOverlayView()
33
+ try {
34
+ windowManager.addView(view, buildLayoutParams())
35
+ overlayView = view
36
+ Log.d(TAG, "Overlay shown")
37
+ } catch (e: Exception) {
38
+ Log.e(TAG, "Failed to add overlay view", e)
39
+ return
40
+ }
41
+
42
+ if (blockedPackageName != null) {
43
+ navigateToApp(blockedPackageName)
44
+ } else {
45
+ bringAppToFront()
46
+ }
47
+ }
48
+
49
+ fun hide() {
50
+ val view = overlayView ?: return
51
+ try {
52
+ windowManager.removeView(view)
53
+ Log.d(TAG, "Overlay hidden")
54
+ } catch (e: Exception) {
55
+ Log.e(TAG, "Failed to remove overlay view", e)
56
+ }
57
+ overlayView = null
58
+ }
59
+
60
+ 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
+ }
68
+
69
+ // Use the app's own scheme for deep linking
70
+ val scheme = getAppScheme()
71
+ val deepLinkIntent = Intent(
72
+ Intent.ACTION_VIEW,
73
+ Uri.parse("${scheme}://blocked?app=${Uri.encode(appName)}&package=${Uri.encode(blockedPackageName)}")
74
+ ).apply {
75
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
76
+ }
77
+
78
+ try {
79
+ context.startActivity(deepLinkIntent)
80
+ } catch (e: Exception) {
81
+ Log.e(TAG, "Failed to deep link", e)
82
+ bringAppToFront()
83
+ }
84
+ }
85
+
86
+ private fun bringAppToFront() {
87
+ val launchIntent = context.packageManager
88
+ .getLaunchIntentForPackage(context.packageName)
89
+ ?.apply {
90
+ addFlags(
91
+ Intent.FLAG_ACTIVITY_NEW_TASK or
92
+ Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
93
+ )
94
+ }
95
+
96
+ if (launchIntent == null) {
97
+ Log.w(TAG, "No launch intent for package ${context.packageName}")
98
+ return
99
+ }
100
+
101
+ context.startActivity(launchIntent)
102
+ }
103
+
104
+ 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
+ }
112
+ }
113
+
114
+ private fun buildOverlayView(): View {
115
+ val textView = TextView(context).apply {
116
+ text = ""
117
+ setBackgroundColor(Color.parseColor("#F0000000"))
118
+ gravity = Gravity.CENTER
119
+ }
120
+ return textView
121
+ }
122
+
123
+ private fun buildLayoutParams(): WindowManager.LayoutParams {
124
+ @Suppress("DEPRECATION")
125
+ val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
126
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
127
+ } else {
128
+ WindowManager.LayoutParams.TYPE_PHONE
129
+ }
130
+
131
+ return WindowManager.LayoutParams(
132
+ WindowManager.LayoutParams.MATCH_PARENT,
133
+ WindowManager.LayoutParams.MATCH_PARENT,
134
+ type,
135
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
136
+ PixelFormat.TRANSLUCENT
137
+ ).apply {
138
+ gravity = Gravity.TOP or Gravity.START
139
+ }
140
+ }
141
+
142
+ companion object {
143
+ private const val TAG = "ExpoAppBlocker"
144
+ }
145
+ }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./plugin/src/index");
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["android", "apple"],
3
+ "android": {
4
+ "modules": ["expo.modules.appblocker.ExpoAppBlockerModule"]
5
+ },
6
+ "apple": {
7
+ "modules": ["ExpoAppBlockerModule"]
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "expo-app-blocker",
3
+ "version": "0.1.0",
4
+ "description": "Expo module for cross-platform app blocking. Android: UsageStatsManager + Overlay. iOS: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity).",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "clean": "rm -rf build"
10
+ },
11
+ "keywords": [
12
+ "expo",
13
+ "react-native",
14
+ "app-blocker",
15
+ "screen-time",
16
+ "family-controls",
17
+ "digital-wellbeing",
18
+ "parental-controls",
19
+ "usage-stats"
20
+ ],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "homepage": "https://github.com/eylonshm/expo-app-blocker",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/eylonshm/expo-app-blocker.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/eylonshm/expo-app-blocker/issues"
30
+ },
31
+ "dependencies": {
32
+ "@bacons/apple-targets": "^4.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "expo": ">=54.0.0",
36
+ "expo-modules-core": ">=3.0.0",
37
+ "react": ">=18.0.0",
38
+ "react-native": ">=0.74.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "~5.9.2",
42
+ "expo-modules-core": "^3.0.0"
43
+ }
44
+ }
@@ -0,0 +1,196 @@
1
+ // Resolve from the app's node_modules, not the package's
2
+ const resolve = (mod) => {
3
+ try { return require(mod); } catch {}
4
+ try { return require(require.resolve(mod, { paths: [process.cwd()] })); } catch {}
5
+ throw new Error(`Cannot find module '${mod}'. Make sure 'expo' is installed.`);
6
+ };
7
+ const {
8
+ withAndroidManifest,
9
+ withEntitlementsPlist,
10
+ withInfoPlist,
11
+ withDangerousMod,
12
+ createRunOncePlugin,
13
+ } = resolve("expo/config-plugins");
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ // ──────────────────────────────────────────────────────────────────────────────
18
+ // Android
19
+ // ──────────────────────────────────────────────────────────────────────────────
20
+
21
+ function withAppBlockerAndroid(config, pluginConfig) {
22
+ return withAndroidManifest(config, (config) => {
23
+ const manifest = config.modResults;
24
+ const mainApplication = manifest.manifest.application?.[0];
25
+ if (!mainApplication) return config;
26
+
27
+ if (!manifest.manifest["uses-permission"]) {
28
+ manifest.manifest["uses-permission"] = [];
29
+ }
30
+ const permissions = manifest.manifest["uses-permission"];
31
+
32
+ const requiredPermissions = [
33
+ "android.permission.SYSTEM_ALERT_WINDOW",
34
+ "android.permission.FOREGROUND_SERVICE",
35
+ "android.permission.FOREGROUND_SERVICE_SPECIAL_USE",
36
+ "android.permission.RECEIVE_BOOT_COMPLETED",
37
+ "android.permission.POST_NOTIFICATIONS",
38
+ ];
39
+
40
+ // PACKAGE_USAGE_STATS needs tools:ignore
41
+ if (!permissions.some((p) => p.$?.["android:name"] === "android.permission.PACKAGE_USAGE_STATS")) {
42
+ permissions.push({
43
+ $: { "android:name": "android.permission.PACKAGE_USAGE_STATS", "tools:ignore": "ProtectedPermissions" },
44
+ });
45
+ }
46
+
47
+ for (const perm of requiredPermissions) {
48
+ if (!permissions.some((p) => p.$?.["android:name"] === perm)) {
49
+ permissions.push({ $: { "android:name": perm } });
50
+ }
51
+ }
52
+
53
+ if (!manifest.manifest.$) manifest.manifest.$ = {};
54
+ manifest.manifest.$["xmlns:tools"] = "http://schemas.android.com/tools";
55
+
56
+ // Add AppBlockerService
57
+ if (!mainApplication.service) mainApplication.service = [];
58
+ if (!mainApplication.service.some((s) => s.$?.["android:name"] === "expo.modules.appblocker.AppBlockerService")) {
59
+ mainApplication.service.push({
60
+ $: {
61
+ "android:name": "expo.modules.appblocker.AppBlockerService",
62
+ "android:enabled": "true",
63
+ "android:exported": "false",
64
+ "android:foregroundServiceType": "specialUse",
65
+ },
66
+ });
67
+ }
68
+
69
+ // Add BootReceiver
70
+ if (!mainApplication.receiver) mainApplication.receiver = [];
71
+ if (!mainApplication.receiver.some((r) => r.$?.["android:name"] === "expo.modules.appblocker.BootReceiver")) {
72
+ mainApplication.receiver.push({
73
+ $: { "android:name": "expo.modules.appblocker.BootReceiver", "android:enabled": "true", "android:exported": "true" },
74
+ "intent-filter": [{ action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }] }],
75
+ });
76
+ }
77
+
78
+ return config;
79
+ });
80
+ }
81
+
82
+ // ──────────────────────────────────────────────────────────────────────────────
83
+ // iOS
84
+ // ──────────────────────────────────────────────────────────────────────────────
85
+
86
+ function withAppBlockerIOS(config, pluginConfig) {
87
+ const appGroup = pluginConfig?.ios?.appGroup || "group.expo.app-blocker";
88
+
89
+ config = withEntitlementsPlist(config, (config) => {
90
+ config.modResults["com.apple.developer.family-controls"] = true;
91
+ config.modResults["com.apple.security.application-groups"] = [appGroup];
92
+ return config;
93
+ });
94
+
95
+ config = withInfoPlist(config, (config) => {
96
+ config.modResults.BGTaskSchedulerPermittedIdentifiers = [
97
+ `${config.ios?.bundleIdentifier || "expo.app-blocker"}.relock`,
98
+ ];
99
+ return config;
100
+ });
101
+
102
+ config = withDangerousMod(config, [
103
+ "ios",
104
+ (config) => {
105
+ const platformRoot = config.modRequest.platformProjectRoot;
106
+ const projectName = config.modRequest.projectName;
107
+
108
+ // Patch Podfile deployment target (pod itself is auto-linked via expo-module.config.json)
109
+ const podfilePath = path.join(platformRoot, "Podfile");
110
+ if (fs.existsSync(podfilePath)) {
111
+ let podfile = fs.readFileSync(podfilePath, "utf-8");
112
+
113
+ podfile = podfile.replace(
114
+ /platform :ios, podfile_properties\['ios\.deploymentTarget'\] \|\| '[\d.]+'/,
115
+ "platform :ios, podfile_properties['ios.deploymentTarget'] || '16.0'"
116
+ );
117
+
118
+ fs.writeFileSync(podfilePath, podfile);
119
+ }
120
+
121
+ // Patch deployment target in pbxproj
122
+ const pbxprojPath = path.join(platformRoot, `${projectName}.xcodeproj`, "project.pbxproj");
123
+ if (fs.existsSync(pbxprojPath)) {
124
+ let pbxproj = fs.readFileSync(pbxprojPath, "utf-8");
125
+ pbxproj = pbxproj.replace(/IPHONEOS_DEPLOYMENT_TARGET = \d+\.\d+;/g, "IPHONEOS_DEPLOYMENT_TARGET = 16.0;");
126
+ fs.writeFileSync(pbxprojPath, pbxproj);
127
+ }
128
+
129
+ // Patch AppDelegate with localhost fallback
130
+ const appDelegatePath = path.join(platformRoot, projectName, "AppDelegate.swift");
131
+ if (fs.existsSync(appDelegatePath)) {
132
+ let appDelegate = fs.readFileSync(appDelegatePath, "utf-8");
133
+ const original = 'return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")';
134
+ const replacement = `if let url = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") {
135
+ return url
136
+ }
137
+ return URL(string: "http://localhost:8081/.expo/.virtual-metro-entry.bundle?platform=ios&dev=true&lazy=true&minify=false")`;
138
+ if (appDelegate.includes(original)) {
139
+ appDelegate = appDelegate.replace(original, replacement);
140
+ fs.writeFileSync(appDelegatePath, appDelegate);
141
+ }
142
+ }
143
+
144
+ // Inject app group into extension Swift files
145
+ const targetsDir = path.join(path.dirname(platformRoot), "targets");
146
+ if (fs.existsSync(targetsDir)) {
147
+ const dirs = fs.readdirSync(targetsDir);
148
+ for (const dir of dirs) {
149
+ const dirPath = path.join(targetsDir, dir);
150
+ if (!fs.statSync(dirPath).isDirectory()) continue;
151
+ const files = fs.readdirSync(dirPath);
152
+ for (const file of files) {
153
+ if (!file.endsWith(".swift")) continue;
154
+ const filePath = path.join(dirPath, file);
155
+ let content = fs.readFileSync(filePath, "utf-8");
156
+ if (content.includes("APP_GROUP_PLACEHOLDER")) {
157
+ content = content.replace(/APP_GROUP_PLACEHOLDER/g, appGroup);
158
+ fs.writeFileSync(filePath, content);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // Copy shield icon to ShieldConfiguration target assets
165
+ const shieldIcon = pluginConfig?.ios?.shield?.icon;
166
+ if (shieldIcon) {
167
+ const projectRoot = path.dirname(platformRoot);
168
+ const iconSrc = path.resolve(projectRoot, shieldIcon);
169
+ const assetsDir = path.join(targetsDir, "ShieldConfiguration", "assets");
170
+ if (fs.existsSync(iconSrc)) {
171
+ if (!fs.existsSync(assetsDir)) {
172
+ fs.mkdirSync(assetsDir, { recursive: true });
173
+ }
174
+ const iconDest = path.join(assetsDir, "shield-icon.png");
175
+ fs.copyFileSync(iconSrc, iconDest);
176
+ }
177
+ }
178
+
179
+ return config;
180
+ },
181
+ ]);
182
+
183
+ return config;
184
+ }
185
+
186
+ // ──────────────────────────────────────────────────────────────────────────────
187
+ // Combined
188
+ // ──────────────────────────────────────────────────────────────────────────────
189
+
190
+ function withAppBlocker(config, pluginConfig = {}) {
191
+ config = withAppBlockerAndroid(config, pluginConfig);
192
+ config = withAppBlockerIOS(config, pluginConfig);
193
+ return config;
194
+ }
195
+
196
+ module.exports = createRunOncePlugin(withAppBlocker, "expo-app-blocker", "0.1.0");
@@ -0,0 +1,102 @@
1
+ // ──────────────────────────────────────────────────────────────────────────────
2
+ // Permission types
3
+ // ──────────────────────────────────────────────────────────────────────────────
4
+
5
+ export interface PermissionStatus {
6
+ allGranted: boolean;
7
+ details: AndroidPermissions | IOSPermissions;
8
+ }
9
+
10
+ export interface AndroidPermissions {
11
+ platform: "android";
12
+ overlay: boolean;
13
+ usageStats: boolean;
14
+ }
15
+
16
+ export interface IOSPermissions {
17
+ platform: "ios";
18
+ authorized: boolean;
19
+ status: "notDetermined" | "denied" | "approved";
20
+ }
21
+
22
+ // ──────────────────────────────────────────────────────────────────────────────
23
+ // App selection types
24
+ // ──────────────────────────────────────────────────────────────────────────────
25
+
26
+ export interface AndroidBlockableApp {
27
+ packageName: string;
28
+ name: string;
29
+ }
30
+
31
+ export interface IOSBlockedItem {
32
+ type: "app" | "category";
33
+ token: string;
34
+ bundleIdentifier?: string;
35
+ displayName?: string;
36
+ categoryName?: string;
37
+ iconBase64?: string;
38
+ }
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────────
41
+ // iOS-specific types
42
+ // ──────────────────────────────────────────────────────────────────────────────
43
+
44
+ export interface IOSBlockConfiguration {
45
+ blockedItems: IOSBlockedItem[];
46
+ isActive: boolean;
47
+ schedule?: {
48
+ intervalStart: number;
49
+ intervalEnd: number;
50
+ repeats: boolean;
51
+ warningTime: number;
52
+ };
53
+ }
54
+
55
+ export interface TemporaryUnlockResult {
56
+ unlocked: boolean;
57
+ expiresAt: number;
58
+ }
59
+
60
+ export interface RelockResult {
61
+ locked: boolean;
62
+ }
63
+
64
+ // ──────────────────────────────────────────────────────────────────────────────
65
+ // Plugin configuration types
66
+ // ──────────────────────────────────────────────────────────────────────────────
67
+
68
+ export interface ShieldConfig {
69
+ /** Title shown on the shield. Use {appName} as placeholder. Default: "Hold on!" */
70
+ title?: string;
71
+ /** Subtitle shown on the shield. Use {appName} as placeholder. */
72
+ subtitle?: string;
73
+ /** Primary button label. Default: "Earn Free Time" */
74
+ primaryButtonLabel?: string;
75
+ /** Secondary button label. Set to null to hide. Default: "Not now" */
76
+ secondaryButtonLabel?: string | null;
77
+ /** Primary button background color (hex). Default: "#7cb518" */
78
+ primaryButtonColor?: string;
79
+ /** Background color (hex). Default: null (uses blur) */
80
+ backgroundColor?: string | null;
81
+ /** Background blur style. Default: "systemThickMaterial" */
82
+ backgroundBlurStyle?: string;
83
+ /** Path to shield icon image. Optional. */
84
+ icon?: string;
85
+ }
86
+
87
+ export interface PluginConfig {
88
+ ios?: {
89
+ /** App Group identifier for shared data between app and extensions. Required. */
90
+ appGroup: string;
91
+ /** Shield overlay customization */
92
+ shield?: ShieldConfig;
93
+ };
94
+ android?: {
95
+ /** Notification title when app is blocked. Use {appName} as placeholder. */
96
+ notificationTitle?: string;
97
+ /** Notification text when app is blocked. Use {appName} as placeholder. */
98
+ notificationText?: string;
99
+ /** Text shown on the blocking overlay. Default: "" (empty) */
100
+ overlayText?: string;
101
+ };
102
+ }