expo-app-blocker 0.1.1 → 0.1.2

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,42 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ # Skip version bump commits to prevent loops
11
+ if: "!startsWith(github.event.head_commit.message, 'v')"
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20
19
+ registry-url: https://registry.npmjs.org
20
+
21
+ - name: Determine version bump
22
+ id: version
23
+ run: |
24
+ COMMIT_MSG="${{ github.event.head_commit.message }}"
25
+ if echo "$COMMIT_MSG" | grep -qw "\[major\]"; then
26
+ echo "bump=major" >> $GITHUB_OUTPUT
27
+ elif echo "$COMMIT_MSG" | grep -qw "\[minor\]"; then
28
+ echo "bump=minor" >> $GITHUB_OUTPUT
29
+ else
30
+ echo "bump=patch" >> $GITHUB_OUTPUT
31
+ fi
32
+
33
+ - name: Bump version (no git commit)
34
+ run: npm version ${{ steps.version.outputs.bump }} --no-git-tag-version
35
+
36
+ - name: Publish to npm
37
+ run: npm publish --access public
38
+ env:
39
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
40
+
41
+ - name: Output version
42
+ run: echo "Published $(node -p 'require("./package.json").version')"
package/README.md CHANGED
@@ -22,6 +22,8 @@ Cross-platform app blocking module for Expo. Block other apps and redirect users
22
22
 
23
23
  ### Apple Developer Portal (iOS)
24
24
 
25
+ > **Full step-by-step guide**: [docs/APPLE_DEVELOPER_SETUP.md](docs/APPLE_DEVELOPER_SETUP.md)
26
+
25
27
  1. Register **4 App IDs** with **Family Controls** and **App Groups** capabilities:
26
28
  - `com.yourapp.id` (main app)
27
29
  - `com.yourapp.id.DeviceActivityMonitor`
