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.
- package/.github/workflows/publish.yml +42 -0
- package/README.md +2 -0
- package/docs/APPLE_DEVELOPER_SETUP.md +161 -0
- package/package.json +1 -1
- package/ios/ExpoAppBlocker.podspec +0 -21
- package/ios/ExpoAppBlockerConfig.swift +0 -19
- package/ios/ExpoAppBlockerModule.swift +0 -1014
|
@@ -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.
|
|
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
|
-
}
|