Skip to content

Fix crash opening Kiosk settings in non-English locales (#4487)#4490

Merged
bgoncal merged 4 commits intohome-assistant:mainfrom
nstefanelli:kiosk-fix-gesture-footer-i18n-crash
Apr 9, 2026
Merged

Fix crash opening Kiosk settings in non-English locales (#4487)#4490
bgoncal merged 4 commits intohome-assistant:mainfrom
nstefanelli:kiosk-fix-gesture-footer-i18n-crash

Conversation

@nstefanelli
Copy link
Copy Markdown
Contributor

Summary

Fixes #4487 — opening Settings → Companion App → Kiosk Mode (Labs) crashes the app immediately in German and Dutch (and potentially any other locale whose translators reordered format specifiers without positional markers).

Root cause

kiosk.security.gesture_footer in en.lproj used non-positional format specifiers:

"Tap the %@ corner %li times to access kiosk settings when locked."

The German and Dutch translations legitimately reordered them for grammar, but without positional markers:

Locale Format string
en Tap the %@ corner %li times...
de Tippe **%li**-mal auf die Ecke **%@**, um... ← reversed
nl Tik **%li** keer op de hoek **%@** om... ← reversed

The SwiftGen-generated L10n.Kiosk.Security.gestureFooter(_ p1: Any, _ p2: Int) passed (String, Int) positionally to String(format: format, locale: Locale.current, arguments: args). With the reversed German/Dutch specifiers:

  • %li reads the first slot (a String bridged via CVarArg) as long int → garbage integer (not fatal)
  • %@ reads the second slot (an Int value) as an Obj-C object pointer → Foundation calls -description on the Int bit-pattern → EXC_BAD_ACCESS in CFStringCreateWithFormatAndArgumentsAux

The KioskSettingsView footer is rendered by default (secretExitGestureEnabled defaults to true), so the crash triggers the moment a German/Dutch user navigates to Kiosk settings.

Why this wasn't caught by CI

Tests/App/LocalizedStrings.test.swift already validates format specifiers match across languages, but uses NSCountedSet for the comparison — which is order-insensitive. English {%@, %li} counted-set equals German {%li, %@} → test passes despite the fatal reorder. This is a broader test defect that's out of scope for this PR but flagged below for follow-up.

The fix (three layers of defense)

1. Swift-level crash elimination (durable, in our control)

Change the English source to use both placeholders as %@ with positional markers:

-"kiosk.security.gesture_footer" = "Tap the %@ corner %li times to access kiosk settings when locked.";
+"kiosk.security.gesture_footer" = "Tap the %1\$@ corner %2\$@ times to access kiosk settings when locked.";

SwiftGen regenerates Strings.swift with signature gestureFooter(_ p1: Any, _ p2: Any). The call site in KioskSettingsView.swift wraps the taps count: String(viewModel.settings.secretExitGestureTaps).

Why both %@ instead of %1\$@ %2\$li? Keeping both slots pointer-typed makes the function crash-proof against any future translation error. Even if a translator drops positional markers and reorders to %@ ... %@, va_arg always reads a pointer in both slots → worst case is mis-ordered text output, never a crash. The integer range is 2–5 (enforced by Stepper: 2...5), so losing %li's locale-specific digit rendering has no visible impact.

2. Patch de.lproj and nl.lproj translations

Updated the German and Dutch strings to use %1\$@ %2\$@ positional markers in the grammatically-correct order:

// de.lproj
"kiosk.security.gesture_footer" = "Tippe %2\$@-mal auf die Ecke %1\$@, um im gesperrten Zustand auf die Kiosk-Einstellungen zuzugreifen.";

// nl.lproj
"kiosk.security.gesture_footer" = "Tik %2\$@ keer op de hoek %1\$@ om de kiosk-instellingen te openen als deze vergrendeld zijn.";

These patches are transient — the nightly fastlane update_strings cron will overwrite them with whatever Lokalise has. Layer 1 prevents any future crash regardless, but the ideal end-state requires a Lokalise source update — see the follow-up request below.

3. Regression tests (Tests/App/Kiosk/KioskLocalization.test.swift)

Two new XCTest cases narrowly scoped to kiosk localization:

  • testKioskFormatStringsAcrossAllLocales — iterates every bundled *.lproj/Localizable.strings, enumerates known kiosk format keys (7 total), asserts specifier count matches English, invokes String(format:) with representative string args, and verifies each arg appears verbatim in the output. Catches reorder-without-positional-markers because the wrong specifier would either swallow or misrender the arg.

  • testGestureFooterDoesNotCrashAcrossLocales — exercises the real L10n.Kiosk.Security.gestureFooter function path by injecting each locale's format string into LocalizedManager, then invoking the generated function with (String, Int). If the generated signature ever regresses to a type-mismatched form, this test crashes the runner against the German/Dutch format — a loud, unmissable failure.

Screenshots

N/A — the visible change is "app no longer crashes"; the footer text in German/Dutch renders the same words it always intended to, in the grammatically correct order.

Link to pull request in Documentation repository

Documentation: home-assistant/companion.home-assistant# — not required, no user-facing behavior change

Any other notes

Follow-ups for maintainers (deliberately out of scope)

  1. Lokalise source-of-truth update — please update the Lokalise project for `kiosk.security.gesture_footer` so the English source uses `%1$@ %2$@` and the `de`/`nl` translations are updated to use positional markers. Until this happens, the next `download_localized_strings` cron will overwrite the Layer 2 patches in this PR. Layer 1 keeps the app crash-free regardless, so there's no blocking urgency, but the de/nl text will revert to misleading word order until the Lokalise update lands.

  2. `LocalizedStrings.test.swift` uses `NSCountedSet` (line 121) for format-specifier comparison, which is order-insensitive. This is why App crashes when enabling Kiosk Mode #4487 slipped past CI despite existing coverage. A stricter ordered comparison (with positional-marker awareness) would catch the entire class of bug across the codebase, not just kiosk. Happy to open a separate PR for this if a maintainer wants it.

  3. Audit of other multi-arg `L10n` functions — a grep shows roughly two dozen other multi-arg `L10n.tr` call sites outside kiosk. Most take two `Any` (`%@` twice), which is safe from the pointer-dereference crash, but a few take `Int` mixed with `Any`. These warrant the same audit-and-fix treatment if the underlying translations turn out to have similar reorder-without-positional-marker issues. Out of scope for me as a kiosk contributor — flagging so a maintainer can prioritize.

Testing notes

  • SwiftGen regenerated via `./Pods/SwiftGen/bin/swiftgen` (not hand-edited).
  • `swiftformat --lint` clean across all touched files.
  • `plutil -lint` confirms `project.pbxproj` remains valid after the test-target additions.
  • I have not yet captured a TestFlight crash log from an affected device to definitively confirm the crash signature; will do so on a German-locale simulator and attach to this PR before marking ready for review. The mechanism is proven by the locale audit and matches the reporter descriptions in App crashes when enabling Kiosk Mode #4487 exactly (German + Dutch, crash immediately on tapping Kiosk Mode).

CC

`@bgoncal` per your comment on #4487.


🤖 Draft PR — opening for early visibility; will mark ready for review after on-device verification.

Copy link
Copy Markdown
Member

@bgoncal bgoncal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use a new localized keys for the footer? Since the translation was already sent to localize (platform we use for translations) these changes won't apply after Lokalise run the GitHub workflow to sync translations.

Also, modify only the English strings

@bgoncal
Copy link
Copy Markdown
Member

bgoncal commented Apr 9, 2026

Wait, I have manually updated in Lokalise, I'll tag the import PR here in a bit then you can merge into your branch and see if it works

@bgoncal
Copy link
Copy Markdown
Member

bgoncal commented Apr 9, 2026

…nt#4487)

The gestureFooter call site passes the taps count as an Int, but with
the format string now using %2$@ (updated in Lokalise), the SwiftGen
signature is gestureFooter(_ p1: Any, _ p2: Any). Wrap the Int with
String() so the CVarArg is a pointer type matching the %@ specifier.

Add regression tests (KioskLocalization.test.swift) that exercise all
kiosk format-string keys against every bundled locale, confirming:
- Specifier count matches English
- String(format:) completes without crash
- Each arg appears verbatim in output

Also adds a targeted test that injects each locale's gesture_footer
format into LocalizedManager and calls the real L10n function path.
@nstefanelli nstefanelli force-pushed the kiosk-fix-gesture-footer-i18n-crash branch from dc3adf8 to 3948efa Compare April 9, 2026 12:13
@bgoncal
Copy link
Copy Markdown
Member

bgoncal commented Apr 9, 2026

Code-wise it looks fine, let me know when you have validated it

@nstefanelli nstefanelli marked this pull request as ready for review April 9, 2026 16:12
Copilot AI review requested due to automatic review settings April 9, 2026 16:12
@nstefanelli
Copy link
Copy Markdown
Contributor Author

Thanks for jumping in and fixing the Lokalise translations directly — that was way faster than the workaround I had going! Rebased on top of #4493, reverted the key rename and de/nl patches since they're no longer needed.

The PR now just has the call site fix (wrapping the taps count with String() to match the new %@ specifier) and a regression test that exercises all kiosk format strings across every bundled locale.

Build passes locally, tests pass, and confirmed no crash on a German locale simulator. Ready for review whenever you get a chance!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a crash that occurs when opening Kiosk Mode settings in non-English locales (specifically German and Dutch) by implementing three layers of defense: (1) changing the English format string to use both placeholders as %@ with positional markers to make it crash-proof regardless of translation reordering, (2) updating German and Dutch translations to use positional markers with correct grammar, and (3) adding comprehensive regression tests to prevent future format string issues.

Changes:

  • Updated the Kiosk settings footer call site to wrap the integer tap count in String(), matching the new format string expectation
  • Added comprehensive regression tests (KioskLocalization.test.swift) with two test methods: one that validates format specifier counts across all locales, and another that directly exercises the real L10n function to ensure no crashes occur
  • Updated the Xcode project file to include the new test file

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
Tests/App/Kiosk/KioskLocalization.test.swift New regression tests for kiosk localization, validating format strings across all bundled locales and testing the L10n function directly
Sources/App/Kiosk/Settings/KioskSettingsView.swift Updated the gestureFooter call to pass the tap count as a String instead of Int to match the new format string requirements
HomeAssistant.xcodeproj/project.pbxproj Added the new test file to the project build configuration

…nt#4487)

The gestureFooter call site passes the taps count as an Int, but with
the format string now using %2$@ (updated in Lokalise), the SwiftGen
signature is gestureFooter(_ p1: Any, _ p2: Any). Wrap the Int with
String() so the CVarArg is a pointer type matching the %@ specifier.

Add regression tests (KioskLocalization.test.swift) that exercise all
kiosk format-string keys against every bundled locale, confirming:
- Specifier count matches English
- String(format:) completes without crash
- Each arg appears verbatim in output

Also adds a targeted test that injects each locale's gesture_footer
format into LocalizedManager and calls the real L10n function path.
@nstefanelli nstefanelli force-pushed the kiosk-fix-gesture-footer-i18n-crash branch from dd06b8a to 4b68710 Compare April 9, 2026 16:34
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@b15a8ae). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4490   +/-   ##
=======================================
  Coverage        ?   42.65%           
=======================================
  Files           ?      274           
  Lines           ?    16230           
  Branches        ?        0           
=======================================
  Hits            ?     6923           
  Misses          ?     9307           
  Partials        ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bgoncal bgoncal merged commit ab8f0b3 into home-assistant:main Apr 9, 2026
9 checks passed
@bgoncal
Copy link
Copy Markdown
Member

bgoncal commented Apr 9, 2026

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App crashes when enabling Kiosk Mode

4 participants