@@ -0,0 +1,161 @@
1
+ # Apple Developer Portal Setup
2
+
3
+ This guide walks you through the one-time setup required in the Apple Developer Portal for `expo-app-blocker` on iOS.
4
+
5
+ ## Prerequisites
6
+
7
+ - A **paid Apple Developer account** ($99/year)
8
+ - Access to https://developer.apple.com/account
9
+
10
+ ---
11
+
12
+ ## Step 1: Create the App Group
13
+
14
+ The App Group enables data sharing between your main app and the three iOS extensions.
15
+
16
+ 1. Go to **Identifiers**: https://developer.apple.com/account/resources/identifiers/list
17
+ 2. Change the dropdown from **"App IDs"** to **"App Groups"**
18
+ - Or go directly to: https://developer.apple.com/account/resources/identifiers/list/applicationGroup
19
+ 3. Click the **+** (blue plus) button
20
+ 4. Select **App Groups** > **Continue**
21
+ 5. Fill in:
22
+ - **Description**: `Your App Name Shared` (e.g., "My App Blocker Shared")
23
+ - **Identifier**: The value you set in `ios.appGroup` in your plugin config
24
+ - Example: `group.com.yourapp.blocker`
25
+ 6. Click **Continue** > **Register**
26
+
27
+ ---
28
+
29
+ ## Step 2: Register the Main App ID
30
+
31
+ 1. Go to **Identifiers**: https://developer.apple.com/account/resources/identifiers/list
32
+ 2. Make sure the dropdown shows **"App IDs"**
33
+ 3. Click the **+** button
34
+ 4. Select **App IDs** > **Continue**
35
+ 5. Select **App** > **Continue**
36
+ 6. Fill in:
37
+ - **Description**: Your app name (e.g., "My App Blocker")
38
+ - **Bundle ID**: Select **Explicit**, enter your `ios.bundleIdentifier` from `app.json`
39
+ - Example: `com.yourapp.id`
40
+ 7. Scroll down to **Capabilities** and enable:
41
+ - **App Groups**
42
+ - **Family Controls**
43
+ 8. Click **Continue** > **Register**
44
+
45
+ > **Note on Family Controls**: If you don't see Family Controls in the capabilities list, you may need to request it. Look for a link to request additional capabilities, or check if it appears under "Additional Capabilities".
46
+
47
+ ---
48
+
49
+ ## Step 3: Register the DeviceActivityMonitor Extension App ID
50
+
51
+ 1. Click the **+** button again
52
+ 2. **App IDs** > **App** > **Continue**
53
+ 3. Fill in:
54
+ - **Description**: `Your App DeviceActivityMonitor`
55
+ - **Bundle ID**: Explicit, enter `{your-bundle-id}.DeviceActivityMonitor`
56
+ - Example: `com.yourapp.id.DeviceActivityMonitor`
57
+ 4. Enable capabilities:
58
+ - **App Groups**
59
+ - **Family Controls**
60
+ 5. Click **Continue** > **Register**
61
+
62
+ ---
63
+
64
+ ## Step 4: Register the ShieldAction Extension App ID
65
+
66
+ 1. Click the **+** button
67
+ 2. **App IDs** > **App** > **Continue**
68
+ 3. Fill in:
69
+ - **Description**: `Your App ShieldAction`
70
+ - **Bundle ID**: Explicit, enter `{your-bundle-id}.ShieldAction`
71
+ - Example: `com.yourapp.id.ShieldAction`
72
+ 4. Enable capabilities:
73
+ - **App Groups**
74
+ - **Family Controls**
75
+ 5. Click **Continue** > **Register**
76
+
77
+ ---
78
+
79
+ ## Step 5: Register the ShieldConfiguration Extension App ID
80
+
81
+ 1. Click the **+** button
82
+ 2. **App IDs** > **App** > **Continue**
83
+ 3. Fill in:
84
+ - **Description**: `Your App ShieldConfiguration`
85
+ - **Bundle ID**: Explicit, enter `{your-bundle-id}.ShieldConfiguration`
86
+ - Example: `com.yourapp.id.ShieldConfiguration`
87
+ 4. Enable capabilities:
88
+ - **App Groups**
89
+ - **Family Controls**
90
+ 5. Click **Continue** > **Register**
91
+
92
+ ---
93
+
94
+ ## Step 6: Assign the App Group to All App IDs
95
+
96
+ For **each of the 4 App IDs** you just created:
97
+
98
+ 1. Click on the App ID in the list
99
+ 2. Scroll to **App Groups**
100
+ 3. Click **Configure** (or **Edit**)
101
+ 4. Check your App Group (e.g., `group.com.yourapp.blocker`)
102
+ 5. Click **Save**
103
+
104
+ Repeat for all 4:
105
+ - `com.yourapp.id`
106
+ - `com.yourapp.id.DeviceActivityMonitor`
107
+ - `com.yourapp.id.ShieldAction`
108
+ - `com.yourapp.id.ShieldConfiguration`
109
+
110
+ ---
111
+
112
+ ## Summary Checklist
113
+
114
+ When you're done, you should have:
115
+
116
+ - [ ] **1 App Group**: `group.com.yourapp.blocker`
117
+ - [ ] **4 App IDs**, each with Family Controls + App Groups:
118
+
119
+ | App ID | Description |
120
+ |---|---|
121
+ | `com.yourapp.id` | Main app |
122
+ | `com.yourapp.id.DeviceActivityMonitor` | Relock timer extension |
123
+ | `com.yourapp.id.ShieldAction` | Shield button handler extension |
124
+ | `com.yourapp.id.ShieldConfiguration` | Custom shield UI extension |
125
+
126
+ - [ ] App Group assigned to all 4 App IDs
127
+
128
+ ---
129
+
130
+ ## About Family Controls Approval
131
+
132
+ - **Development builds** (run from Xcode): Family Controls works **without** formal Apple approval
133
+ - **TestFlight**: May require approval depending on your account
134
+ - **App Store**: Requires Family Controls capability approval from Apple
135
+
136
+ To request approval:
137
+ 1. Go to https://developer.apple.com/contact/request/family-controls-distribution
138
+ 2. Fill out the form explaining your app's use case
139
+ 3. Wait for Apple's response (can take days to weeks)
140
+
141
+ **You can develop and test locally without waiting for approval.**
142
+
143
+ ---
144
+
145
+ ## Troubleshooting
146
+
147
+ **"Family Controls" not visible in capabilities list**
148
+ - Make sure you're on a paid developer account (not free)
149
+ - Try searching for it in the capabilities search bar
150
+ - You may need to request access: https://developer.apple.com/contact/request/family-controls-distribution
151
+
152
+ **"An App ID with this identifier is not available"**
153
+ - The bundle ID might already be registered. Check your existing identifiers.
154
+
155
+ **App Group not showing when configuring an App ID**
156
+ - Make sure you created the App Group first (Step 1)
157
+ - Try refreshing the page
158
+
159
+ **Signing errors in Xcode after setup**
160
+ - In Xcode: select each target > Signing & Capabilities > set your Team
161
+ - Xcode should automatically create provisioning profiles using the registered App IDs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-app-blocker",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",
@@ -1,21 +0,0 @@
1
- require 'json'
2
-
3
- package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
-
5
- Pod::Spec.new do |s|
6
- s.name = 'ExpoAppBlocker'
7
- s.version = package['version']
8
- s.summary = package['description']
9
- s.description = package['description']
10
- s.license = package['license']
11
- s.author = package['author'] || 'expo-app-blocker contributors'
12
- s.homepage = package['homepage']
13
- s.platforms = { :ios => '16.0' }
14
- s.swift_version = '5.9'
15
- s.source = { git: '' }
16
- s.static_framework = true
17
-
18
- s.dependency 'ExpoModulesCore'
19
-
20
- s.source_files = '**/*.{h,m,swift}'
21
- end
@@ -1,19 +0,0 @@
1
- import Foundation
2
-
3
- // This file provides default configuration values.
4
- // The actual values are injected by the config plugin at prebuild time
5
- // into a generated file in the app's ios directory.
6
- // If the generated file exists, its values override these defaults.
7
-
8
- public struct ExpoAppBlockerConfig {
9
- // Override this in your app by creating a file with:
10
- // let expoAppBlockerAppGroup = "group.com.yourapp.blocker"
11
- public static var appGroupIdentifier: String {
12
- // Try to read from UserDefaults (set by config plugin)
13
- if let appGroup = UserDefaults.standard.string(forKey: "expo.appblocker.appGroup") {
14
- return appGroup
15
- }
16
- // Fallback - should be overridden
17
- return "group.expo.app-blocker"
18
- }
19
- }
@@ -1,1014 +0,0 @@
1
- import ExpoModulesCore
2
- import FamilyControls
3
- import ManagedSettings
4
- import DeviceActivity
5
- import SwiftUI
6
- import Foundation
7
-
8
- public class ExpoAppBlockerModule: Module {
9
- private let appGroupIdentifier = ExpoAppBlockerConfig.appGroupIdentifier
10
-
11
- private let authCenter = AuthorizationCenter.shared
12
- private let store = ManagedSettingsStore()
13
- private let activityCenter = DeviceActivityCenter()
14
- private var sharedDefaults: UserDefaults?
15
- private let userDefaults = UserDefaults.standard
16
- private let blockConfigStorageKey = "appBlocker.blockConfiguration.v1"
17
- private let temporaryUnlockKey = "appBlocker.temporaryUnlock.v1"
18
- private let unlockActivityName = "appBlocker.temporaryUnlock"
19
- private let pendingUnlockKey = "appBlocker.pendingUnlock.v1"
20
- private let minimumTemporaryUnlockMinutes = 1
21
- private var didLoadPersistedConfig = false
22
-
23
- private var currentBlockConfig: BlockConfig?
24
- private let stateQueue = DispatchQueue(label: "expo.appblocker.state", qos: .userInitiated)
25
- private let scheduleLock = NSLock()
26
- private var isProcessingUnlockState = false
27
-
28
- public func definition() -> ModuleDefinition {
29
- Name("ExpoAppBlocker")
30
-
31
- Events("onPendingUnlockRequest")
32
-
33
- // Native view that renders blocked app tokens with real names and icons
34
- View(BlockedAppsView.self) {
35
- Prop("selectionData") { (view: BlockedAppsView, selectionBase64: String) in
36
- guard !selectionBase64.isEmpty,
37
- let data = Data(base64Encoded: selectionBase64),
38
- let selection = try? JSONDecoder().decode(FamilyActivitySelection.self, from: data)
39
- else { return }
40
- view.viewModel.selection = selection
41
- }
42
-
43
- Prop("tokens") { (view: BlockedAppsView, tokens: [[String: String]]) in
44
- var appTokens: Set<ApplicationToken> = []
45
- var categoryTokens: Set<ActivityCategoryToken> = []
46
-
47
- for tokenInfo in tokens {
48
- guard let tokenString = tokenInfo["token"], let type = tokenInfo["type"] else { continue }
49
- if type == "app" {
50
- if let token = Self.decodeApplicationTokenStatic(from: tokenString) {
51
- appTokens.insert(token)
52
- }
53
- } else if type == "category" {
54
- if let token = Self.decodeCategoryTokenStatic(from: tokenString) {
55
- categoryTokens.insert(token)
56
- }
57
- }
58
- }
59
-
60
- var selection = FamilyActivitySelection()
61
- selection.applicationTokens = appTokens
62
- selection.categoryTokens = categoryTokens
63
- view.viewModel.selection = selection
64
- }
65
- }
66
-
67
- OnCreate {
68
- self.sharedDefaults = UserDefaults(suiteName: self.appGroupIdentifier)
69
- self.setupUnlockNotificationObserver()
70
-
71
- self.stateQueue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
72
- self?.checkAndApplyUnlockState()
73
- }
74
- }
75
-
76
- AsyncFunction("requestAuthorization") { (promise: Promise) in
77
- Task {
78
- do {
79
- try await self.authCenter.requestAuthorization(for: .individual)
80
- let status = self.getAuthStatus()
81
- promise.resolve([
82
- "authorized": status.authorized,
83
- "status": status.statusString
84
- ])
85
- } catch {
86
- promise.resolve([
87
- "authorized": false,
88
- "status": "denied"
89
- ])
90
- }
91
- }
92
- }
93
-
94
- Function("getAuthorizationStatus") {
95
- let status = self.getAuthStatus()
96
- return [
97
- "authorized": status.authorized,
98
- "status": status.statusString
99
- ]
100
- }
101
-
102
- AsyncFunction("presentFamilyActivityPicker") { (promise: Promise) in
103
- DispatchQueue.main.async {
104
- self.ensureLoadedPersistedConfig()
105
-
106
- guard self.authCenter.authorizationStatus == .approved else {
107
- promise.reject("NOT_AUTHORIZED", "Family Controls authorization not granted")
108
- return
109
- }
110
-
111
- let initialAppTokens = Set(self.currentBlockConfig?.items.compactMap { $0.appToken } ?? [])
112
- let initialCategoryTokens = Set(self.currentBlockConfig?.items.compactMap { $0.categoryToken } ?? [])
113
- let pickerView = FamilyActivityPickerView(
114
- initialApplicationTokens: initialAppTokens,
115
- initialCategoryTokens: initialCategoryTokens,
116
- promise: promise
117
- )
118
- let hostingController = UIHostingController(rootView: pickerView)
119
-
120
- if let rootVC = self.getRootViewController() {
121
- hostingController.modalPresentationStyle = .formSheet
122
- rootVC.present(hostingController, animated: true)
123
- } else {
124
- promise.reject("NO_ROOT_VC", "Could not find root view controller")
125
- }
126
- }
127
- }
128
-
129
- AsyncFunction("setBlockConfiguration") { (config: [String: Any], promise: Promise) in
130
- self.stateQueue.async {
131
- do {
132
- self.ensureLoadedPersistedConfig()
133
- let blockConfig = try self.parseBlockConfig(config)
134
- self.currentBlockConfig = blockConfig
135
- try self.applyBlocks(blockConfig)
136
- self.persistBlockConfiguration(config)
137
-
138
- DispatchQueue.main.async {
139
- promise.resolve(nil)
140
- }
141
- } catch {
142
- DispatchQueue.main.async {
143
- promise.reject("CONFIG_ERROR", "Failed to set block configuration: \(error.localizedDescription)")
144
- }
145
- }
146
- }
147
- }
148
-
149
- Function("getBlockConfiguration") { () -> [String: Any]? in
150
- self.ensureLoadedPersistedConfig()
151
-
152
- guard let config = self.currentBlockConfig else {
153
- return nil
154
- }
155
- return self.serializeBlockConfig(config)
156
- }
157
-
158
- Function("clearAllBlocks") {
159
- self.stateQueue.async {
160
- self.ensureLoadedPersistedConfig()
161
- self.cancelRelockActivity()
162
- self.store.shield.applications = nil
163
- self.store.shield.applicationCategories = nil
164
- self.currentBlockConfig = nil
165
- self.userDefaults.removeObject(forKey: self.blockConfigStorageKey)
166
- self.sharedDefaults?.removeObject(forKey: self.blockConfigStorageKey)
167
- self.sharedDefaults?.removeObject(forKey: self.temporaryUnlockKey)
168
- }
169
- }
170
-
171
- Function("checkAndClearPendingUnlock") { () -> Bool in
172
- guard let defaults = self.sharedDefaults else { return false }
173
- let hasPending = defaults.bool(forKey: self.pendingUnlockKey)
174
- if hasPending {
175
- defaults.removeObject(forKey: self.pendingUnlockKey)
176
- defaults.synchronize()
177
- }
178
- return hasPending
179
- }
180
-
181
- Function("isAppBlocked") { (bundleIdentifier: String) -> Bool in
182
- self.ensureLoadedPersistedConfig()
183
- guard let config = self.currentBlockConfig else {
184
- return false
185
- }
186
- return config.items.contains { $0.bundleIdentifier == bundleIdentifier }
187
- }
188
-
189
- AsyncFunction("temporaryUnlock") { (durationMinutes: Int, promise: Promise) in
190
- self.stateQueue.async {
191
- self.ensureLoadedPersistedConfig()
192
- let sanitizedDurationMinutes = max(self.minimumTemporaryUnlockMinutes, durationMinutes)
193
-
194
- guard let config = self.currentBlockConfig, config.isActive else {
195
- DispatchQueue.main.async {
196
- promise.reject("NO_ACTIVE_BLOCKS", "No active blocks to unlock")
197
- }
198
- return
199
- }
200
-
201
- let expirationDate = Date().addingTimeInterval(TimeInterval(sanitizedDurationMinutes * 60))
202
- self.sharedDefaults?.set(expirationDate, forKey: self.temporaryUnlockKey)
203
-
204
- DispatchQueue.main.async {
205
- self.store.shield.applications = nil
206
- self.store.shield.applicationCategories = nil
207
- }
208
-
209
- // Try to schedule relock, but don't fail if schedule is too short
210
- // (Apple requires minimum ~15 min for DeviceActivitySchedule)
211
- do {
212
- try self.scheduleRelockActivity(expirationDate: expirationDate)
213
- } catch {
214
- // Schedule failed (too short) - that's OK, we still unlock.
215
- // The app will re-check and relock via checkAndApplyUnlockState
216
- // when the expiration passes and user returns to the app.
217
- print("[AppBlocker] Schedule relock failed (duration may be too short): \(error.localizedDescription)")
218
- }
219
-
220
- DispatchQueue.main.async {
221
- promise.resolve([
222
- "unlocked": true,
223
- "expiresAt": expirationDate.timeIntervalSince1970
224
- ])
225
- }
226
- }
227
- }
228
-
229
- Function("isTemporarilyUnlocked") { () -> Bool in
230
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
231
- return false
232
- }
233
-
234
- if Date() < expirationDate {
235
- return true
236
- }
237
-
238
- self.relockApps()
239
- return false
240
- }
241
-
242
- Function("getRemainingUnlockTime") { () -> Int in
243
- guard let expirationDate = self.sharedDefaults?.object(forKey: self.temporaryUnlockKey) as? Date else {
244
- return 0
245
- }
246
-
247
- let remaining = expirationDate.timeIntervalSince(Date())
248
- if remaining > 0 {
249
- return Int(remaining)
250
- }
251
-
252
- self.relockApps()
253
- return 0
254
- }
255
-
256
- AsyncFunction("relockApps") { (promise: Promise) in
257
- self.stateQueue.async {
258
- self.relockApps()
259
-
260
- DispatchQueue.main.async {
261
- promise.resolve(["locked": true])
262
- }
263
- }
264
- }
265
- }
266
-
267
- // MARK: - Authorization
268
-
269
- private func getAuthStatus() -> (authorized: Bool, statusString: String) {
270
- let status = authCenter.authorizationStatus
271
- switch status {
272
- case .notDetermined:
273
- return (false, "notDetermined")
274
- case .denied:
275
- return (false, "denied")
276
- case .approved:
277
- return (true, "approved")
278
- @unknown default:
279
- return (false, "denied")
280
- }
281
- }
282
-
283
- private func getRootViewController() -> UIViewController? {
284
- if let currentVC = appContext?.utilities?.currentViewController() {
285
- return currentVC
286
- }
287
-
288
- let scenes = UIApplication.shared.connectedScenes
289
- let windowScene = scenes.first as? UIWindowScene
290
- let window = windowScene?.windows.first
291
- return window?.rootViewController
292
- }
293
-
294
- // MARK: - Darwin Notification Observer
295
-
296
- private func setupUnlockNotificationObserver() {
297
- let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
298
- let observer = Unmanaged.passUnretained(self).toOpaque()
299
-
300
- let legacyName = "expo.appblocker.temporaryUnlock" as CFString
301
- CFNotificationCenterAddObserver(
302
- notificationCenter,
303
- observer,
304
- { (_, observer, _, _, _) in
305
- guard let observer else { return }
306
- let module = Unmanaged<ExpoAppBlockerModule>.fromOpaque(observer).takeUnretainedValue()
307
- module.stateQueue.async {
308
- module.checkAndApplyUnlockState()
309
- }
310
- },
311
- legacyName,
312
- nil,
313
- .deliverImmediately
314
- )
315
-
316
- let pendingName = "expo.appblocker.pendingUnlock" as CFString
317
- CFNotificationCenterAddObserver(
318
- notificationCenter,
319
- observer,
320
- { (_, observer, _, _, _) in
321
- guard let observer else { return }
322
- let module = Unmanaged<ExpoAppBlockerModule>.fromOpaque(observer).takeUnretainedValue()
323
- module.handlePendingUnlockRequest()
324
- },
325
- pendingName,
326
- nil,
327
- .deliverImmediately
328
- )
329
- }
330
-
331
- private func handlePendingUnlockRequest() {
332
- DispatchQueue.main.async {
333
- self.sendEvent("onPendingUnlockRequest", [:])
334
- }
335
- }
336
-
337
- // MARK: - Unlock State
338
-
339
- private func checkAndApplyUnlockState() {
340
- guard !isProcessingUnlockState else {
341
- return
342
- }
343
-
344
- isProcessingUnlockState = true
345
- defer { isProcessingUnlockState = false }
346
-
347
- ensureLoadedPersistedConfig()
348
-
349
- if let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date {
350
- let remaining = expirationDate.timeIntervalSince(Date())
351
-
352
- if remaining > 0 {
353
- DispatchQueue.main.async {
354
- self.store.shield.applications = nil
355
- self.store.shield.applicationCategories = nil
356
- }
357
-
358
- do {
359
- try scheduleRelockActivity(expirationDate: expirationDate)
360
- } catch {
361
- relockApps()
362
- }
363
- } else {
364
- relockApps()
365
- }
366
- } else if let config = currentBlockConfig {
367
- do {
368
- try applyBlocks(config)
369
- } catch {
370
- }
371
- }
372
- }
373
-
374
- // MARK: - Block Configuration
375
-
376
- private func parseBlockConfig(_ dict: [String: Any]) throws -> BlockConfig {
377
- let rawItems: [[String: Any]]
378
- if let blockedItems = dict["blockedItems"] as? [[String: Any]] {
379
- rawItems = blockedItems
380
- } else if let appSelections = dict["appSelections"] as? [[String: Any]] {
381
- rawItems = appSelections.map { item in
382
- var normalized = item
383
- normalized["type"] = "app"
384
- return normalized
385
- }
386
- } else {
387
- throw NSError(domain: "AppBlocker", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing blockedItems"])
388
- }
389
-
390
- let items: [BlockedItemInfo] = rawItems.compactMap { selection -> BlockedItemInfo? in
391
- guard let tokenString = selection["token"] as? String else {
392
- return nil
393
- }
394
-
395
- let itemTypeRaw = (selection["type"] as? String ?? "app").lowercased()
396
- let itemType: BlockedItemType = itemTypeRaw == "category" ? .category : .app
397
-
398
- return BlockedItemInfo(
399
- type: itemType,
400
- tokenId: tokenString,
401
- appToken: itemType == .app ? self.decodeApplicationToken(from: tokenString) : nil,
402
- categoryToken: itemType == .category ? self.decodeCategoryToken(from: tokenString) : nil,
403
- bundleIdentifier: selection["bundleIdentifier"] as? String,
404
- displayName: selection["displayName"] as? String,
405
- categoryName: selection["categoryName"] as? String,
406
- iconBase64: selection["iconBase64"] as? String
407
- )
408
- }
409
-
410
- let isActive = dict["isActive"] as? Bool ?? true
411
-
412
- var schedule: ScheduleInfo?
413
- if let scheduleDict = dict["schedule"] as? [String: Any] {
414
- schedule = ScheduleInfo(
415
- intervalStart: scheduleDict["intervalStart"] as? Int ?? 0,
416
- intervalEnd: scheduleDict["intervalEnd"] as? Int ?? 24,
417
- repeats: scheduleDict["repeats"] as? Bool ?? true,
418
- warningTime: scheduleDict["warningTime"] as? Int ?? 5
419
- )
420
- }
421
-
422
- return BlockConfig(items: items, isActive: isActive, schedule: schedule)
423
- }
424
-
425
- private func applyBlocks(_ config: BlockConfig) throws {
426
- guard config.isActive else {
427
- store.shield.applications = nil
428
- store.shield.applicationCategories = nil
429
- return
430
- }
431
-
432
- if isTemporarilyUnlockedInternal() {
433
- store.shield.applications = nil
434
- store.shield.applicationCategories = nil
435
- return
436
- }
437
-
438
- let validAppTokens = config.items.compactMap { $0.appToken }
439
- let validCategoryTokens = config.items.compactMap { $0.categoryToken }
440
-
441
- guard !validAppTokens.isEmpty || !validCategoryTokens.isEmpty else {
442
- store.shield.applications = nil
443
- store.shield.applicationCategories = nil
444
- return
445
- }
446
-
447
- if !validAppTokens.isEmpty {
448
- store.shield.applications = Set(validAppTokens)
449
- } else {
450
- store.shield.applications = nil
451
- }
452
-
453
- if !validCategoryTokens.isEmpty {
454
- store.shield.applicationCategories = .specific(Set(validCategoryTokens))
455
- } else {
456
- store.shield.applicationCategories = nil
457
- }
458
- }
459
-
460
- private func relockApps() {
461
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
462
- cancelRelockActivity()
463
- ensureLoadedPersistedConfig()
464
-
465
- guard let config = currentBlockConfig else {
466
- return
467
- }
468
-
469
- do {
470
- try applyBlocks(config)
471
- } catch {
472
- }
473
- }
474
-
475
- // MARK: - Activity Scheduling
476
-
477
- private func scheduleRelockActivity(expirationDate: Date) throws {
478
- scheduleLock.lock()
479
- defer { scheduleLock.unlock() }
480
-
481
- cancelRelockActivityLocked()
482
-
483
- let activityName = DeviceActivityName(unlockActivityName)
484
- let calendar = Calendar.current
485
- let now = Date()
486
- let startComponents = calendar.dateComponents([.hour, .minute, .second], from: now)
487
- let endComponents = calendar.dateComponents([.hour, .minute, .second], from: expirationDate)
488
- let nowDay = calendar.startOfDay(for: now)
489
- let expirationDay = calendar.startOfDay(for: expirationDate)
490
-
491
- let schedule: DeviceActivitySchedule
492
-
493
- if nowDay == expirationDay {
494
- schedule = DeviceActivitySchedule(
495
- intervalStart: DateComponents(
496
- hour: startComponents.hour,
497
- minute: startComponents.minute,
498
- second: startComponents.second
499
- ),
500
- intervalEnd: DateComponents(
501
- hour: endComponents.hour,
502
- minute: endComponents.minute,
503
- second: endComponents.second
504
- ),
505
- repeats: false
506
- )
507
- } else {
508
- schedule = DeviceActivitySchedule(
509
- intervalStart: DateComponents(
510
- hour: startComponents.hour,
511
- minute: startComponents.minute,
512
- second: startComponents.second
513
- ),
514
- intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
515
- repeats: false
516
- )
517
- }
518
-
519
- try activityCenter.startMonitoring(activityName, during: schedule)
520
- }
521
-
522
- private func cancelRelockActivity() {
523
- scheduleLock.lock()
524
- defer { scheduleLock.unlock() }
525
- cancelRelockActivityLocked()
526
- }
527
-
528
- private func cancelRelockActivityLocked() {
529
- let activityName = DeviceActivityName(unlockActivityName)
530
- activityCenter.stopMonitoring([activityName])
531
- }
532
-
533
- private func isTemporarilyUnlockedInternal() -> Bool {
534
- guard let expirationDate = sharedDefaults?.object(forKey: temporaryUnlockKey) as? Date else {
535
- return false
536
- }
537
-
538
- if Date() < expirationDate {
539
- return true
540
- }
541
-
542
- sharedDefaults?.removeObject(forKey: temporaryUnlockKey)
543
- return false
544
- }
545
-
546
- // MARK: - Serialization
547
-
548
- private func serializeBlockConfig(_ config: BlockConfig) -> [String: Any] {
549
- let blockedItems: [[String: Any]] = config.items.compactMap { tokenInfo in
550
- var tokenId = tokenInfo.tokenId
551
- if tokenId.isEmpty {
552
- switch tokenInfo.type {
553
- case .app:
554
- if let token = tokenInfo.appToken, let encoded = self.encodeApplicationToken(token) {
555
- tokenId = encoded
556
- }
557
- case .category:
558
- if let token = tokenInfo.categoryToken, let encoded = self.encodeCategoryToken(token) {
559
- tokenId = encoded
560
- }
561
- }
562
- }
563
-
564
- guard !tokenId.isEmpty else {
565
- return nil
566
- }
567
-
568
- var dict: [String: Any] = [
569
- "type": tokenInfo.type.rawValue,
570
- "token": tokenId
571
- ]
572
-
573
- if let bundleId = tokenInfo.bundleIdentifier {
574
- dict["bundleIdentifier"] = bundleId
575
- }
576
- if let displayName = tokenInfo.displayName {
577
- dict["displayName"] = displayName
578
- }
579
- if let categoryName = tokenInfo.categoryName {
580
- dict["categoryName"] = categoryName
581
- }
582
- if let iconBase64 = tokenInfo.iconBase64 {
583
- dict["iconBase64"] = iconBase64
584
- }
585
-
586
- return dict
587
- }
588
-
589
- let appSelections = blockedItems.filter { ($0["type"] as? String) == BlockedItemType.app.rawValue }
590
-
591
- var result: [String: Any] = [
592
- "blockedItems": blockedItems,
593
- "appSelections": appSelections,
594
- "isActive": config.isActive
595
- ]
596
-
597
- if let schedule = config.schedule {
598
- result["schedule"] = [
599
- "intervalStart": schedule.intervalStart,
600
- "intervalEnd": schedule.intervalEnd,
601
- "repeats": schedule.repeats,
602
- "warningTime": schedule.warningTime
603
- ]
604
- }
605
-
606
- return result
607
- }
608
-
609
- // MARK: - Persistence
610
-
611
- private func ensureLoadedPersistedConfig() {
612
- if didLoadPersistedConfig {
613
- return
614
- }
615
- didLoadPersistedConfig = true
616
-
617
- guard let savedConfig = userDefaults.dictionary(forKey: blockConfigStorageKey) else {
618
- return
619
- }
620
-
621
- do {
622
- let config = try parseBlockConfig(savedConfig)
623
- currentBlockConfig = config
624
- try applyBlocks(config)
625
- } catch {
626
- currentBlockConfig = nil
627
- userDefaults.removeObject(forKey: blockConfigStorageKey)
628
- }
629
- }
630
-
631
- private func persistBlockConfiguration(_ config: [String: Any]) {
632
- userDefaults.set(config, forKey: blockConfigStorageKey)
633
- sharedDefaults?.set(config, forKey: blockConfigStorageKey)
634
- }
635
-
636
- // MARK: - Token Encoding/Decoding
637
-
638
- private func encodeApplicationToken(_ token: ApplicationToken) -> String? {
639
- do {
640
- let data = try JSONEncoder().encode(token)
641
- return data.base64EncodedString()
642
- } catch {
643
- return nil
644
- }
645
- }
646
-
647
- private func decodeApplicationToken(from encoded: String) -> ApplicationToken? {
648
- guard let data = Data(base64Encoded: encoded) else {
649
- return nil
650
- }
651
-
652
- do {
653
- return try JSONDecoder().decode(ApplicationToken.self, from: data)
654
- } catch {
655
- return nil
656
- }
657
- }
658
-
659
- private func encodeCategoryToken(_ token: ActivityCategoryToken) -> String? {
660
- do {
661
- let data = try JSONEncoder().encode(token)
662
- return data.base64EncodedString()
663
- } catch {
664
- return nil
665
- }
666
- }
667
-
668
- private func decodeCategoryToken(from encoded: String) -> ActivityCategoryToken? {
669
- return Self.decodeCategoryTokenStatic(from: encoded)
670
- }
671
-
672
- // Static versions for use in View prop closures
673
- static func decodeApplicationTokenStatic(from encoded: String) -> ApplicationToken? {
674
- guard let data = Data(base64Encoded: encoded) else { return nil }
675
- return try? JSONDecoder().decode(ApplicationToken.self, from: data)
676
- }
677
-
678
- static func decodeCategoryTokenStatic(from encoded: String) -> ActivityCategoryToken? {
679
- guard let data = Data(base64Encoded: encoded) else { return nil }
680
- return try? JSONDecoder().decode(ActivityCategoryToken.self, from: data)
681
- }
682
- }
683
-
684
- // MARK: - Native View for rendering blocked app tokens with real names/icons
685
-
686
- class BlockedAppsViewModel: ObservableObject {
687
- @Published var selection = FamilyActivitySelection()
688
- }
689
-
690
- class BlockedAppsView: ExpoView {
691
- let viewModel = BlockedAppsViewModel()
692
- private var hostingController: UIHostingController<BlockedAppsContentView>?
693
-
694
- required init(appContext: AppContext? = nil) {
695
- super.init(appContext: appContext)
696
- clipsToBounds = true
697
- let contentView = BlockedAppsContentView(viewModel: viewModel)
698
- let hc = UIHostingController(rootView: contentView)
699
- hc.view.backgroundColor = .clear
700
- addSubview(hc.view)
701
- hostingController = hc
702
- }
703
-
704
- override func layoutSubviews() {
705
- super.layoutSubviews()
706
- hostingController?.view.frame = bounds
707
- }
708
- }
709
-
710
- struct BlockedAppsContentView: View {
711
- @ObservedObject var viewModel: BlockedAppsViewModel
712
-
713
- // Grandmizer design system colors
714
- private let cardBg = Color(red: 1.0, green: 1.0, blue: 1.0) // #ffffff
715
- private let borderColor = Color(red: 0.91, green: 0.91, blue: 0.91) // #e8e8e8
716
- private let labelColor = Color(red: 0.067, green: 0.067, blue: 0.067) // #111111
717
- private let subtitleColor = Color(red: 0.73, green: 0.73, blue: 0.73) // #bbbbbb
718
- private let greenBadgeBg = Color(red: 0.94, green: 0.96, blue: 0.91) // #f0f6e8
719
- private let greenText = Color(red: 0.24, green: 0.31, blue: 0.0) // #3d5000
720
-
721
- var body: some View {
722
- VStack(alignment: .leading, spacing: 8) {
723
- ForEach(Array(viewModel.selection.applicationTokens), id: \.self) { token in
724
- HStack(spacing: 12) {
725
- Label(token)
726
- .labelStyle(.titleAndIcon)
727
- .font(.system(size: 16, weight: .semibold))
728
- .tint(labelColor)
729
- .foregroundStyle(labelColor)
730
- Spacer()
731
- Text("App")
732
- .font(.system(size: 11, weight: .semibold))
733
- .foregroundColor(greenText)
734
- .padding(.horizontal, 10)
735
- .padding(.vertical, 4)
736
- .background(greenBadgeBg)
737
- .cornerRadius(100)
738
- }
739
- .padding(.vertical, 10)
740
- .padding(.horizontal, 14)
741
- .background(cardBg)
742
- .cornerRadius(16)
743
- .overlay(
744
- RoundedRectangle(cornerRadius: 16)
745
- .stroke(borderColor, lineWidth: 1)
746
- )
747
- }
748
-
749
- ForEach(Array(viewModel.selection.categoryTokens), id: \.self) { token in
750
- HStack(spacing: 12) {
751
- Label(token)
752
- .labelStyle(.titleAndIcon)
753
- .font(.system(size: 16, weight: .semibold))
754
- .tint(labelColor)
755
- .foregroundStyle(labelColor)
756
- Spacer()
757
- Text("Category")
758
- .font(.system(size: 11, weight: .semibold))
759
- .foregroundColor(greenText)
760
- .padding(.horizontal, 10)
761
- .padding(.vertical, 4)
762
- .background(greenBadgeBg)
763
- .cornerRadius(100)
764
- }
765
- .padding(.vertical, 10)
766
- .padding(.horizontal, 14)
767
- .background(cardBg)
768
- .cornerRadius(16)
769
- .overlay(
770
- RoundedRectangle(cornerRadius: 16)
771
- .stroke(borderColor, lineWidth: 1)
772
- )
773
- }
774
-
775
- if viewModel.selection.applicationTokens.isEmpty && viewModel.selection.categoryTokens.isEmpty {
776
- Text("No apps blocked")
777
- .foregroundColor(subtitleColor)
778
- .font(.system(size: 14))
779
- .frame(maxWidth: .infinity, alignment: .center)
780
- .padding(.vertical, 16)
781
- }
782
- }
783
- .environment(\.colorScheme, .light)
784
- }
785
- }
786
-
787
- // MARK: - Data Types
788
-
789
- enum BlockedItemType: String {
790
- case app
791
- case category
792
- }
793
-
794
- struct BlockedItemInfo {
795
- let type: BlockedItemType
796
- let tokenId: String
797
- let appToken: ApplicationToken?
798
- let categoryToken: ActivityCategoryToken?
799
- let bundleIdentifier: String?
800
- let displayName: String?
801
- let categoryName: String?
802
- let iconBase64: String?
803
- }
804
-
805
- struct BlockConfig {
806
- let items: [BlockedItemInfo]
807
- let isActive: Bool
808
- let schedule: ScheduleInfo?
809
- }
810
-
811
- struct ScheduleInfo {
812
- let intervalStart: Int
813
- let intervalEnd: Int
814
- let repeats: Bool
815
- let warningTime: Int
816
- }
817
-
818
- // MARK: - FamilyActivityPicker SwiftUI View
819
-
820
- struct FamilyActivityPickerView: View {
821
- @State private var selection: FamilyActivitySelection
822
- @State private var didAppear = false
823
- @State private var didFinish = false
824
- let promise: Promise
825
-
826
- init(
827
- initialApplicationTokens: Set<ApplicationToken>,
828
- initialCategoryTokens: Set<ActivityCategoryToken>,
829
- promise: Promise
830
- ) {
831
- self.promise = promise
832
-
833
- var initialSelection = FamilyActivitySelection()
834
- initialSelection.applicationTokens = initialApplicationTokens
835
- initialSelection.categoryTokens = initialCategoryTokens
836
- self._selection = State(initialValue: initialSelection)
837
- }
838
-
839
- var body: some View {
840
- NavigationView {
841
- VStack {
842
- familyActivityPicker
843
- .onChange(of: selection) { newSelection in
844
- _ = newSelection
845
- }
846
- }
847
- .onAppear {
848
- didAppear = true
849
- }
850
- .onDisappear {
851
- handleInteractiveDismissIfNeeded()
852
- }
853
- .navigationBarItems(
854
- leading: Button("Cancel") {
855
- dismissWithCancel()
856
- },
857
- trailing: Button("Done") {
858
- dismissWithSelection()
859
- }
860
- )
861
- }
862
- }
863
-
864
- @ViewBuilder
865
- private var familyActivityPicker: some View {
866
- FamilyActivityPicker(selection: $selection)
867
- }
868
-
869
- private func dismissWithSelection() {
870
- let appItems: [[String: Any]] = selection.applications.compactMap { selectedApp in
871
- guard let token = selectedApp.token,
872
- let tokenId = encodeSelectionToken(token) else {
873
- return nil
874
- }
875
-
876
- let bundleIdentifier = selectedApp.bundleIdentifier ?? ""
877
- let displayName = selectedApp.localizedDisplayName ?? ""
878
- // String(describing:) on Application sometimes contains the app name
879
- let descriptionString = String(describing: selectedApp)
880
-
881
- // Log everything for debugging
882
- print("[AppBlocker] Application: displayName='\(displayName)' bundleId='\(bundleIdentifier)' description='\(descriptionString)'")
883
-
884
- // Try multiple strategies to get a meaningful name
885
- let resolvedName: String
886
- if !displayName.isEmpty {
887
- resolvedName = displayName
888
- } else if !bundleIdentifier.isEmpty {
889
- // Try to make a readable name from bundle ID
890
- // e.g. "com.instagram.android" -> "Instagram"
891
- let parts = bundleIdentifier.split(separator: ".")
892
- if let lastPart = parts.last {
893
- let name = String(lastPart)
894
- // Capitalize and clean up
895
- resolvedName = name.prefix(1).uppercased() + name.dropFirst()
896
- } else {
897
- resolvedName = bundleIdentifier
898
- }
899
- } else if !descriptionString.isEmpty && descriptionString != "Application()" {
900
- // Try to parse something useful from description
901
- let cleaned = descriptionString
902
- .replacingOccurrences(of: "Application(", with: "")
903
- .replacingOccurrences(of: ")", with: "")
904
- .trimmingCharacters(in: .whitespaces)
905
- resolvedName = cleaned.isEmpty ? "Blocked App" : cleaned
906
- } else {
907
- resolvedName = "Blocked App"
908
- }
909
-
910
- return [
911
- "type": "app",
912
- "token": tokenId,
913
- "bundleIdentifier": bundleIdentifier,
914
- "displayName": resolvedName,
915
- "description": descriptionString
916
- ]
917
- }
918
-
919
- let categoryItems: [[String: Any]] = selection.categoryTokens.compactMap { categoryToken in
920
- guard let tokenId = encodeSelectionCategoryToken(categoryToken) else {
921
- return nil
922
- }
923
-
924
- let descriptionString = String(describing: categoryToken)
925
- let name = resolveCategoryName(categoryToken)
926
- print("[AppBlocker] Category: name='\(name)' description='\(descriptionString)'")
927
-
928
- return [
929
- "type": "category",
930
- "token": tokenId,
931
- "categoryName": name.isEmpty ? "Category" : name
932
- ]
933
- }
934
-
935
- // Serialize the full FamilyActivitySelection for the native view
936
- var selectionBase64 = ""
937
- if let selectionData = try? JSONEncoder().encode(selection) {
938
- selectionBase64 = selectionData.base64EncodedString()
939
- }
940
-
941
- var result: [[String: Any]] = appItems + categoryItems
942
- result.append([
943
- "type": "summary",
944
- "totalApps": selection.applications.count,
945
- "totalCategories": selection.categoryTokens.count,
946
- "selectionData": selectionBase64
947
- ])
948
-
949
- dismissWithResult(result)
950
- }
951
-
952
- private func dismissWithCancel() {
953
- didFinish = true
954
-
955
- DispatchQueue.main.async {
956
- if let rootVC = getRootViewController() {
957
- rootVC.dismiss(animated: true) {
958
- self.promise.reject("PICKER_CANCELLED", "User cancelled Family Activity Picker")
959
- }
960
- }
961
- }
962
- }
963
-
964
- private func encodeSelectionCategoryToken(_ token: ActivityCategoryToken) -> String? {
965
- do {
966
- let data = try JSONEncoder().encode(token)
967
- return data.base64EncodedString()
968
- } catch {
969
- return nil
970
- }
971
- }
972
-
973
- private func resolveCategoryName(_ token: ActivityCategoryToken) -> String {
974
- let raw = String(describing: token)
975
- return raw.isEmpty ? "Category" : raw
976
- }
977
-
978
- private func encodeSelectionToken(_ token: ApplicationToken) -> String? {
979
- do {
980
- let data = try JSONEncoder().encode(token)
981
- return data.base64EncodedString()
982
- } catch {
983
- return nil
984
- }
985
- }
986
-
987
- private func dismissWithResult(_ result: [[String: Any]]) {
988
- didFinish = true
989
-
990
- DispatchQueue.main.async {
991
- if let rootVC = getRootViewController() {
992
- rootVC.dismiss(animated: true) {
993
- self.promise.resolve(result)
994
- }
995
- }
996
- }
997
- }
998
-
999
- private func getRootViewController() -> UIViewController? {
1000
- let scenes = UIApplication.shared.connectedScenes
1001
- let windowScene = scenes.first as? UIWindowScene
1002
- let window = windowScene?.windows.first
1003
- return window?.rootViewController
1004
- }
1005
-
1006
- private func handleInteractiveDismissIfNeeded() {
1007
- guard didAppear, !didFinish else {
1008
- return
1009
- }
1010
-
1011
- didFinish = true
1012
- promise.reject("PICKER_CANCELLED", "User dismissed Family Activity Picker")
1013
- }
1014
- }