Skip to content

Commit 108fb07

Browse files
committed
Feature: Add Kiosk Mode core manager and integration
Part 2 of Kiosk Mode implementation. Depends on PR home-assistant#4197. ## KioskModeManager (~970 lines) Central coordinator for all kiosk functionality: - Enable/disable kiosk mode - Screen state management (on, dimmed, screensaver) - Brightness control with day/night scheduling - Idle timer and activity tracking - Dashboard navigation - Entity trigger subscriptions - HA WebSocket integration ## Security (~760 lines) - SecurityManager: PIN and biometric authentication - SettingsManager: Settings persistence ## Dashboard (~360 lines) - DashboardManager: Dashboard rotation and scheduling ## Camera Detection (~320 lines) - CameraDetectionManager: Motion and presence detection using front camera ## App Launcher (~360 lines) - AppLauncherManager: Launch external apps, return handling ## WebViewController Integration (~565 lines) - WebViewController+Kiosk: Kiosk overlay management - Modified WebViewController: Made properties internal for kiosk access
1 parent b777610 commit 108fb07

File tree

9 files changed

+3434
-17
lines changed

9 files changed

+3434
-17
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 64 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import Combine
2+
import Foundation
3+
import Shared
4+
import UIKit
5+
import UserNotifications
6+
7+
// MARK: - App Launcher Manager
8+
9+
/// Manages external app launching, return timeout, and away state tracking
10+
@MainActor
11+
public final class AppLauncherManager: ObservableObject {
12+
// MARK: - Singleton
13+
14+
public static let shared = AppLauncherManager()
15+
16+
// MARK: - Published State
17+
18+
/// Current app state (active, away, background)
19+
@Published public private(set) var appState: AppState = .active
20+
21+
/// Whether the app is currently "away" (another app was launched)
22+
@Published public private(set) var isAway: Bool = false
23+
24+
/// The app that was launched (if tracking)
25+
@Published public private(set) var launchedApp: AppShortcut?
26+
27+
/// Time when the app was launched
28+
@Published public private(set) var launchTime: Date?
29+
30+
/// Time remaining on return timeout (if active)
31+
@Published public private(set) var returnTimeRemaining: TimeInterval = 0
32+
33+
// MARK: - Notifications
34+
35+
public static let appStateDidChangeNotification = Notification.Name("AppLauncherManager.appStateDidChange")
36+
public static let didReturnFromAppNotification = Notification.Name("AppLauncherManager.didReturnFromApp")
37+
38+
// MARK: - Callbacks
39+
40+
/// Called when return timeout expires
41+
public var onReturnTimeoutExpired: (() -> Void)?
42+
43+
/// Called when user returns from launched app
44+
public var onReturnFromApp: ((AppShortcut?) -> Void)?
45+
46+
// MARK: - Private
47+
48+
private var settings: KioskSettings { KioskModeManager.shared.settings }
49+
private var returnTimer: Timer?
50+
private var countdownTimer: Timer?
51+
private var sceneObservers: [NSObjectProtocol] = []
52+
53+
// MARK: - Initialization
54+
55+
private init() {
56+
setupSceneObservers()
57+
requestNotificationPermission()
58+
}
59+
60+
deinit {
61+
sceneObservers.forEach { NotificationCenter.default.removeObserver($0) }
62+
}
63+
64+
// MARK: - Public Methods
65+
66+
/// Launch an app by URL scheme
67+
public func launchApp(urlScheme: String, shortcut: AppShortcut? = nil) -> Bool {
68+
guard let url = URL(string: urlScheme) else {
69+
Current.Log.warning("Invalid URL scheme: \(urlScheme)")
70+
return false
71+
}
72+
73+
return launchApp(url: url, shortcut: shortcut)
74+
}
75+
76+
/// Launch an app by URL
77+
public func launchApp(url: URL, shortcut: AppShortcut? = nil) -> Bool {
78+
guard UIApplication.shared.canOpenURL(url) else {
79+
Current.Log.warning("Cannot open URL: \(url)")
80+
return false
81+
}
82+
83+
Current.Log.info("Launching app: \(url.absoluteString)")
84+
85+
// Record the launch
86+
launchedApp = shortcut
87+
launchTime = Date()
88+
isAway = true
89+
appState = .away
90+
91+
// Start return timeout if configured
92+
startReturnTimeout()
93+
94+
// Open the URL
95+
UIApplication.shared.open(url, options: [:]) { success in
96+
if !success {
97+
Current.Log.warning("Failed to open URL: \(url)")
98+
Task { @MainActor in
99+
self.cancelAwayState()
100+
}
101+
}
102+
}
103+
104+
// Record activity
105+
KioskModeManager.shared.recordActivity(source: "app_launch")
106+
107+
// Notify
108+
NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil)
109+
110+
return true
111+
}
112+
113+
/// Launch an app shortcut
114+
public func launchShortcut(_ shortcut: AppShortcut) -> Bool {
115+
launchApp(urlScheme: shortcut.urlScheme, shortcut: shortcut)
116+
}
117+
118+
/// Return to Home Assistant (called when app becomes active again)
119+
public func handleReturn() {
120+
guard isAway else { return }
121+
122+
Current.Log.info("Returned from app: \(launchedApp?.name ?? "unknown")")
123+
124+
cancelReturnTimeout()
125+
126+
let returnedFromApp = launchedApp
127+
launchedApp = nil
128+
launchTime = nil
129+
isAway = false
130+
appState = .active
131+
returnTimeRemaining = 0
132+
133+
// Notify
134+
onReturnFromApp?(returnedFromApp)
135+
NotificationCenter.default.post(
136+
name: Self.didReturnFromAppNotification,
137+
object: nil,
138+
userInfo: ["app": returnedFromApp as Any]
139+
)
140+
NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil)
141+
142+
// Record activity
143+
KioskModeManager.shared.recordActivity(source: "app_return")
144+
}
145+
146+
/// Cancel away state without triggering return callbacks
147+
public func cancelAwayState() {
148+
cancelReturnTimeout()
149+
launchedApp = nil
150+
launchTime = nil
151+
isAway = false
152+
appState = .active
153+
returnTimeRemaining = 0
154+
}
155+
156+
/// Get all configured app shortcuts
157+
public var shortcuts: [AppShortcut] {
158+
settings.appShortcuts
159+
}
160+
161+
/// Check if an app can be launched
162+
public func canLaunch(urlScheme: String) -> Bool {
163+
guard let url = URL(string: urlScheme) else { return false }
164+
return UIApplication.shared.canOpenURL(url)
165+
}
166+
167+
/// Get duration since app was launched
168+
public var awayDuration: TimeInterval? {
169+
guard let launchTime else { return nil }
170+
return Date().timeIntervalSince(launchTime)
171+
}
172+
173+
// MARK: - Private Methods
174+
175+
private func setupSceneObservers() {
176+
// Observe app becoming active (returning from another app)
177+
let activeObserver = NotificationCenter.default.addObserver(
178+
forName: UIScene.didActivateNotification,
179+
object: nil,
180+
queue: .main
181+
) { [weak self] _ in
182+
self?.handleSceneActivation()
183+
}
184+
sceneObservers.append(activeObserver)
185+
186+
// Observe app going to background
187+
let backgroundObserver = NotificationCenter.default.addObserver(
188+
forName: UIScene.didEnterBackgroundNotification,
189+
object: nil,
190+
queue: .main
191+
) { [weak self] _ in
192+
self?.handleSceneBackground()
193+
}
194+
sceneObservers.append(backgroundObserver)
195+
196+
// Observe app becoming inactive
197+
let inactiveObserver = NotificationCenter.default.addObserver(
198+
forName: UIScene.willDeactivateNotification,
199+
object: nil,
200+
queue: .main
201+
) { [weak self] _ in
202+
self?.handleSceneDeactivation()
203+
}
204+
sceneObservers.append(inactiveObserver)
205+
}
206+
207+
private func handleSceneActivation() {
208+
if isAway {
209+
handleReturn()
210+
} else if appState == .background {
211+
appState = .active
212+
NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil)
213+
}
214+
}
215+
216+
private func handleSceneBackground() {
217+
if !isAway {
218+
appState = .background
219+
NotificationCenter.default.post(name: Self.appStateDidChangeNotification, object: nil)
220+
}
221+
}
222+
223+
private func handleSceneDeactivation() {
224+
// This fires when the app is about to go to background
225+
// Don't change state here - wait for didEnterBackground
226+
}
227+
228+
// MARK: - Return Timeout
229+
230+
private func startReturnTimeout() {
231+
let timeout = settings.appLaunchReturnTimeout
232+
guard timeout > 0 else { return }
233+
234+
cancelReturnTimeout()
235+
returnTimeRemaining = timeout
236+
237+
Current.Log.info("Starting return timeout: \(Int(timeout)) seconds")
238+
239+
// Countdown timer for UI updates
240+
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
241+
Task { @MainActor in
242+
guard let self else { return }
243+
if self.returnTimeRemaining > 0 {
244+
self.returnTimeRemaining -= 1
245+
}
246+
}
247+
}
248+
249+
// Main timeout timer
250+
returnTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
251+
Task { @MainActor in
252+
self?.handleReturnTimeout()
253+
}
254+
}
255+
256+
// Schedule local notification
257+
scheduleReturnNotification(timeout: timeout)
258+
}
259+
260+
private func cancelReturnTimeout() {
261+
returnTimer?.invalidate()
262+
returnTimer = nil
263+
countdownTimer?.invalidate()
264+
countdownTimer = nil
265+
returnTimeRemaining = 0
266+
267+
// Cancel pending notification
268+
cancelReturnNotification()
269+
}
270+
271+
private func handleReturnTimeout() {
272+
Current.Log.info("Return timeout expired")
273+
274+
cancelReturnTimeout()
275+
onReturnTimeoutExpired?()
276+
277+
// The notification will alert the user to return
278+
}
279+
280+
// MARK: - Local Notifications
281+
282+
private func requestNotificationPermission() {
283+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
284+
if let error {
285+
Current.Log.error("Failed to request notification permission: \(error)")
286+
} else if granted {
287+
Current.Log.info("Notification permission granted for return reminders")
288+
}
289+
}
290+
}
291+
292+
private func scheduleReturnNotification(timeout: TimeInterval) {
293+
let content = UNMutableNotificationContent()
294+
content.title = "Home Assistant"
295+
content.body = "Time to return to your dashboard"
296+
content.sound = .default
297+
content.categoryIdentifier = "KIOSK_RETURN"
298+
299+
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeout, repeats: false)
300+
let request = UNNotificationRequest(
301+
identifier: "kiosk.return.reminder",
302+
content: content,
303+
trigger: trigger
304+
)
305+
306+
UNUserNotificationCenter.current().add(request) { error in
307+
if let error {
308+
Current.Log.error("Failed to schedule return notification: \(error)")
309+
}
310+
}
311+
}
312+
313+
private func cancelReturnNotification() {
314+
UNUserNotificationCenter.current().removePendingNotificationRequests(
315+
withIdentifiers: ["kiosk.return.reminder"]
316+
)
317+
}
318+
}
319+
320+
// MARK: - App Launcher Command Support
321+
322+
extension AppLauncherManager {
323+
/// Handle launch app command from HA notification
324+
public func handleLaunchCommand(urlScheme: String) -> Bool {
325+
// Find matching shortcut if exists
326+
let shortcut = settings.appShortcuts.first { $0.urlScheme == urlScheme }
327+
return launchApp(urlScheme: urlScheme, shortcut: shortcut)
328+
}
329+
}
330+
331+
// MARK: - Sensor Attributes
332+
333+
extension AppLauncherManager {
334+
/// Sensor state for HA reporting
335+
public var sensorState: String {
336+
appState.rawValue
337+
}
338+
339+
/// Sensor attributes for HA reporting
340+
public var sensorAttributes: [String: Any] {
341+
var attrs: [String: Any] = [
342+
"is_away": isAway,
343+
]
344+
345+
if let launchedApp {
346+
attrs["launched_app"] = launchedApp.name
347+
attrs["launched_scheme"] = launchedApp.urlScheme
348+
}
349+
350+
if let launchTime {
351+
attrs["launch_time"] = ISO8601DateFormatter().string(from: launchTime)
352+
attrs["away_duration_seconds"] = Int(awayDuration ?? 0)
353+
}
354+
355+
if returnTimeRemaining > 0 {
356+
attrs["return_timeout_remaining"] = Int(returnTimeRemaining)
357+
}
358+
359+
return attrs
360+
}
361+
}

0 commit comments

Comments
 (0)