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.
- package/LICENSE +21 -0
- package/README.md +354 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerPrefs.kt +21 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +176 -0
- package/android/src/main/java/expo/modules/appblocker/BootReceiver.kt +19 -0
- package/android/src/main/java/expo/modules/appblocker/ExpoAppBlockerModule.kt +116 -0
- package/android/src/main/java/expo/modules/appblocker/OverlayManager.kt +145 -0
- package/app.plugin.js +1 -0
- package/expo-module.config.json +9 -0
- package/package.json +44 -0
- package/plugin/src/index.js +196 -0
- package/src/ExpoAppBlocker.types.ts +102 -0
- package/src/index.ts +225 -0
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +150 -0
- package/targets/DeviceActivityMonitor/expo-target.config.js +17 -0
- package/targets/ShieldAction/ShieldActionExtension.swift +86 -0
- package/targets/ShieldAction/expo-target.config.js +17 -0
- package/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +152 -0
- package/targets/ShieldConfiguration/expo-target.config.js +17 -0
- package/tsconfig.json +16 -0
|
@@ -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");
|
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
|
+
}
|