Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,7 @@
C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; };
C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; };
C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; };
DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; };
C8860D27D848451A887BC441 /* WatchFolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */; };
CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; };
CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */; };
Expand Down Expand Up @@ -3471,6 +3472,7 @@
E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = "<group>"; };
ED4B2D38DF1316D881D79769 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = "<group>"; };
EF91E383A44843F087423FB5 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntitiesTimelineProvider.swift; sourceTree = "<group>"; };
4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskLocalization.test.swift; sourceTree = "<group>"; };
EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettings.test.swift; sourceTree = "<group>"; };
F49767602CA2066683EC638F /* KioskSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskSettingsView.swift; sourceTree = "<group>"; };
F6DA82FEEE2DDC3B2CC20DA3 /* Pods_iOS_Extensions_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -3676,6 +3678,7 @@
06D62F8A8D381DAFB70C6B31 /* Kiosk */ = {
isa = PBXGroup;
children = (
4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */,
EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */,
);
path = Kiosk;
Expand Down Expand Up @@ -9699,6 +9702,7 @@
429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */,
119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */,
C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */,
DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
2 changes: 1 addition & 1 deletion Sources/App/Kiosk/Settings/KioskSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ public struct KioskSettingsView: View {
Text(
L10n.Kiosk.Security.gestureFooter(
viewModel.settings.secretExitGestureCorner.displayName,
viewModel.settings.secretExitGestureTaps
String(viewModel.settings.secretExitGestureTaps)
)
)
}
Expand Down
146 changes: 146 additions & 0 deletions Tests/App/Kiosk/KioskLocalization.test.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Foundation
@testable import HomeAssistant
@testable import Shared
import XCTest

/// Regression tests for kiosk L10n format strings across all bundled locales.
///
/// Guards against issue #4487, in which German and Dutch translations of
/// `kiosk.security.gesture_footer` reordered format specifiers without using
/// positional markers (`%N$...`), causing `String(format:)` to misinterpret a
/// CVarArg as the wrong type and crash (EXC_BAD_ACCESS) when the Kiosk
/// settings view body was evaluated.
///
/// These tests exercise every kiosk format-string key against every bundled
/// `*.lproj/Localizable.strings` file, confirming the format call completes
/// without crashing and returns non-empty output containing the supplied
/// argument values.
final class KioskLocalizationTests: XCTestCase {
/// Kiosk format-string keys that take at least one argument, paired with
/// representative invocation args. Args are strings because the current
/// SwiftGen output coerces all args with `String(describing:)` before
/// passing to `String(format:)` — this test mirrors the runtime path.
private static let kioskFormatKeys: [(key: String, args: [CVarArg], specifiers: Int)] = [
("kiosk.brightness.manual", [80 as Int], 1),
("kiosk.screensaver.dim_level", [25 as Int], 1),
("kiosk.clock.accessibility.analog_clock", ["3:45 PM"], 1),
("kiosk.clock.accessibility.current_time", ["3:45 PM"], 1),
("kiosk.clock.accessibility.date", ["Wednesday, April 8"], 1),
("kiosk.security.taps_required", [5 as Int], 1),
("kiosk.security.gesture_footer", ["top-left", "5"], 2),
]

func testKioskFormatStringsAcrossAllLocales() throws {
// Matches a single printf-style format specifier, e.g. `%@`, `%li`, `%1$@`.
let specifierRegex = try NSRegularExpression(
pattern: "%{1,2}[+0123456789$.luq]*?[sduiefgcCp@]"
)
let bundle = Bundle(for: AppDelegate.self)

let lprojURLs: [URL] = try {
let resourceURL = try XCTUnwrap(bundle.resourceURL)
let enumerator = try XCTUnwrap(FileManager.default.enumerator(
at: resourceURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsSubdirectoryDescendants]
))
return enumerator.compactMap { $0 as? URL }
.filter { $0.pathExtension == "lproj" }
.filter { $0.deletingPathExtension().lastPathComponent != "Base" }
}()

XCTAssertGreaterThan(lprojURLs.count, 1, "Expected multiple bundled locales")

for lprojURL in lprojURLs {
let language = lprojURL.deletingPathExtension().lastPathComponent
let stringsURL = lprojURL.appendingPathComponent("Localizable.strings")
guard let strings = NSDictionary(contentsOf: stringsURL) as? [String: String] else {
XCTFail("Could not load Localizable.strings for locale \(language)")
continue
}

for (key, args, expectedSpecifiers) in Self.kioskFormatKeys {
guard let format = strings[key] else {
// Missing key is acceptable (fallback to English via LocalizedManager)
continue
}

let specifierCount = specifierRegex.numberOfMatches(
in: format,
range: NSRange(location: 0, length: format.utf16.count)
)
XCTAssertEqual(
specifierCount,
expectedSpecifiers,
"Locale '\(language)' key '\(key)' has \(specifierCount) format specifiers, expected \(expectedSpecifiers): \(format)"
)

// Execute the format call — this is the path that crashed in #4487.
let result = String(format: format, locale: Locale(identifier: language), arguments: args)
XCTAssertFalse(
result.isEmpty,
"Locale '\(language)' key '\(key)' produced empty result"
)
// Every supplied arg must appear in the output, confirming each specifier consumed a value.
for arg in args {
let argString: String
if let s = arg as? String {
argString = s
} else if let i = arg as? Int {
argString = "\(i)"
} else {
continue
}
XCTAssertTrue(
result.contains(argString),
"Locale '\(language)' key '\(key)' output '\(result)' missing arg '\(argString)'"
)
}
}
}
}

/// Targeted regression test for issue #4487: exercises the real
/// `L10n.Kiosk.Security.gestureFooter` function via the app's
/// `LocalizedManager`, once per bundled locale, by injecting a string
/// provider that returns that locale's format string.
func testGestureFooterDoesNotCrashAcrossLocales() throws {
let bundle = Bundle(for: AppDelegate.self)
let resourceURL = try XCTUnwrap(bundle.resourceURL)
let enumerator = try XCTUnwrap(FileManager.default.enumerator(
at: resourceURL,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsSubdirectoryDescendants]
))
let lprojURLs = enumerator.compactMap { $0 as? URL }
.filter { $0.pathExtension == "lproj" }
.filter { $0.deletingPathExtension().lastPathComponent != "Base" }

let savedLocalized = Current.localized
defer { Current.localized = savedLocalized }

for lprojURL in lprojURLs {
let language = lprojURL.deletingPathExtension().lastPathComponent
let stringsURL = lprojURL.appendingPathComponent("Localizable.strings")
guard let strings = NSDictionary(contentsOf: stringsURL) as? [String: String],
let format = strings["kiosk.security.gesture_footer"] else {
continue
}

// Inject this locale's format into the LocalizedManager.
let localized = LocalizedManager()
localized.add(stringProvider: { request in
Comment thread
nstefanelli marked this conversation as resolved.
request.key == "kiosk.security.gesture_footer" ? format : nil
})
Current.localized = localized

// Call the generated L10n function — this is the exact path
// KioskSettingsView exercises when rendering the footer.
let result = L10n.Kiosk.Security.gestureFooter("top-left", String(5))
XCTAssertFalse(
Comment thread
nstefanelli marked this conversation as resolved.
result.isEmpty,
"Locale '\(language)' gestureFooter produced empty result"
)
}
}
}
Loading