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
package/src/index.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireNativeModule,
|
|
3
|
+
requireNativeViewManager,
|
|
4
|
+
EventEmitter,
|
|
5
|
+
} from "expo-modules-core";
|
|
6
|
+
import { Platform } from "react-native";
|
|
7
|
+
import React from "react";
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
PermissionStatus,
|
|
11
|
+
AndroidPermissions,
|
|
12
|
+
IOSPermissions,
|
|
13
|
+
AndroidBlockableApp,
|
|
14
|
+
IOSBlockedItem,
|
|
15
|
+
IOSBlockConfiguration,
|
|
16
|
+
TemporaryUnlockResult,
|
|
17
|
+
RelockResult,
|
|
18
|
+
} from "./ExpoAppBlocker.types";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
PermissionStatus,
|
|
22
|
+
AndroidPermissions,
|
|
23
|
+
IOSPermissions,
|
|
24
|
+
AndroidBlockableApp,
|
|
25
|
+
IOSBlockedItem,
|
|
26
|
+
IOSBlockConfiguration,
|
|
27
|
+
TemporaryUnlockResult,
|
|
28
|
+
RelockResult,
|
|
29
|
+
ShieldConfig,
|
|
30
|
+
PluginConfig,
|
|
31
|
+
} from "./ExpoAppBlocker.types";
|
|
32
|
+
|
|
33
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Native module bridge
|
|
35
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const NativeModule = requireNativeModule("ExpoAppBlocker");
|
|
38
|
+
|
|
39
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// Permissions
|
|
41
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export async function getPermissionStatus(): Promise<PermissionStatus> {
|
|
44
|
+
if (Platform.OS === "android") {
|
|
45
|
+
const overlay = await NativeModule.checkOverlayPermission();
|
|
46
|
+
const usageStats = await NativeModule.checkUsageStatsPermission();
|
|
47
|
+
const details: AndroidPermissions = { platform: "android", overlay, usageStats };
|
|
48
|
+
return { allGranted: overlay && usageStats, details };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Platform.OS === "ios") {
|
|
52
|
+
const result = NativeModule.getAuthorizationStatus();
|
|
53
|
+
const details: IOSPermissions = {
|
|
54
|
+
platform: "ios",
|
|
55
|
+
authorized: result.authorized,
|
|
56
|
+
status: result.status,
|
|
57
|
+
};
|
|
58
|
+
return { allGranted: result.authorized, details };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error("Unsupported platform");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function requestPermissions(): Promise<PermissionStatus> {
|
|
65
|
+
if (Platform.OS === "ios") {
|
|
66
|
+
const result = await NativeModule.requestAuthorization();
|
|
67
|
+
const details: IOSPermissions = {
|
|
68
|
+
platform: "ios",
|
|
69
|
+
authorized: result.authorized,
|
|
70
|
+
status: result.status,
|
|
71
|
+
};
|
|
72
|
+
return { allGranted: result.authorized, details };
|
|
73
|
+
}
|
|
74
|
+
return getPermissionStatus();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// Android-specific: permission settings
|
|
79
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function openOverlaySettings(): void {
|
|
82
|
+
if (Platform.OS !== "android") return;
|
|
83
|
+
NativeModule.openOverlaySettings();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function openUsageStatsSettings(): void {
|
|
87
|
+
if (Platform.OS !== "android") return;
|
|
88
|
+
NativeModule.openUsageStatsSettings();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
// Android-specific: app list and blocking
|
|
93
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export async function getInstalledApps(): Promise<AndroidBlockableApp[]> {
|
|
96
|
+
if (Platform.OS !== "android") return [];
|
|
97
|
+
return NativeModule.getInstalledApps();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function setBlockedApps(packageNames: string[]): void {
|
|
101
|
+
if (Platform.OS !== "android") return;
|
|
102
|
+
NativeModule.setBlockedApps(packageNames);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getBlockedApps(): string[] {
|
|
106
|
+
if (Platform.OS !== "android") return [];
|
|
107
|
+
return NativeModule.getBlockedApps();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function startMonitoring(): void {
|
|
111
|
+
if (Platform.OS !== "android") return;
|
|
112
|
+
NativeModule.startMonitoring();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function stopMonitoring(): void {
|
|
116
|
+
if (Platform.OS !== "android") return;
|
|
117
|
+
NativeModule.stopMonitoring();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// iOS-specific: Family Controls
|
|
122
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export async function presentFamilyActivityPicker(): Promise<IOSBlockedItem[]> {
|
|
125
|
+
if (Platform.OS !== "ios") {
|
|
126
|
+
throw new Error("Family Activity Picker is only available on iOS");
|
|
127
|
+
}
|
|
128
|
+
return NativeModule.presentFamilyActivityPicker();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function setBlockConfiguration(config: IOSBlockConfiguration): Promise<void> {
|
|
132
|
+
if (Platform.OS !== "ios") {
|
|
133
|
+
throw new Error("Block configuration is only available on iOS");
|
|
134
|
+
}
|
|
135
|
+
return NativeModule.setBlockConfiguration(config);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getBlockConfiguration(): IOSBlockConfiguration | null {
|
|
139
|
+
if (Platform.OS !== "ios") return null;
|
|
140
|
+
return NativeModule.getBlockConfiguration();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function clearAllBlocks(): void {
|
|
144
|
+
if (Platform.OS !== "ios") return;
|
|
145
|
+
NativeModule.clearAllBlocks();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isAppBlocked(bundleIdentifier: string): boolean {
|
|
149
|
+
if (Platform.OS !== "ios") return false;
|
|
150
|
+
return NativeModule.isAppBlocked(bundleIdentifier);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
// iOS-specific: Temporary unlock
|
|
155
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export async function temporaryUnlock(durationMinutes: number = 15): Promise<TemporaryUnlockResult> {
|
|
158
|
+
if (Platform.OS !== "ios") {
|
|
159
|
+
throw new Error("Temporary unlock is only available on iOS");
|
|
160
|
+
}
|
|
161
|
+
return NativeModule.temporaryUnlock(durationMinutes);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isTemporarilyUnlocked(): boolean {
|
|
165
|
+
if (Platform.OS !== "ios") return false;
|
|
166
|
+
return NativeModule.isTemporarilyUnlocked();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getRemainingUnlockTime(): number {
|
|
170
|
+
if (Platform.OS !== "ios") return 0;
|
|
171
|
+
return NativeModule.getRemainingUnlockTime();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function relockApps(): Promise<RelockResult> {
|
|
175
|
+
if (Platform.OS !== "ios") {
|
|
176
|
+
throw new Error("Relock is only available on iOS");
|
|
177
|
+
}
|
|
178
|
+
return NativeModule.relockApps();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function checkAndClearPendingUnlock(): boolean {
|
|
182
|
+
if (Platform.OS !== "ios") return false;
|
|
183
|
+
return NativeModule.checkAndClearPendingUnlock();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function addPendingUnlockListener(
|
|
187
|
+
handler: () => void
|
|
188
|
+
): { remove: () => void } | null {
|
|
189
|
+
if (Platform.OS !== "ios") return null;
|
|
190
|
+
const emitter = new EventEmitter(NativeModule);
|
|
191
|
+
return (emitter as any).addListener("onPendingUnlockRequest", handler);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// iOS Native View: renders blocked app tokens with real names and icons
|
|
196
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
let NativeBlockedAppsView: any = null;
|
|
199
|
+
if (Platform.OS === "ios") {
|
|
200
|
+
try {
|
|
201
|
+
NativeBlockedAppsView = requireNativeViewManager("ExpoAppBlocker");
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function BlockedAppsNativeList({
|
|
206
|
+
items,
|
|
207
|
+
selectionData,
|
|
208
|
+
style,
|
|
209
|
+
}: {
|
|
210
|
+
items: IOSBlockedItem[];
|
|
211
|
+
selectionData?: string;
|
|
212
|
+
style?: any;
|
|
213
|
+
}) {
|
|
214
|
+
if (!NativeBlockedAppsView || Platform.OS !== "ios") return null;
|
|
215
|
+
|
|
216
|
+
const tokens = items
|
|
217
|
+
.filter((item) => (item.type as string) !== "summary")
|
|
218
|
+
.map((item) => ({ token: item.token, type: item.type }));
|
|
219
|
+
|
|
220
|
+
return React.createElement(NativeBlockedAppsView, {
|
|
221
|
+
selectionData: selectionData || "",
|
|
222
|
+
tokens,
|
|
223
|
+
style: [{ minHeight: 50 }, style],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import DeviceActivity
|
|
2
|
+
import ManagedSettings
|
|
3
|
+
import FamilyControls
|
|
4
|
+
import Foundation
|
|
5
|
+
|
|
6
|
+
@available(iOS 15.0, *)
|
|
7
|
+
class AppBlockerDeviceActivityMonitor: DeviceActivityMonitor {
|
|
8
|
+
// CONFIGURE: Replace with your App Group identifier
|
|
9
|
+
private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
|
|
10
|
+
private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
|
|
11
|
+
private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
|
|
12
|
+
|
|
13
|
+
private let store = ManagedSettingsStore()
|
|
14
|
+
private var sharedDefaults: UserDefaults?
|
|
15
|
+
|
|
16
|
+
override init() {
|
|
17
|
+
super.init()
|
|
18
|
+
sharedDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override func intervalDidEnd(for activity: DeviceActivityName) {
|
|
22
|
+
super.intervalDidEnd(for: activity)
|
|
23
|
+
sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
|
|
24
|
+
reapplyBlockConfiguration()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override func intervalDidStart(for activity: DeviceActivityName) {
|
|
28
|
+
super.intervalDidStart(for: activity)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private func reapplyBlockConfiguration() {
|
|
32
|
+
let userDefaults = sharedDefaults ?? UserDefaults.standard
|
|
33
|
+
|
|
34
|
+
guard let configDict = userDefaults.dictionary(forKey: blockConfigStorageKey) else {
|
|
35
|
+
store.shield.applications = nil
|
|
36
|
+
store.shield.applicationCategories = nil
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
guard let blockConfig = parseBlockConfig(configDict) else {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
applyBlocks(blockConfig)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private func parseBlockConfig(_ dict: [String: Any]) -> MonitorBlockConfig? {
|
|
48
|
+
let rawItems: [[String: Any]]
|
|
49
|
+
if let blockedItems = dict["blockedItems"] as? [[String: Any]] {
|
|
50
|
+
rawItems = blockedItems
|
|
51
|
+
} else if let appSelections = dict["appSelections"] as? [[String: Any]] {
|
|
52
|
+
rawItems = appSelections.map { item in
|
|
53
|
+
var normalized = item
|
|
54
|
+
normalized["type"] = "app"
|
|
55
|
+
return normalized
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let items: [MonitorBlockedItemInfo] = rawItems.compactMap { selection -> MonitorBlockedItemInfo? in
|
|
62
|
+
guard let tokenString = selection["token"] as? String else {
|
|
63
|
+
return nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let itemTypeRaw = (selection["type"] as? String ?? "app").lowercased()
|
|
67
|
+
let itemType: MonitorBlockedItemType = itemTypeRaw == "category" ? .category : .app
|
|
68
|
+
|
|
69
|
+
return MonitorBlockedItemInfo(
|
|
70
|
+
type: itemType,
|
|
71
|
+
tokenId: tokenString,
|
|
72
|
+
appToken: itemType == .app ? decodeApplicationToken(from: tokenString) : nil,
|
|
73
|
+
categoryToken: itemType == .category ? decodeCategoryToken(from: tokenString) : nil
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let isActive = dict["isActive"] as? Bool ?? true
|
|
78
|
+
return MonitorBlockConfig(items: items, isActive: isActive)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private func applyBlocks(_ config: MonitorBlockConfig) {
|
|
82
|
+
guard config.isActive else {
|
|
83
|
+
store.shield.applications = nil
|
|
84
|
+
store.shield.applicationCategories = nil
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let validAppTokens = config.items.compactMap { $0.appToken }
|
|
89
|
+
let validCategoryTokens = config.items.compactMap { $0.categoryToken }
|
|
90
|
+
|
|
91
|
+
guard !validAppTokens.isEmpty || !validCategoryTokens.isEmpty else {
|
|
92
|
+
store.shield.applications = nil
|
|
93
|
+
store.shield.applicationCategories = nil
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if !validAppTokens.isEmpty {
|
|
98
|
+
store.shield.applications = Set(validAppTokens)
|
|
99
|
+
} else {
|
|
100
|
+
store.shield.applications = nil
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if !validCategoryTokens.isEmpty {
|
|
104
|
+
store.shield.applicationCategories = .specific(Set(validCategoryTokens))
|
|
105
|
+
} else {
|
|
106
|
+
store.shield.applicationCategories = nil
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func decodeApplicationToken(from encoded: String) -> ApplicationToken? {
|
|
111
|
+
guard let data = Data(base64Encoded: encoded) else {
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
do {
|
|
116
|
+
return try JSONDecoder().decode(ApplicationToken.self, from: data)
|
|
117
|
+
} catch {
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func decodeCategoryToken(from encoded: String) -> ActivityCategoryToken? {
|
|
123
|
+
guard let data = Data(base64Encoded: encoded) else {
|
|
124
|
+
return nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
do {
|
|
128
|
+
return try JSONDecoder().decode(ActivityCategoryToken.self, from: data)
|
|
129
|
+
} catch {
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
enum MonitorBlockedItemType: String {
|
|
136
|
+
case app
|
|
137
|
+
case category
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
struct MonitorBlockedItemInfo {
|
|
141
|
+
let type: MonitorBlockedItemType
|
|
142
|
+
let tokenId: String
|
|
143
|
+
let appToken: ApplicationToken?
|
|
144
|
+
let categoryToken: ActivityCategoryToken?
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
struct MonitorBlockConfig {
|
|
148
|
+
let items: [MonitorBlockedItemInfo]
|
|
149
|
+
let isActive: Bool
|
|
150
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
|
2
|
+
module.exports = (config) => {
|
|
3
|
+
const appGroup = config.ios?.entitlements?.["com.apple.security.application-groups"]?.[0]
|
|
4
|
+
|| "group.expo.app-blocker";
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
type: "device-activity-monitor",
|
|
8
|
+
name: "DeviceActivityMonitor",
|
|
9
|
+
deploymentTarget: "16.0",
|
|
10
|
+
bundleIdentifier: ".DeviceActivityMonitor",
|
|
11
|
+
frameworks: ["DeviceActivity", "ManagedSettings", "FamilyControls"],
|
|
12
|
+
entitlements: {
|
|
13
|
+
"com.apple.developer.family-controls": true,
|
|
14
|
+
"com.apple.security.application-groups": [appGroup],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import ManagedSettings
|
|
2
|
+
import ManagedSettingsUI
|
|
3
|
+
import UIKit
|
|
4
|
+
import UserNotifications
|
|
5
|
+
|
|
6
|
+
class ShieldActionExtension: ShieldActionDelegate {
|
|
7
|
+
private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
|
|
8
|
+
private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
|
|
9
|
+
private let pendingUnlockNotificationIdentifier = "expo.appblocker.pendingUnlock.local"
|
|
10
|
+
|
|
11
|
+
override func handle(action: ShieldAction, for application: ApplicationToken, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
12
|
+
handleAction(action, completionHandler: completionHandler)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override func handle(action: ShieldAction, for webDomain: WebDomainToken, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
16
|
+
handleAction(action, completionHandler: completionHandler)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override func handle(action: ShieldAction, for category: ActivityCategoryToken, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
20
|
+
handleAction(action, completionHandler: completionHandler)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func handleAction(_ action: ShieldAction, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
24
|
+
switch action {
|
|
25
|
+
case .primaryButtonPressed:
|
|
26
|
+
setPendingUnlockFlag()
|
|
27
|
+
schedulePendingUnlockNotification { didSchedule in
|
|
28
|
+
let response: ShieldActionResponse = didSchedule ? .none : .defer
|
|
29
|
+
self.complete(on: response, completionHandler: completionHandler)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
case .secondaryButtonPressed:
|
|
33
|
+
complete(on: .close, completionHandler: completionHandler)
|
|
34
|
+
|
|
35
|
+
@unknown default:
|
|
36
|
+
complete(on: .close, completionHandler: completionHandler)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func complete(on response: ShieldActionResponse, completionHandler: @escaping (ShieldActionResponse) -> Void) {
|
|
41
|
+
if Thread.isMainThread {
|
|
42
|
+
completionHandler(response)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
DispatchQueue.main.async {
|
|
47
|
+
completionHandler(response)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private func setPendingUnlockFlag() {
|
|
52
|
+
guard let sharedDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
|
|
53
|
+
sharedDefaults.set(true, forKey: pendingUnlockKey)
|
|
54
|
+
sharedDefaults.synchronize()
|
|
55
|
+
|
|
56
|
+
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
|
|
57
|
+
CFNotificationCenterPostNotification(
|
|
58
|
+
notificationCenter,
|
|
59
|
+
CFNotificationName("expo.appblocker.pendingUnlock" as CFString),
|
|
60
|
+
nil,
|
|
61
|
+
nil,
|
|
62
|
+
true
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func schedulePendingUnlockNotification(completion: @escaping (Bool) -> Void) {
|
|
67
|
+
let center = UNUserNotificationCenter.current()
|
|
68
|
+
|
|
69
|
+
let content = UNMutableNotificationContent()
|
|
70
|
+
content.title = "App Blocker"
|
|
71
|
+
content.body = "Tap to return to the app and complete the unlock challenge."
|
|
72
|
+
content.sound = .default
|
|
73
|
+
content.userInfo = ["link": "/unlock"]
|
|
74
|
+
|
|
75
|
+
let request = UNNotificationRequest(
|
|
76
|
+
identifier: pendingUnlockNotificationIdentifier,
|
|
77
|
+
content: content,
|
|
78
|
+
trigger: nil
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
center.removePendingNotificationRequests(withIdentifiers: [pendingUnlockNotificationIdentifier])
|
|
82
|
+
center.add(request) { error in
|
|
83
|
+
completion(error == nil)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
|
2
|
+
module.exports = (config) => {
|
|
3
|
+
const appGroup = config.ios?.entitlements?.["com.apple.security.application-groups"]?.[0]
|
|
4
|
+
|| "group.expo.app-blocker";
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
type: "shield-action",
|
|
8
|
+
name: "ShieldAction",
|
|
9
|
+
deploymentTarget: "16.0",
|
|
10
|
+
bundleIdentifier: ".ShieldAction",
|
|
11
|
+
frameworks: ["ManagedSettings", "ManagedSettingsUI"],
|
|
12
|
+
entitlements: {
|
|
13
|
+
"com.apple.developer.family-controls": true,
|
|
14
|
+
"com.apple.security.application-groups": [appGroup],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import ManagedSettingsUI
|
|
2
|
+
import ManagedSettings
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
|
6
|
+
|
|
7
|
+
private let appGroupIdentifier = "APP_GROUP_PLACEHOLDER"
|
|
8
|
+
|
|
9
|
+
// Grandmizer design system colors
|
|
10
|
+
private let primaryGreen = UIColor(red: 0.486, green: 0.710, blue: 0.094, alpha: 1.0) // #7cb518
|
|
11
|
+
private let darkGreen = UIColor(red: 0.361, green: 0.502, blue: 0.004, alpha: 1.0) // #5c8001
|
|
12
|
+
private let accentOrange = UIColor(red: 0.984, green: 0.380, blue: 0.027, alpha: 1.0) // #fb6107
|
|
13
|
+
private let darkText = UIColor(red: 0.067, green: 0.067, blue: 0.067, alpha: 1.0) // #111111
|
|
14
|
+
private let subtitleGray = UIColor(red: 0.45, green: 0.45, blue: 0.45, alpha: 1.0)
|
|
15
|
+
|
|
16
|
+
private var mascotIcon: UIImage? {
|
|
17
|
+
// Load from the extension's own bundle
|
|
18
|
+
let bundle = Bundle(for: type(of: self))
|
|
19
|
+
return UIImage(named: "shield-icon", in: bundle, compatibleWith: nil)
|
|
20
|
+
?? UIImage(contentsOfFile: bundle.path(forResource: "shield-icon", ofType: "png") ?? "")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func getBlockedAppCount() -> Int {
|
|
24
|
+
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return 0 }
|
|
25
|
+
guard let config = defaults.dictionary(forKey: "appBlocker.blockConfiguration.v1") else { return 0 }
|
|
26
|
+
if let items = config["blockedItems"] as? [[String: Any]] {
|
|
27
|
+
return items.count
|
|
28
|
+
}
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private func isTemporarilyUnlocked() -> Bool {
|
|
33
|
+
guard let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return false }
|
|
34
|
+
guard let expiration = defaults.object(forKey: "appBlocker.temporaryUnlock.v1") as? Date else { return false }
|
|
35
|
+
return Date() < expiration
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
|
39
|
+
let appName = application.localizedDisplayName ?? "This app"
|
|
40
|
+
|
|
41
|
+
// If temporarily unlocked, show a different message
|
|
42
|
+
if isTemporarilyUnlocked() {
|
|
43
|
+
return ShieldConfiguration(
|
|
44
|
+
backgroundBlurStyle: .systemThickMaterial,
|
|
45
|
+
backgroundColor: nil,
|
|
46
|
+
icon: mascotIcon,
|
|
47
|
+
title: ShieldConfiguration.Label(
|
|
48
|
+
text: "Almost there!",
|
|
49
|
+
color: darkText
|
|
50
|
+
),
|
|
51
|
+
subtitle: ShieldConfiguration.Label(
|
|
52
|
+
text: "Your free time is loading. Try again in a moment.",
|
|
53
|
+
color: subtitleGray
|
|
54
|
+
),
|
|
55
|
+
primaryButtonLabel: ShieldConfiguration.Label(
|
|
56
|
+
text: "OK",
|
|
57
|
+
color: .white
|
|
58
|
+
),
|
|
59
|
+
primaryButtonBackgroundColor: primaryGreen,
|
|
60
|
+
secondaryButtonLabel: nil
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let count = getBlockedAppCount()
|
|
65
|
+
let contextLine = count > 1
|
|
66
|
+
? "You have \(count) apps blocked. Stay focused!"
|
|
67
|
+
: "Stay focused! Take a quick quiz to earn free time."
|
|
68
|
+
|
|
69
|
+
return ShieldConfiguration(
|
|
70
|
+
backgroundBlurStyle: .systemThickMaterial,
|
|
71
|
+
backgroundColor: nil,
|
|
72
|
+
icon: mascotIcon,
|
|
73
|
+
title: ShieldConfiguration.Label(
|
|
74
|
+
text: "Hold on!",
|
|
75
|
+
color: darkText
|
|
76
|
+
),
|
|
77
|
+
subtitle: ShieldConfiguration.Label(
|
|
78
|
+
text: "\(appName) is blocked. \(contextLine)",
|
|
79
|
+
color: subtitleGray
|
|
80
|
+
),
|
|
81
|
+
primaryButtonLabel: ShieldConfiguration.Label(
|
|
82
|
+
text: "Earn Free Time",
|
|
83
|
+
color: .white
|
|
84
|
+
),
|
|
85
|
+
primaryButtonBackgroundColor: accentOrange,
|
|
86
|
+
secondaryButtonLabel: ShieldConfiguration.Label(
|
|
87
|
+
text: "Not now",
|
|
88
|
+
color: subtitleGray
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override func configuration(shielding application: Application,
|
|
94
|
+
in category: ActivityCategory) -> ShieldConfiguration {
|
|
95
|
+
let categoryName = category.localizedDisplayName ?? "This category"
|
|
96
|
+
|
|
97
|
+
return ShieldConfiguration(
|
|
98
|
+
backgroundBlurStyle: .systemThickMaterial,
|
|
99
|
+
backgroundColor: nil,
|
|
100
|
+
icon: mascotIcon,
|
|
101
|
+
title: ShieldConfiguration.Label(
|
|
102
|
+
text: "Hold on!",
|
|
103
|
+
color: darkText
|
|
104
|
+
),
|
|
105
|
+
subtitle: ShieldConfiguration.Label(
|
|
106
|
+
text: "\(categoryName) is blocked. Take a quick quiz to earn free time!",
|
|
107
|
+
color: subtitleGray
|
|
108
|
+
),
|
|
109
|
+
primaryButtonLabel: ShieldConfiguration.Label(
|
|
110
|
+
text: "Earn Free Time",
|
|
111
|
+
color: .white
|
|
112
|
+
),
|
|
113
|
+
primaryButtonBackgroundColor: accentOrange,
|
|
114
|
+
secondaryButtonLabel: ShieldConfiguration.Label(
|
|
115
|
+
text: "Not now",
|
|
116
|
+
color: subtitleGray
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
|
122
|
+
let domain = webDomain.domain ?? "This website"
|
|
123
|
+
|
|
124
|
+
return ShieldConfiguration(
|
|
125
|
+
backgroundBlurStyle: .systemThickMaterial,
|
|
126
|
+
backgroundColor: nil,
|
|
127
|
+
icon: mascotIcon,
|
|
128
|
+
title: ShieldConfiguration.Label(
|
|
129
|
+
text: "Hold on!",
|
|
130
|
+
color: darkText
|
|
131
|
+
),
|
|
132
|
+
subtitle: ShieldConfiguration.Label(
|
|
133
|
+
text: "\(domain) is blocked. Take a quick quiz to earn free time!",
|
|
134
|
+
color: subtitleGray
|
|
135
|
+
),
|
|
136
|
+
primaryButtonLabel: ShieldConfiguration.Label(
|
|
137
|
+
text: "Earn Free Time",
|
|
138
|
+
color: .white
|
|
139
|
+
),
|
|
140
|
+
primaryButtonBackgroundColor: accentOrange,
|
|
141
|
+
secondaryButtonLabel: ShieldConfiguration.Label(
|
|
142
|
+
text: "Not now",
|
|
143
|
+
color: subtitleGray
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override func configuration(shielding webDomain: WebDomain,
|
|
149
|
+
in category: ActivityCategory) -> ShieldConfiguration {
|
|
150
|
+
return configuration(shielding: webDomain)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
|
2
|
+
module.exports = (config) => {
|
|
3
|
+
const appGroup = config.ios?.entitlements?.["com.apple.security.application-groups"]?.[0]
|
|
4
|
+
|| "group.expo.app-blocker";
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
type: "shield-config",
|
|
8
|
+
name: "ShieldConfiguration",
|
|
9
|
+
deploymentTarget: "16.0",
|
|
10
|
+
bundleIdentifier: ".ShieldConfiguration",
|
|
11
|
+
frameworks: ["ManagedSettings", "ManagedSettingsUI"],
|
|
12
|
+
entitlements: {
|
|
13
|
+
"com.apple.developer.family-controls": true,
|
|
14
|
+
"com.apple.security.application-groups": [appGroup],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "esnext",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["esnext"],
|
|
6
|
+
"jsx": "react-native",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"outDir": "./build",
|
|
12
|
+
"rootDir": "."
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*", "plugin/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "build"]
|
|
16
|
+
}
|