mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-16 13:26:27 -05:00
## Summary Reopening kiosk mode PR (previously #4218) after addressing all review feedback from @bgoncal. Apologies for the delay — some family matters pulled me away, but I'm excited to get this implemented and am fully committed to seeing it through. This PR adds the foundational infrastructure for **kiosk mode** — a feature designed for wall-mounted iPad displays running Home Assistant dashboards. ### What's included: - **KioskModeManager** — Central coordinator singleton with KioskModeObserver protocol (no NotificationCenter) - **KioskSettings** — Comprehensive settings model with GRDB persistence (camelCase columns, Codable) - **Screensaver system** — Clock screensaver with 4 styles, pixel shift for OLED burn-in prevention - **Secret exit gesture** — Configurable corner tap to access settings when locked down - **Status bar hiding** — StatusBarForwardingNavigationController for proper UIKit integration - **Settings UI** — SwiftUI settings accessible via Settings menu and secret gesture - **28 unit tests** — Covering settings serialization, time logic, orientations, enums ### Changes since #4218 (all review feedback addressed): - ✅ Fixed settings modal dismissal on iOS 15 (UINavigationController + explicit onDismiss closure) - ✅ Fixed status bar hiding (StatusBarForwardingNavigationController + statusBarView integration) - ✅ Removed non-functional settings (navigationLockdown, TouchFeedbackManager, IconMapper) - ✅ Replaced `Date()` with `Current.date()` for testability - ✅ GRDB persistence follows CarPlayConfig/WatchConfig pattern with DatabaseTableProtocol - ✅ All strings localized via L10n (SwiftGen) - ✅ SFSafeSymbols used throughout (no string-based systemName) - ✅ KioskModeObserver protocol (no NotificationCenter) - ✅ Kiosk files prefixed with `Kiosk` - ✅ UIKit screensaver container (required for status bar/home indicator control) - ✅ macCatalyst filtered out - ✅ Merged with latest upstream/main (adapted to Frontend/ directory restructure) - ✅ Idle timer pauses when settings view is open - ✅ TODO comments (no PR-specific comments) ### Testing Instructions 1. **Settings → Companion App → Kiosk Mode** → Enable "Enable Kiosk Mode" 2. Set screensaver timeout to **30 seconds** for quick testing 3. Wait for screensaver → tap to wake 4. Use secret gesture (default: 3 taps in top-left corner) to access settings ### Previous review context All feedback from @bgoncal in #4218 has been addressed. See that PR for full discussion history. ## Test plan - [ ] Enable/disable kiosk mode from Settings - [ ] Screensaver activates after timeout - [ ] Tap to wake from screensaver - [ ] Secret gesture opens settings - [ ] Status bar hides when kiosk mode active (full-screen iPad only) - [ ] Device authentication toggle works - [ ] Settings persist across app restart - [ ] Clock screensaver styles (large, minimal, digital, analog) - [ ] Pixel shift moves clock position periodically - [ ] Kiosk entry hidden on macCatalyst 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Nick Stefanelli <nstefanelli@users.noreply.github.com>
192 lines
7.2 KiB
Swift
192 lines
7.2 KiB
Swift
import GRDB
|
|
@testable import Shared
|
|
import Testing
|
|
|
|
@Suite("Database Migration Tests")
|
|
struct DatabaseMigrationTests {
|
|
/// Helper table class for migration testing
|
|
class TestTable: DatabaseTableProtocol {
|
|
var tableName: String
|
|
var definedColumns: [String]
|
|
|
|
init(tableName: String, definedColumns: [String]) {
|
|
self.tableName = tableName
|
|
self.definedColumns = definedColumns
|
|
}
|
|
|
|
func createIfNeeded(database: DatabaseQueue) throws {
|
|
let shouldCreateTable = try database.read { db in
|
|
try !db.tableExists(tableName)
|
|
}
|
|
if shouldCreateTable {
|
|
try database.write { db in
|
|
try db.create(table: tableName) { t in
|
|
// Create all defined columns
|
|
for columnName in definedColumns {
|
|
t.column(columnName, .text)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
try migrateColumns(database: database)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("Add new columns to existing table")
|
|
func addNewColumns() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create initial table with 2 columns
|
|
let initialTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
|
|
try initialTable.createIfNeeded(database: database)
|
|
|
|
// Verify initial columns
|
|
var columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 2)
|
|
#expect(columns.contains("column1"))
|
|
#expect(columns.contains("column2"))
|
|
|
|
// Create updated table definition with 3 columns (added column3)
|
|
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2", "column3"])
|
|
try updatedTable.createIfNeeded(database: database)
|
|
|
|
// Verify column was added
|
|
columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 3, "Should have 3 columns after adding column3")
|
|
#expect(columns.contains("column1"))
|
|
#expect(columns.contains("column2"))
|
|
#expect(columns.contains("column3"))
|
|
}
|
|
|
|
@Test("Skip already existing columns")
|
|
func skipExistingColumns() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create table with 2 columns
|
|
let table = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Try to create the same table again (should skip since columns already exist)
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Verify still only 2 columns
|
|
let columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 2)
|
|
#expect(columns.contains("column1"))
|
|
#expect(columns.contains("column2"))
|
|
}
|
|
|
|
@Test("Remove obsolete columns")
|
|
func removeObsoleteColumns() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create initial table with 3 columns
|
|
let initialTable = TestTable(
|
|
tableName: "testTable",
|
|
definedColumns: ["column1", "column2", "obsoleteColumn"]
|
|
)
|
|
try initialTable.createIfNeeded(database: database)
|
|
|
|
// Verify initial columns
|
|
var columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 3)
|
|
#expect(columns.contains("obsoleteColumn"))
|
|
|
|
// Create updated table definition without obsoleteColumn
|
|
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
|
|
try updatedTable.createIfNeeded(database: database)
|
|
|
|
// Verify obsolete column was removed
|
|
columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 2, "Should have 2 columns after removing obsoleteColumn")
|
|
#expect(columns.contains("column1"))
|
|
#expect(columns.contains("column2"))
|
|
#expect(!columns.contains("obsoleteColumn"), "obsoleteColumn should have been removed")
|
|
}
|
|
|
|
@Test("Handle add and remove simultaneously")
|
|
func addAndRemoveSimultaneously() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create initial table with columns: column1, column2, obsoleteColumn
|
|
let initialTable = TestTable(
|
|
tableName: "testTable",
|
|
definedColumns: ["column1", "column2", "obsoleteColumn"]
|
|
)
|
|
try initialTable.createIfNeeded(database: database)
|
|
|
|
// Create updated table definition: column1, column2, newColumn (removed obsoleteColumn, added newColumn)
|
|
let updatedTable = TestTable(tableName: "testTable", definedColumns: ["column1", "column2", "newColumn"])
|
|
try updatedTable.createIfNeeded(database: database)
|
|
|
|
// Verify columns
|
|
let columns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(columns.count == 3, "Should have 3 columns")
|
|
#expect(columns.contains("column1"))
|
|
#expect(columns.contains("column2"))
|
|
#expect(columns.contains("newColumn"), "newColumn should have been added")
|
|
#expect(!columns.contains("obsoleteColumn"), "obsoleteColumn should have been removed")
|
|
}
|
|
|
|
@Test("Handle no changes needed")
|
|
func noChangesNeeded() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create table with 2 columns
|
|
let table = TestTable(tableName: "testTable", definedColumns: ["column1", "column2"])
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Get initial columns
|
|
let initialColumns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
|
|
// Run migration again with same columns
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Verify columns haven't changed
|
|
let finalColumns = try database.read { db in
|
|
try db.columns(in: "testTable").map(\.name)
|
|
}
|
|
#expect(initialColumns == finalColumns, "Columns should remain unchanged")
|
|
}
|
|
|
|
@Test("Migration with actual table: HAppEntityTable")
|
|
func realTableMigration() throws {
|
|
let database = try DatabaseQueue(path: ":memory:")
|
|
|
|
// Create HAppEntityTable
|
|
let table = HAppEntityTable()
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Verify table was created
|
|
let tableExists = try database.read { db in
|
|
try db.tableExists(table.tableName)
|
|
}
|
|
#expect(tableExists)
|
|
|
|
// Call createIfNeeded again (should trigger migration path, not creation)
|
|
try table.createIfNeeded(database: database)
|
|
|
|
// Verify table still exists and has correct columns
|
|
let columns = try database.read { db in
|
|
try db.columns(in: table.tableName).map(\.name)
|
|
}
|
|
let expectedColumns = DatabaseTables.AppEntity.allCases.map(\.rawValue)
|
|
#expect(Set(columns) == Set(expectedColumns))
|
|
}
|
|
}
|