A multilingual messaging, calendar, or collaboration app can look finished on every screen and still speak English when a notification expands. The custom Notification Content Extension shows English labels from its storyboard. Action buttons under the alert still say Accept or Decline. Android notification action rows keep English titles built as string literals in the notification builder. Translators finished the product UI. The alert chrome drifted because Apple's content-extension target and category registration APIs, plus Android Notification.Action builders, own strings that never entered the ordinary host catalog.
Give rich notifications the same release discipline as checkout or permissions. Inventory content-extension and action-button strings apart from host screens, localize category titles and Android action labels on purpose, then prove expanded notifications after real Language and Region changes.
Why notification chrome drifts from the app language
Rich notifications have more than one owner. The push or local payload owns the title and body. The category owns the action buttons and summary strings. The Notification Content Extension owns the custom expanded interface. Android's notification builder owns action titles attached through Notification.Action. Apple's UNNotificationContentExtension protocol is the entry point for a content app extension that presents a custom interface for a delivered local or remote notification. Customizing the Appearance of Notifications explains that the extension manages a view controller from its own Xcode template, configures labels from the notification payload and files in the extension bundle, and declares supported categories with UNNotificationExtensionCategory in the extension Info.plist.
Declaring your actionable notification types states that each action object contains the localized string to display to the user. The sample registers English titles "Accept" and "Decline". The UNNotificationAction.title symbol abstract is explicit: "The localized string to use as the title of the action." UNNotificationCategory also exposes hiddenPreviewsBodyPlaceholder and categorySummaryFormat, which appear when previews are disabled or notifications are grouped.
The localization gap shows up in six places:
- Host screens live in the main String Catalog or
strings.xml. Content-extension storyboard labels and category action titles often start as English literals in the extension target or at launch-time registration. - Push title and body localization can be correct while action buttons still use the language captured when categories were registered.
- Apple sample code ships English Accept/Decline titles that teams paste forward without
String(localized:). - The Notification Content Extension is a separate process and bundle. Host catalogs do not automatically appear there.
- Android
Notification.Actiontakes aCharSequence titlein the builder. AOSPNotification.javadefines that title as an app-supplied field, so English literals in Kotlin or Java stay English across locales. - After an in-app language change, categories registered with eagerly resolved
Stringvalues keep the old language until the app re-registers them.
Practitioner materials line up with that split. A public Stack Overflow thread about setting UNNotificationAction titles from a push payload shows an author who already has static action titles working and wants payload-driven button text. That is author-reported evidence that action chrome is treated as registration-time copy, which is easy to leave in one language even when alert title and body are localized per message. Another Stack Overflow thread asks whether app extensions need their own Localizable.strings and documents that extension strings must be present for the extension target. Phrase's iOS localization overview and the live i18nAgent iOS and Android guides cover catalogs and UI strings. They do not connect Notification Content Extension ownership to UNNotificationAction titles or Android notification-action resources. Server-sent push campaigns, scheduled local notification language freeze, and MediaProjection capture-service notifications covered elsewhere are different problems: a translated push body or capture foreground service is not the same as shipping localized content-extension chrome and category action buttons.
Register localized action titles on purpose
Register UNNotificationAction titles with String(localized:) or catalog keys at category setup time: Apple documents the title as the localized string to display, and sample Accept/Decline literals stay English until you localize them on purpose.
For each actionable category, record:
| Field | Why it matters |
|---|---|
| Category identifier | Stable key in local and remote payloads; not translated |
| Action identifiers | Stable handler keys; not translated |
| Action titles | User-visible button labels; must be localized |
categorySummaryFormat |
Grouped notification summary; user-visible |
hiddenPreviewsBodyPlaceholder |
Shown when previews are disabled; user-visible |
| Content-extension category list | UNNotificationExtensionCategory must match registered IDs |
Build actions with deferred localization instead of English literals:
let accept = UNNotificationAction(
identifier: "ACCEPT_ACTION",
title: String(localized: "notification.action.accept", bundle: .main),
options: []
)
let decline = UNNotificationAction(
identifier: "DECLINE_ACTION",
title: String(localized: "notification.action.decline", bundle: .main),
options: .destructive
)
let category = UNNotificationCategory(
identifier: "MEETING_INVITATION",
actions: [accept, decline],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: String(localized: "notification.preview.hidden"),
categorySummaryFormat: String(localized: "notification.summary.format"),
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
Keep identifiers English and stable. Translate only the user-visible titles and placeholders. If your product supports an in-app language that can differ from the device language, resolve strings against the effective app locale bundle, then call setNotificationCategories again after that locale changes so buttons do not keep the launch-time language.
Do not invent a second localization system inside the push payload for button titles unless product requirements demand per-message actions. Apple's model registers categories up front. Payload-driven titles are an advanced path that still needs a localized fallback when the payload omits a language.
Keep content-extension strings inside the extension bundle
Keep Notification Content Extension storyboard labels and extension-owned strings in the content-extension bundle String Catalog or Localizable.strings; host-app catalogs do not automatically ship inside the notification content extension target.
When you add a Notification Content Extension target, Xcode creates a separate storyboard and view controller. Labels you drop into that storyboard, plus any NSLocalizedString or String Catalog keys referenced only from the extension, belong in the extension target membership. Follow the same ownership rule practitioners use for other app extensions: the extension looks up strings in its own bundle.
Practical inventory steps:
- List every user-visible label in the content-extension storyboard and
didReceive(_:)configuration path. - Move literals into the extension String Catalog or
Localizable.stringswith translator comments that mention notification chrome, not a generic screen name. - Confirm target membership so the strings file ships in the content-extension bundle.
- Align
UNNotificationExtensionCategoryvalues with the category identifiers your host registers. - Decide whether default system title and body remain visible or whether
UNNotificationExtensionDefaultContentHiddenhides them because your custom interface already shows localized fields.
If the extension reads shared data through an App Group, keep display strings in the extension resources anyway. Shared containers are for data, not a substitute for extension-localized UI copy. When the host app localizes a meeting title in the payload, the extension can show that payload string, but chrome around it (section headers, empty states, fallback labels) still needs extension-owned keys.
Own Android notification action titles in the builder
Android does not use a Notification Content Extension target in the Apple sense, but action rows are still app-owned chrome. AOSP Notification.Action stores a CharSequence title supplied through Action.Builder. If that title is a Java or Kotlin literal, every locale sees English.
Resolve action titles from resources when you build the notification:
val acceptIntent = PendingIntent.getBroadcast(
context,
0,
Intent(context, NotificationActionReceiver::class.java).setAction("ACCEPT"),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val acceptAction = NotificationCompat.Action.Builder(
R.drawable.ic_check,
context.getString(R.string.notification_action_accept),
acceptIntent
).build()
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification)
.addAction(acceptAction)
.build()
Put notification_action_accept and sibling keys in values/strings.xml and locale overlays. Give translators length notes: action labels sit on compact system UI and truncate quickly in German or Finnish. If you use semantic actions such as reply, still localize the visible title even when the semantic constant is language-neutral.
Channel names and notification content titles may already be localized from earlier push or local-notification work. Do not treat that as coverage for action labels. An English action row under a translated title is still a mixed-language alert.
Re-check categories after language changes
Resolve Android Notification.Action titles through strings.xml or getString when building actions, and re-register iOS notification categories after an in-app language change when titles were resolved eagerly at launch.
iOS categories are registered, not re-read from the storyboard on every notification. If you resolve String(localized:) once in application(_:didFinishLaunchingWithOptions:) and the user later switches the app language without killing the process, action buttons can keep the previous language until you register again. Hook language-change flows so they rebuild categories with the new locale and call setNotificationCategories.
On Android, rebuild notifications that remain ongoing or can be updated, and ensure future notify calls read getString under the effective app locale configuration. If your app uses per-app language APIs, apply the locale context before inflating action titles.
Release checks to run on a real device:
- System language matching a translated app locale: expand a categorized notification and confirm action titles, content-extension labels, summary text, and hidden-preview placeholders.
- Per-app language different from system language, where the OS allows it: confirm buttons follow the app locale policy you document.
- Fresh install cold start: confirm categories registered before the first notification arrives.
- Language switch without reinstall: confirm re-registration updates action titles on the next notification.
- Android API levels you support: confirm
NotificationCompat.Actiontitles from resources on a compact shade and on an expanded notification. - English fallback: remove one locale overlay and confirm keys fall back cleanly instead of showing raw key names.
Capture localization QA screenshots beside your ordinary product shots. Alert chrome is easy to miss when testers only open the app.
What not to confuse with this workflow
Do not treat this as another push-campaign article. Server-sent title and body localization remains a separate release surface. Do not reopen scheduled local notification scheduling language freeze either. That governs reminder text captured at schedule time, not content-extension storyboards or category action buttons. MediaProjection capture-service foreground notifications during screen share are also out of scope here: those are service-status chrome, not rich notification categories.
Share extensions, custom keyboards, and credential providers also own OS-hosted chrome, but their string inventories and Settings enablement paths are different products. Keep those checklists separate so notification action regressions do not hide behind an unrelated extension audit.
