mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
## Summary > For architecture decisions, data model details, iOS version strategy, push token flow, and rate limiting — see [technical-brief.pdf](https://github.com/user-attachments/files/26280274/technical-brief.pdf) Adds iOS Live Activities support, letting Home Assistant automations push real-time state to the Lock Screen — washing machine countdowns, EV charging progress, delivery tracking, alarm states, or anything time-sensitive that benefits from glanceable visibility without unlocking the phone. When an automation sends a notification with `live_update: true` in the data payload, the companion app starts a Live Activity instead of (or in addition to) a standard notification banner. Subsequent pushes with the same `tag` update it in-place silently. `clear_notification` + `tag` ends it. Field names (`tag`, `title`, `message`, `progress`, `progress_max`, `chronometer`, `when`, `when_relative`, `notification_icon`, `notification_icon_color`) are intentionally shared with Android's Live Notifications API. Both platforms use the same `live_update: true` trigger — a single YAML block targets iOS 17.2+ and Android 16+ with no platform-specific keys. ```yaml data: title: "Washing Machine" message: "Cycle in progress" data: tag: washer_cycle live_update: true # Android 16+ and iOS 17.2+ progress: 2700 progress_max: 3600 chronometer: true when: 2700 when_relative: true notification_icon: mdi:washing-machine notification_icon_color: "#2196F3" ``` **New files:** - `Sources/Shared/LiveActivity/HALiveActivityAttributes.swift` — the `ActivityAttributes` type. Field names match the Android payload spec. **Struct name and `CodingKeys` are wire-format frozen** — APNs push-to-start payloads reference the Swift type name directly. - `Sources/Shared/LiveActivity/LiveActivityRegistry.swift` — Swift `actor` managing `Activity<HALiveActivityAttributes>` lifecycle. Uses a reservation pattern to prevent duplicate activities when two pushes with the same `tag` arrive simultaneously. - `Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift` — start/update and end `NotificationCommandHandler` implementations, guarded against the `PushProvider` extension process where ActivityKit is unavailable. - `Sources/Extensions/Widgets/LiveActivity/` — `ActivityConfiguration` wrapper, Lock Screen / StandBy view, and compact / minimal / expanded Dynamic Island views. - `Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift` — activity status, active list, privacy disclosure, and 11 debug scenarios for pre-server-side testing. **Modified files:** - `Widgets.swift` — registers `HALiveActivityConfiguration` in all three `WidgetBundle` variants behind `#available(iOSApplicationExtension 17.2, *)` - `NotificationsCommandManager.swift` — registers new handlers; `HandlerClearNotification` now also ends a matching Live Activity when `tag` is present - `HAAPI.swift` — adds `supports_live_activities`, `supports_live_activities_frequent_updates`, `live_activity_push_to_start_token`, `live_activity_push_to_start_apns_environment` to registration payload under a single `#available(iOS 17.2, *)` check - `AppDelegate.swift` — reattaches surviving activities at launch and starts observing push-to-start tokens under a single `#available(iOS 17.2, *)` check - `Info.plist` — `NSSupportsLiveActivities` + `NSSupportsLiveActivitiesFrequentUpdates` - `SettingsItem.swift` / `SettingsView.swift` — Live Activities settings row is gated behind `Current.isTestFlight` and shows a `BetaLabel` badge **Tests:** - *Unit Tests* - 45 new tests (36 handler tests, 9 command manager routing tests). All 490 existing tests continue to pass. - *Device Tests* - I tried to test with my physical device, however I do not have a paid account. Turns out I could not deploy without disabling a lot of entitlements for compilation. Even so, once I did get it deployed and running Live Activities wouldn't show unless some of those settings that I disable were re-enable and leaving me in a particular spot. - I mainly tested with the simulator which is what is shown below ## Screenshots  ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant#1303 ## Link to pull request in push relay repository Relay server: home-assistant/mobile-apps-fcm-push#278 ## Link to pull request in HA core Core: home-assistant/core#166072 ## Any other notes **iOS version gating at 17.2.** The entire feature is gated at `#available(iOS 17.2, *)` — this is the minimum for push-to-start (the primary server-side start mechanism). A single availability check now covers all Live Activity APIs: `supports_live_activities`, `frequentPushesEnabled`, push-to-start token, and all ActivityKit usage. This eliminates the nested 16.2/17.2 check pattern. **Push-to-start (iOS 17.2+) is client-complete.** The token is observed, stored in Keychain, and included in registration payloads. All companion server-side PRs are now open — relay server at home-assistant/mobile-apps-fcm-push#278 and HA core webhook handlers at home-assistant/core#166072. The relay server uses FCM's native `apns.liveActivityToken` support (Firebase Admin SDK v13.5.0+) — no custom APNs client or credentials needed. > **Server-side work** — all PRs now open: > - ~~`supports_live_activities` field handling in device registration~~ ✓ home-assistant/core#166072 > - ~~`mobile_app_live_activity_token` webhook handler~~ ✓ home-assistant/core#166072 > - ~~`mobile_app_live_activity_dismissed` webhook handler~~ ✓ home-assistant/core#166072 > - ~~Relay server: Live Activity delivery via FCM `apns.liveActivityToken`~~ ✓ home-assistant/mobile-apps-fcm-push#278 > - ~~`notify.py` routing: includes Live Activity APNs token alongside FCM token~~ ✓ home-assistant/core#166072 **Live Activities entry in Settings is gated behind TestFlight.** The settings row only appears when `Current.isTestFlight` is true, preventing it from surfacing in a release build before the feature is fully tested. A `BetaLabel` badge is shown alongside the row title. **iPad:** `areActivitiesEnabled` is always `false` on iPad — Apple system restriction. The Settings screen shows "Not available on iPad." The registry silently no-ops. HA receives `supports_live_activities: false` in the device registration for iPad. **`HALiveActivityAttributes` is frozen post-ship.** The struct name appears as `attributes-type` in APNs push-to-start payloads. Renaming it silently breaks all remote starts. The `ContentState` `CodingKeys` are equally frozen — only additions are safe. Both have comments in the source calling this out. **The debug section in Settings is intentional.** Gated behind `#if DEBUG` so it only appears in debug builds — it never ships to TestFlight or the App Store. It exercises the full ActivityKit lifecycle without requiring the server-side chain. **`UNUserNotificationCenter` in tests.** The `clear_notification` + `tag` → Live Activity dismissal path is covered by code review rather than a unit test. `HandlerClearNotification` calls `UNUserNotificationCenter.current().removeDeliveredNotifications` synchronously, which requires a real app bundle and throws `NSInternalInconsistencyException` in the XCTest host. A comment in the test file explains this. **Rate limiting on iOS 18.** Apple throttles Live Activity updates to ~15 seconds between renders. Automations should trigger on state change events, not polling timers. **Related:** - Community discussion: https://github.com/orgs/home-assistant/discussions/84 - Android companion reference: https://github.com/home-assistant/android - Roadmap: https://github.com/home-assistant/roadmap/issues/52 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Bruno Pantaleão Gonçalves <5808343+bgoncal@users.noreply.github.com>
532 lines
17 KiB
Swift
532 lines
17 KiB
Swift
import CoreLocation
|
|
import CoreMotion
|
|
import Foundation
|
|
import KeychainAccess
|
|
import UIKit
|
|
import Version
|
|
|
|
public class SettingsStore {
|
|
let keychain = AppConstants.Keychain
|
|
let prefs = UserDefaults(suiteName: AppConstants.AppGroupID)!
|
|
|
|
/// These will only be posted on the main thread
|
|
public static let webViewRelatedSettingDidChange: Notification.Name = .init("webViewRelatedSettingDidChange")
|
|
public static let menuRelatedSettingDidChange: Notification.Name = .init("menuRelatedSettingDidChange")
|
|
public static let locationRelatedSettingDidChange: Notification.Name = .init("locationRelatedSettingDidChange")
|
|
|
|
public var pushID: String? {
|
|
get {
|
|
prefs.string(forKey: "pushID")
|
|
}
|
|
set {
|
|
prefs.setValue(newValue, forKeyPath: "pushID")
|
|
}
|
|
}
|
|
|
|
public var integrationDeviceID: String {
|
|
let baseString = Current.device.identifierForVendor() ?? deviceID
|
|
|
|
switch Current.appConfiguration {
|
|
case .beta:
|
|
return "beta_" + baseString
|
|
case .debug:
|
|
return "debug_" + baseString
|
|
case .fastlaneSnapshot, .release:
|
|
return baseString
|
|
}
|
|
}
|
|
|
|
public var deviceID: String {
|
|
get {
|
|
keychain["deviceID"] ?? defaultDeviceID
|
|
}
|
|
set {
|
|
keychain["deviceID"] = newValue
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
public var matterLastPreferredNetWorkMacExtendedAddress: String? {
|
|
get {
|
|
keychain["matterLastPreferredNetWorkMacExtendedAddress"]
|
|
}
|
|
set {
|
|
keychain["matterLastPreferredNetWorkMacExtendedAddress"] = newValue
|
|
}
|
|
}
|
|
|
|
public var matterLastPreferredNetWorkActiveOperationalDataset: String? {
|
|
get {
|
|
keychain["matterLastPreferredNetWorkActiveOperationalDataset"]
|
|
}
|
|
set {
|
|
keychain["matterLastPreferredNetWorkActiveOperationalDataset"] = newValue
|
|
}
|
|
}
|
|
|
|
public var matterLastPreferredNetWorkExtendedPANID: String? {
|
|
get {
|
|
keychain["matterLastPreferredNetWorkExtendedPANID"]
|
|
}
|
|
set {
|
|
keychain["matterLastPreferredNetWorkExtendedPANID"] = newValue
|
|
}
|
|
}
|
|
|
|
public func isLocationEnabled(for state: UIApplication.State) -> Bool {
|
|
let authorizationStatus: CLAuthorizationStatus
|
|
|
|
let locationManager = CLLocationManager()
|
|
authorizationStatus = locationManager.authorizationStatus
|
|
|
|
switch authorizationStatus {
|
|
case .authorizedAlways:
|
|
return true
|
|
case .authorizedWhenInUse:
|
|
switch state {
|
|
case .active, .inactive:
|
|
return true
|
|
case .background:
|
|
return false
|
|
@unknown default:
|
|
return false
|
|
}
|
|
case .denied, .notDetermined, .restricted:
|
|
return false
|
|
@unknown default:
|
|
return false
|
|
}
|
|
}
|
|
#endif
|
|
|
|
public struct PageZoom: CaseIterable, Equatable, CustomStringConvertible, Hashable {
|
|
public let zoom: Int
|
|
|
|
init?(preference: Int) {
|
|
guard Self.allCases.contains(where: { $0.zoom == preference }) else {
|
|
// in case one of the options causes problems, removing it from allCases will kill it
|
|
Current.Log.info("disregarding zoom preference for \(preference)")
|
|
return nil
|
|
}
|
|
|
|
self.zoom = preference
|
|
}
|
|
|
|
init(_ zoom: IntegerLiteralType) {
|
|
self.zoom = zoom
|
|
}
|
|
|
|
public var description: String {
|
|
let zoomString = String(format: "%d%%", zoom)
|
|
|
|
if zoom == 100 {
|
|
return L10n.SettingsDetails.General.PageZoom.default(zoomString)
|
|
} else {
|
|
return zoomString
|
|
}
|
|
}
|
|
|
|
public var viewScaleValue: String {
|
|
String(format: "%.02f", CGFloat(zoom) / 100.0)
|
|
}
|
|
|
|
public static let `default`: PageZoom = .init(100)
|
|
|
|
public static let allCases: [PageZoom] = [
|
|
// similar zooms to Safari, but with nothing above 200%
|
|
.init(50), .init(75), .init(85),
|
|
.init(100), .init(115), .init(125), .init(150), .init(175),
|
|
.init(200),
|
|
]
|
|
}
|
|
|
|
public var pageZoom: PageZoom {
|
|
get {
|
|
if let pageZoom = PageZoom(preference: prefs.integer(forKey: "page_zoom")) {
|
|
return pageZoom
|
|
} else {
|
|
return .default
|
|
}
|
|
}
|
|
set {
|
|
precondition(Thread.isMainThread)
|
|
prefs.set(newValue.zoom, forKey: "page_zoom")
|
|
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public var pinchToZoom: Bool {
|
|
get {
|
|
prefs.bool(forKey: "pinchToZoom")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "pinchToZoom")
|
|
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public var restoreLastURL: Bool {
|
|
get {
|
|
if let value = prefs.object(forKey: "restoreLastURL") as? NSNumber {
|
|
return value.boolValue
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "restoreLastURL")
|
|
}
|
|
}
|
|
|
|
public var fullScreen: Bool {
|
|
get {
|
|
prefs.bool(forKey: "fullScreen")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "fullScreen")
|
|
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public var edgeToEdge: Bool {
|
|
get {
|
|
prefs.bool(forKey: "edgeToEdge_experimental")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "edgeToEdge_experimental")
|
|
NotificationCenter.default.post(name: Self.webViewRelatedSettingDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public var refreshWebViewAfterInactive: Bool {
|
|
get {
|
|
if let value = prefs.object(forKey: "refreshWebViewAfterInactive") as? NSNumber {
|
|
return value.boolValue
|
|
} else {
|
|
return true // Default to ON
|
|
}
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "refreshWebViewAfterInactive")
|
|
}
|
|
}
|
|
|
|
public var macNativeFeaturesOnly: Bool {
|
|
get {
|
|
prefs.bool(forKey: "macNativeFeaturesOnly")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "macNativeFeaturesOnly")
|
|
}
|
|
}
|
|
|
|
/// Whether the one-time Live Activity lock screen privacy disclosure has been shown.
|
|
/// Set to true after the first Live Activity is started; never reset.
|
|
public var hasSeenLiveActivityDisclosure: Bool {
|
|
get {
|
|
prefs.bool(forKey: "hasSeenLiveActivityDisclosure")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "hasSeenLiveActivityDisclosure")
|
|
}
|
|
}
|
|
|
|
/// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it
|
|
public var migratedOptInLocalPush: Bool {
|
|
get {
|
|
prefs.bool(forKey: "migratedOptInLocalPush")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "migratedOptInLocalPush")
|
|
}
|
|
}
|
|
|
|
public var periodicUpdateInterval: TimeInterval? {
|
|
get {
|
|
if prefs.object(forKey: "periodicUpdateInterval") == nil {
|
|
return 300.0
|
|
} else {
|
|
let doubleValue = prefs.double(forKey: "periodicUpdateInterval")
|
|
return doubleValue > 0 ? doubleValue : nil
|
|
}
|
|
}
|
|
set {
|
|
if let newValue {
|
|
precondition(newValue > 0)
|
|
prefs.set(newValue, forKey: "periodicUpdateInterval")
|
|
} else {
|
|
prefs.set(-1, forKey: "periodicUpdateInterval")
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct Privacy {
|
|
public var messaging: Bool
|
|
public var crashes: Bool
|
|
public var analytics: Bool
|
|
public var alerts: Bool
|
|
public var updates: Bool
|
|
public var updatesIncludeBetas: Bool
|
|
|
|
static func key(for keyPath: KeyPath<Privacy, Bool>) -> String {
|
|
switch keyPath {
|
|
case \.messaging: return "messagingEnabled"
|
|
case \.crashes: return "crashesEnabled"
|
|
case \.analytics: return "analyticsEnabled"
|
|
case \.alerts: return "alertsEnabled"
|
|
case \.updates: return "updateCheckingEnabled"
|
|
case \.updatesIncludeBetas: return "updatesIncludeBetas"
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
static func `default`(for keyPath: KeyPath<Privacy, Bool>) -> Bool {
|
|
switch keyPath {
|
|
case \.messaging: return true
|
|
case \.crashes: return false
|
|
case \.analytics: return false
|
|
case \.alerts: return true
|
|
case \.updates: return true
|
|
case \.updatesIncludeBetas: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
public var privacy: Privacy {
|
|
get {
|
|
func boolValue(for keyPath: KeyPath<Privacy, Bool>) -> Bool {
|
|
let key = Privacy.key(for: keyPath)
|
|
if prefs.object(forKey: key) == nil {
|
|
// value never set, use the default for this one
|
|
return Privacy.default(for: keyPath)
|
|
}
|
|
return prefs.bool(forKey: key)
|
|
}
|
|
|
|
return .init(
|
|
messaging: boolValue(for: \.messaging),
|
|
crashes: boolValue(for: \.crashes),
|
|
analytics: boolValue(for: \.analytics),
|
|
alerts: boolValue(for: \.alerts),
|
|
updates: boolValue(for: \.updates),
|
|
updatesIncludeBetas: boolValue(for: \.updatesIncludeBetas)
|
|
)
|
|
}
|
|
set {
|
|
prefs.set(newValue.messaging, forKey: Privacy.key(for: \.messaging))
|
|
prefs.set(newValue.crashes, forKey: Privacy.key(for: \.crashes))
|
|
prefs.set(newValue.analytics, forKey: Privacy.key(for: \.analytics))
|
|
prefs.set(newValue.alerts, forKey: Privacy.key(for: \.alerts))
|
|
prefs.set(newValue.updates, forKey: Privacy.key(for: \.updates))
|
|
prefs.set(newValue.updatesIncludeBetas, forKey: Privacy.key(for: \.updatesIncludeBetas))
|
|
Current.Log.info("privacy updated to \(newValue)")
|
|
}
|
|
}
|
|
|
|
public enum LocationVisibility: String, CaseIterable {
|
|
case dock
|
|
case dockAndMenuBar
|
|
case menuBar
|
|
|
|
public var isStatusItemVisible: Bool {
|
|
switch self {
|
|
case .dockAndMenuBar, .menuBar: return true
|
|
case .dock: return false
|
|
}
|
|
}
|
|
|
|
public var isDockVisible: Bool {
|
|
switch self {
|
|
case .dockAndMenuBar, .dock: return true
|
|
case .menuBar: return false
|
|
}
|
|
}
|
|
|
|
public var title: String {
|
|
switch self {
|
|
case .dock: return L10n.SettingsDetails.General.Visibility.Options.dock
|
|
case .dockAndMenuBar: return L10n.SettingsDetails.General.Visibility.Options.dockAndMenuBar
|
|
case .menuBar: return L10n.SettingsDetails.General.Visibility.Options.menuBar
|
|
}
|
|
}
|
|
}
|
|
|
|
public var locationVisibility: LocationVisibility {
|
|
get {
|
|
prefs.string(forKey: "locationVisibility").flatMap(LocationVisibility.init(rawValue:)) ?? .dock
|
|
}
|
|
set {
|
|
prefs.set(newValue.rawValue, forKey: "locationVisibility")
|
|
NotificationCenter.default.post(
|
|
name: Self.menuRelatedSettingDidChange,
|
|
object: nil,
|
|
userInfo: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
public var menuItemTemplate: (server: Server, template: String)? {
|
|
get {
|
|
let server: Server?
|
|
|
|
if let serverIdentifier = prefs.string(forKey: "menuItemTemplate-server"),
|
|
let configured = Current.servers.server(forServerIdentifier: serverIdentifier) {
|
|
server = configured
|
|
} else {
|
|
// backwards compatibility to before servers, or if the server was deleted
|
|
server = Current.servers.all.first
|
|
}
|
|
|
|
if let server {
|
|
return (server, prefs.string(forKey: "menuItemTemplate") ?? "")
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
set {
|
|
prefs.setValue(newValue?.0.identifier.rawValue, forKey: "menuItemTemplate-server")
|
|
prefs.setValue(newValue?.1, forKey: "menuItemTemplate")
|
|
NotificationCenter.default.post(
|
|
name: Self.menuRelatedSettingDidChange,
|
|
object: nil,
|
|
userInfo: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
public struct LocationSource {
|
|
public var zone: Bool
|
|
public var backgroundFetch: Bool
|
|
public var significantLocationChange: Bool
|
|
public var pushNotifications: Bool
|
|
|
|
static func key(for keyPath: KeyPath<LocationSource, Bool>) -> String {
|
|
switch keyPath {
|
|
case \.zone: return "locationUpdateOnZone"
|
|
case \.backgroundFetch: return "locationUpdateOnBackgroundFetch"
|
|
case \.significantLocationChange: return "locationUpdateOnSignificant"
|
|
case \.pushNotifications: return "locationUpdateOnNotification"
|
|
default: return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
public var locationSources: LocationSource {
|
|
get {
|
|
func boolValue(for keyPath: KeyPath<LocationSource, Bool>) -> Bool {
|
|
let key = LocationSource.key(for: keyPath)
|
|
if prefs.object(forKey: key) == nil {
|
|
// default to enabled for location source settings
|
|
return true
|
|
}
|
|
return prefs.bool(forKey: key)
|
|
}
|
|
|
|
return .init(
|
|
zone: boolValue(for: \.zone),
|
|
backgroundFetch: boolValue(for: \.backgroundFetch),
|
|
significantLocationChange: boolValue(for: \.significantLocationChange),
|
|
pushNotifications: boolValue(for: \.pushNotifications)
|
|
)
|
|
}
|
|
set {
|
|
prefs.set(newValue.zone, forKey: LocationSource.key(for: \.zone))
|
|
prefs.set(newValue.backgroundFetch, forKey: LocationSource.key(for: \.backgroundFetch))
|
|
prefs.set(newValue.significantLocationChange, forKey: LocationSource.key(for: \.significantLocationChange))
|
|
prefs.set(newValue.pushNotifications, forKey: LocationSource.key(for: \.pushNotifications))
|
|
Current.Log.info("location sources updated to \(newValue)")
|
|
NotificationCenter.default.post(name: Self.locationRelatedSettingDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
public var clearBadgeAutomatically: Bool {
|
|
get {
|
|
if let value = prefs.object(forKey: "clearBadgeAutomatically") as? NSNumber {
|
|
return value.boolValue
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "clearBadgeAutomatically")
|
|
}
|
|
}
|
|
|
|
public var widgetAuthenticityToken: String {
|
|
let key = "widgetAuthenticityToken"
|
|
|
|
if let existing = prefs.string(forKey: key) {
|
|
return existing
|
|
} else {
|
|
let string = UUID().uuidString
|
|
prefs.set(string, forKey: key)
|
|
return string
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
public var gestures: [AppGesture: HAGestureAction] {
|
|
get {
|
|
guard let data = prefs.data(forKey: "gesturesSettings"),
|
|
let decodedGestures = try? JSONDecoder().decode([AppGesture: HAGestureAction].self, from: data) else {
|
|
Current.Log.error("Failed to decode gestures from settings")
|
|
return .defaultGestures
|
|
}
|
|
return decodedGestures
|
|
}
|
|
set {
|
|
do {
|
|
let encoded = try JSONEncoder().encode(newValue)
|
|
prefs.set(encoded, forKey: "gesturesSettings")
|
|
} catch {
|
|
Current.Log.error("Failed to encode gestures for settings, error \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Debug settings
|
|
|
|
/// Debug options to receive local notifications when something goes wrong
|
|
/// e.g. location update in background fails
|
|
public var receiveDebugNotifications: Bool {
|
|
get {
|
|
prefs.bool(forKey: "receiveDebugNotifications")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "receiveDebugNotifications")
|
|
}
|
|
}
|
|
|
|
/// Debug option to enable toasts handled by the app instead of the web frontend
|
|
public var toastsHandledByApp: Bool {
|
|
get {
|
|
prefs.bool(forKey: "toastsHandledByApp")
|
|
}
|
|
set {
|
|
prefs.set(newValue, forKey: "toastsHandledByApp")
|
|
}
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
private var defaultDeviceID: String {
|
|
let baseID = removeSpecialCharsFromString(text: Current.device.deviceName())
|
|
.replacingOccurrences(of: " ", with: "_")
|
|
.lowercased()
|
|
|
|
if Current.appConfiguration != .release {
|
|
return "\(baseID)_\(Current.appConfiguration.description.lowercased())"
|
|
}
|
|
|
|
return baseID
|
|
}
|
|
|
|
private func removeSpecialCharsFromString(text: String) -> String {
|
|
let okayChars: Set<Character> =
|
|
Set("abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890")
|
|
return String(text.filter { okayChars.contains($0) })
|
|
}
|
|
}
|