expo-app-blocker 0.1.4 → 0.1.5
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/README.md +6 -3
- package/ios/ExpoAppBlocker.podspec +21 -0
- package/ios/ExpoAppBlockerConfig.swift +19 -0
- package/ios/ExpoAppBlockerModule.swift +1014 -0
- package/package.json +1 -1
- package/plugin/src/index.js +3 -1
- package/src/ExpoAppBlocker.types.ts +6 -2
package/README.md
CHANGED
|
@@ -72,7 +72,8 @@ Add the plugin to your `app.json`:
|
|
|
72
72
|
"subtitle": "{appName} is blocked.",
|
|
73
73
|
"primaryButtonLabel": "Earn Free Time",
|
|
74
74
|
"secondaryButtonLabel": "Not now",
|
|
75
|
-
"primaryButtonColor": "#
|
|
75
|
+
"primaryButtonColor": "#fb6107",
|
|
76
|
+
"background": "#f6f6f6",
|
|
76
77
|
"icon": "./assets/shield-icon.png"
|
|
77
78
|
}
|
|
78
79
|
}
|
|
@@ -91,8 +92,10 @@ Add the plugin to your `app.json`:
|
|
|
91
92
|
| `ios.shield.subtitle` | `string` | `"{appName} is blocked."` | Shield subtitle. `{appName}` is replaced with the blocked app name |
|
|
92
93
|
| `ios.shield.primaryButtonLabel` | `string` | `"Earn Free Time"` | Primary button text |
|
|
93
94
|
| `ios.shield.secondaryButtonLabel` | `string\|null` | `"Not now"` | Secondary button text. Set to `null` to hide |
|
|
94
|
-
| `ios.shield.primaryButtonColor` | `string` | `"#
|
|
95
|
-
| `ios.shield.
|
|
95
|
+
| `ios.shield.primaryButtonColor` | `string` | `"#fb6107"` | Primary button background color (hex) |
|
|
96
|
+
| `ios.shield.titleColor` | `string` | `"#111111"` | Title text color (hex) |
|
|
97
|
+
| `ios.shield.subtitleColor` | `string` | `"#737373"` | Subtitle text color (hex) |
|
|
98
|
+
| `ios.shield.background` | `"blur"\|string` | `"blur"` | `"blur"` for frosted glass effect, or a hex color (e.g. `"#f6f6f6"`) for a solid background |
|
|
96
99
|
| `ios.shield.icon` | `string` | SF Symbol | Path to custom shield icon PNG (relative to project root, e.g. `"./assets/shield-icon.png"`) |
|
|
97
100
|
| `android.notificationTitle` | `string` | `"App Blocked"` | Notification title |
|
|
98
101
|
| `android.notificationText` | `string` | `"{appName} is blocked."` | Notification text |
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,1014 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-app-blocker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
package/plugin/src/index.js
CHANGED
|
@@ -156,7 +156,9 @@ function withAppBlockerIOS(config, pluginConfig) {
|
|
|
156
156
|
const primaryColor = hexToRgb(shield.primaryButtonColor || "#fb6107");
|
|
157
157
|
const titleColor = hexToRgb(shield.titleColor || "#111111");
|
|
158
158
|
const subtitleColor = hexToRgb(shield.subtitleColor || "#737373");
|
|
159
|
-
|
|
159
|
+
// background: "blur" (default) or a hex color string
|
|
160
|
+
const bgRaw = shield.background ?? shield.backgroundColor ?? null;
|
|
161
|
+
const bgColor = (bgRaw && bgRaw !== "blur") ? hexToRgb(bgRaw) : null;
|
|
160
162
|
|
|
161
163
|
// All placeholder replacements
|
|
162
164
|
const replacements = {
|
|
@@ -80,8 +80,12 @@ export interface ShieldConfig {
|
|
|
80
80
|
titleColor?: string;
|
|
81
81
|
/** Subtitle text color (hex). Default: "#737373" */
|
|
82
82
|
subtitleColor?: string;
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Background style. Two modes:
|
|
85
|
+
* - "blur" (default): Frosted glass effect over the blocked app (iOS systemThickMaterial)
|
|
86
|
+
* - A hex color string (e.g. "#f6f6f6"): Solid color background
|
|
87
|
+
*/
|
|
88
|
+
background?: "blur" | string;
|
|
85
89
|
/** Path to shield icon image (PNG). Optional. */
|
|
86
90
|
icon?: string;
|
|
87
91
|
}
|