Internationalization & Translations
Aptean Mesh delivers all SDUI display strings from Business Central to the mobile app at startup. This means your AL Label declarations are automatically translated via BC's standard XLIFF workflow, and your extension strings are translated the same way.
Network error dialogs, loading indicators, OS-native date pickers, and Android system dialogs use Android's own localization — not the BC string pipeline. These follow the device locale automatically but cannot be customised via AL.
Supported Languages
| Language | BC Language Code | IETF Tag |
|---|---|---|
| English (default) | ENU | en-US |
| Danish | DAN | da-DK |
| German | DEU | de-DE |
| Spanish | ESP | es-ES |
| French | FRA | fr-FR |
| Dutch | NLD | nl-NL |
| Swedish | SVE | sv-SE |
| Chinese Simplified | CHS | zh-CN |
How the System Works
Two mechanisms, two purposes
| Mechanism | What it controls | Where configured |
|---|---|---|
Accept-Language header | Which XLIFF translations BC picks for all Label values in JSON responses | Mobile User Assignment 220FDW → Language Code (per user) |
| App Locale Code | Number and date display formatting for all users in the company | Mobile Setup 220FDW → App Locale Code (company-wide) |
Startup sequence
When the mobile app starts, it calls three RPC methods:
-
GetUserInformation— returns the user'slanguageCode(IETF tag from their assigned Language Code) andlocaleCode(company-wide App Locale Code). The client useslanguageCodeas theAccept-Languageheader on all subsequent requests, which drives XLIFF resolution in BC. -
GetClientStrings— returns a flat JSON string map of all Android UI strings (button labels, error messages, accessibility descriptions) translated for the user's language. -
GetPageFlow— returns all page definitions. Before delivering each page,MobileRPCRegistry220FDWcallsTranslatePageJson()which replaces"#TokenName"markers in the page JSON with the translated values from the string map.
Page JSON translation via # tokens
Translatable strings in page JSON files use a # prefix as a translation marker:
// Stored in the Mobile Page record:
{
"header": { "title": "#Receipt" },
"footer": {
"actions": [
{ "label": "#Print Label", "action": "onPrintLabel" },
{ "label": "#Scan Entries", "action": "onViewEntry" }
]
}
}
During GetPageFlow, TranslatePageJson replaces each "#TokenName" with its translated value from the string map:
// Delivered to a German client (Accept-Language: de-DE):
{
"header": { "title": "Wareneingang" },
"footer": {
"actions": [
{ "label": "Etikett drucken", "action": "onPrintLabel" },
{ "label": "Scan-Einträge", "action": "onViewEntry" }
]
}
}
The string map key is the plain English text without the #. The # only appears in the page JSON file:
// In MobileClientStrings220FDW.BuildSduiStrings():
StringMap.Set('Receipt', PageTitleReceiptLbl); // key has no #
StringMap.Set('Print Label', PageTitlePrintLabelLbl); // key has no #
# = string never translatedIf you write "title": "Receipt" (no #), TranslatePageJson will never match it. The literal string "Receipt" will appear on screen in all languages with no error or warning.
Label Naming Convention
The codebase follows a strict naming convention:
| Purpose | Locked | Suffix | Example |
|---|---|---|---|
JSON data key, State.GetInput() key, API token | Locked = true | Tok | LotNoKeyTok |
| User-visible display text, InfoPane label, error message | (none) | Lbl | InfoPaneLotLbl |
This distinction is critical. Using a non-locked Label as a JSON object key causes it to change with the session language — see Critical Anti-Pattern below.
Adding Custom Translations for Your Extension
Step 1: Declare translatable Labels in AL
namespace MyCompany.MeshExtension;
codeunit 50300 MyExtensionStrings
{
var
// Display labels — translatable, no Locked
ScanItemLbl: Label 'Scan Item';
QtyPositiveErr: Label 'Quantity must be positive';
SelectZoneLbl: Label 'Select Zone';
TransferCompleteLbl: Label 'Transfer complete';
// API tokens — must not change with language
ItemNoKeyTok: Label 'itemNo', Locked = true;
ZoneCodeTok: Label 'zoneCode', Locked = true;
Step 2: Register strings via OnRegisterExtensionStrings
Subscribe to MobileClientStrings220FDW.OnRegisterExtensionStrings. The string map key is the plain English text — the same text you put in the page JSON with a # prefix:
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileClientStrings220FDW,
OnRegisterExtensionStrings, '', false, false)]
local procedure RegisterMyStrings(var StringMap: Dictionary of [Text, Text])
begin
StringMap.Set('Scan Item', ScanItemLbl);
StringMap.Set('Quantity must be positive', QtyPositiveErr);
StringMap.Set('Select Zone', SelectZoneLbl);
StringMap.Set('Transfer complete', TransferCompleteLbl);
end;
Dictionary.Set() is an upsert — duplicate keys replace silently with no error. Before adding a key, search BuildSduiStrings() and BuildAndroidStrings() in MobileClientStrings220FDW to make sure the key is not already registered. Accidental collision overrides the core string everywhere it appears.
Step 3: Use # tokens in your page JSON
{
"type": "button",
"label": "#Scan Item",
"action": "onScan"
}
{
"type": "input",
"inputId": "itemNo",
"placeholder": "#Scan Item",
"action": "onScan"
}
Step 4: Run NAB Refresh XLF and translate
When you add new Label declarations (without Locked = true), they are not automatically added to the XLIFF files. You must extract them:
-
In VS Code Command Palette:
NAB: Refresh XLF files from g.XLF
This scans all AL source and adds<trans-unit>entries withstate="needs-translation"to every language file. -
Fill in translations in the 7 XLIFF files under
Mesh/Translations/:File Language Aptean Mesh.da-DK.xlfDanish Aptean Mesh.de-DE.xlfGerman Aptean Mesh.es-ES.xlfSpanish Aptean Mesh.fr-FR.xlfFrench Aptean Mesh.nl-NL.xlfDutch Aptean Mesh.sv-SE.xlfSwedish Aptean Mesh.zh-CN.xlfChinese Simplified -
Set
state="translated"on each completed entry:<trans-unit id="Codeunit 50300 - Label ScanItemLbl - NamedType"><source>Scan Item</source><target state="translated">Artikel scannen</target></trans-unit>
Until this step is complete, the label displays in English in all non-English sessions — no error, no warning.
Adding Custom Filter Options with Translations
Subscribe to both OnRegisterFilterOptions (to add the option) and OnRegisterExtensionStrings (to register the label). The label in each option is a translatable Label; the value is a locked API token:
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileOptionsProvider220FDW,
OnRegisterFilterOptions, '', false, false)]
local procedure AddMyFilterOptions(FilterType: Code[20]; var Options: JsonArray)
var
OptionsProvider: Codeunit MobileOptionsProvider220FDW;
OptionObj: JsonObject;
begin
if FilterType <> OptionsProvider.FilterTypeDocType() then
exit;
OptionObj.Add('label', TransferOrderLbl);
OptionObj.Add('value', TransferOrderTok); // stable API token
Options.Add(OptionObj);
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileClientStrings220FDW,
OnRegisterExtensionStrings, '', false, false)]
local procedure RegisterFilterStrings(var StringMap: Dictionary of [Text, Text])
begin
StringMap.Set('Transfer Order', TransferOrderLbl);
end;
var
TransferOrderLbl: Label 'Transfer Order';
TransferOrderTok: Label 'TransferOrder', Locked = true;
Use the public accessor methods on MobileOptionsProvider220FDW to identify filter types — never hardcode the filter type token string directly:
// ✅ correct
if FilterType <> OptionsProvider.FilterTypeDocType() then exit;
// ❌ wrong — token strings can only change with a breaking release
if FilterType <> 'DocType' then exit;
Critical Anti-Pattern: Data Key Translation
If you use a non-locked Label as the key when adding a field to a JsonObject, the key name changes with the session language. Anything that reads the field by its English name — page JSON params arrays, State.GetInput() calls — will silently receive empty or missing values.
Example — Dutch user taps Print Label in the Bin Contents page:
// ❌ WRONG — not locked, evaluates to "Lotnummer" in a Dutch session
LotNoDisplayTok: Label 'Lot';
// In BuildLotCard:
LineCard.Add(LotNoDisplayTok, LotNo);
// Dutch session writes: { "Lotnummer": "LOT001" }
// In PrintLotLabel:
LotNo := CopyStr(State.GetInput(LotNoDisplayTok, true), 1, MaxStrLen(LotNo));
// State.GetInput looks for "Lotnummer", but Details.json params hardcode "Lot"
// → LotNo = '' → BC error in Dutch: "Vereiste parameter 'Lotnummer' ontbreekt"
The fix — split into two declarations with distinct purposes:
// ✅ Used as a JSON data key — stable in all languages
LotNoKeyTok: Label 'Lot', Locked = true;
// ✅ Used for display only — translatable
InfoPaneLotLbl: Label 'Lot';
// In BuildLotCard:
LineCard.Add(LotNoKeyTok, LotNo); // all sessions: { "Lot": "LOT001" } ✅
// In PrintLotLabel:
LotNo := CopyStr(State.GetInput(LotNoKeyTok, true), 1, MaxStrLen(LotNo));
// matches Details.json params "Lot" ✅
How to spot this bug: A feature works in English but fails in another language with a "missing parameter" or "required parameter" error. Look for a non-locked Label used as a JsonObject.Add() key.
Date Formatting
Wire format: ISO 8601
All dates exchanged between BC and the client use ISO 8601 (YYYY-MM-DD). Use JsonHelper220FDW — never format manually:
// ✅ Writing a date to a JSON response
JsonHelper.WriteDate(JObj, 'expiryDate', ExpDate);
// produces: "expiryDate": "2025-12-31"
// ✅ Reading a date from a JSON request
ExpDate := JsonHelper.ReadDate(JObj, 'expiryDate', true);
// parses: "2025-12-31" → Date
// ❌ Wrong — locale-sensitive, breaks in nl-NL
JObj.Add('expiryDate', Format(ExpDate));
Display dates: FormatDateForDisplay()
Dates shown directly to the user in info panes, card fields, or document lists must be locale-formatted before sending. Use MobileLocalization220FDW.FormatDateForDisplay():
// ✅ German user sees "31.12.2025"
.Field(State.DueDateLabel(), Localization.FormatDateForDisplay(WhseReceiptLine."Due Date"))
// ❌ Wrong — sends raw ISO "2025-12-31", no locale formatting for the user
.Field(State.DueDateLabel(), Format(WhseReceiptLine."Due Date", 0, 9))
Date summary
| Use case | Method | Result |
|---|---|---|
| Date in a JSON API field | JsonHelper.WriteDate() | "2025-12-31" (ISO) |
Date in a prefill Text variable | Format(date, 0, 9) | "2025-12-31" (ISO) |
| Date shown to the user | Localization.FormatDateForDisplay(date) | "31.12.2025" (locale) |
Decimal Formatting
Decimals are always sent as native JSON numbers — not strings. JsonHelper220FDW handles this:
// ✅ Wire: 1.5 (JSON number)
JsonHelper.WriteDecimal(JObj, 'quantity', Qty);
// ❌ Wire: "1.5" (string) — client cannot do arithmetic
JObj.Add('quantity', Format(Qty, 0, 9));
For display labels, pass the decimal bare into StrSubstNo — AL's StrSubstNo respects the session locale:
// ✅ German user sees "Max. Menge (1,5 ST)"
StrSubstNo(EnterQtyLbl, DefaultQty, SelectedUOM)
// ❌ German user sees "Max. Menge (1.5 ST)" — period is always used
StrSubstNo(EnterQtyLbl, Format(DefaultQty, 0, 9), SelectedUOM)
Timezone and WorkDate
MobileRPCRegistry220FDW.ExecuteInterfaceHandler() calls Localization.ApplyUserWorkDate() automatically before every service call. This sets WorkDate() to the user's local calendar date — your service code does not need to call it manually.
// ✅ Always use WorkDate() in service code
if ExpiryDate < WorkDate() then
Error(LotExpiredErr);
// ❌ Today = server UTC date, not the user's local date
if ExpiryDate < Today then
Error(LotExpiredErr);
A warehouse worker in Frankfurt at 23:30 local time is already in the next UTC day. Today would reject lot operations that are still valid for them. WorkDate() is always correct.
InfoPane Labels
InfoPane labels are AL Label values passed as the first argument to .Field(). BC resolves them via XLIFF based on the Accept-Language header — declare them without Locked = true:
// ✅ Translatable InfoPane labels — no Locked
InfoPaneLotLbl: Label 'Lot';
InfoPaneCurrentQtyLbl: Label 'Current Qty';
InfoPaneExpiryLbl: Label 'Expiry Date';
// ✅ Used in service:
.Field(InfoPaneLotLbl, LotNo)
.Field(InfoPaneCurrentQtyLbl, Format(CurrentQty))
// ❌ Hardcoded — never translated
.Field('Lot Number', LotNo)
Do's and Don'ts
✅ Do's
| Rule | Why |
|---|---|
Use Locked = true on all JSON data keys, binding tokens, and API names | Prevents the key from changing with session language |
Add Comment to every Label with %1, %2 placeholders | Translators cannot correctly place placeholders without context |
Use "#TokenName" in page JSON for all translatable strings | Required for TranslatePageJson to replace it |
Register string map keys as plain English text (no #) | The # is only for the page JSON file |
Use JsonHelper.WriteDate() / ReadDate() for all date JSON fields | Guarantees ISO 8601 wire format |
Use JsonHelper.WriteDecimal() / ReadDecimal() for all decimal JSON fields | Guarantees JSON number (not string) |
Use Localization.FormatDateForDisplay() for user-visible dates | Formats using the company's App Locale Code |
| Run NAB: Refresh XLF after adding new Labels | New labels are not in XLIFF until extracted |
Translate all 7 XLIFF files and set state="translated" | Labels stay English until this is done |
❌ Don'ts
| Anti-pattern | What breaks |
|---|---|
Non-locked Label as a JsonObject.Add() key | Key changes with language → State.GetInput() returns empty/errors |
Hardcoded string in JsonObject.Add() | Never translated, always English |
"title": "Receipt" in page JSON (missing #) | String never replaced — appears as literal text on screen |
Locked = true on a display label | Label is never sent to the XLIFF translator |
Format(Qty, 0, 9) in StrSubstNo for display | German user sees period decimal separator (1.5) instead of comma (1,5) |
JObj.Add('qty', Format(Qty)) | Locale-sensitive — produces "1,5" in nl-NL, breaking JSON parsing |
JObj.Add('date', Format(date)) | Locale-sensitive date format, breaks client parsing |
Duplicate StringMap.Set() key | Second registration silently overwrites the first — no error |
| Hardcoding filter type token strings | Future token changes silently break your subscriber |
Best Practices Checklist
Labels
- All user-visible text declared as a
Labelvariable — no hardcoded strings in JSON - Every
Labelwith a placeholder (%1,%2, ...) has aCommentfield - Labels used as JSON data keys or API tokens have
Locked = trueand aToksuffix - Labels used for display text have no
Lockedand aLblsuffix
Page JSON tokens
- Every translatable string in page JSON uses
"#EnglishText"format - Every
#tokenhas a matchingStringMap.Set('EnglishText', ...)entry - No duplicate keys — checked against
BuildSduiStrings()andBuildAndroidStrings() - XLIFF entries exist for all 7 supported languages with
state="translated"
Data binding
- Every Label used as a
JsonObject.Add()key hasLocked = true - Every Label used in
State.GetInput()hasLocked = true - The write key and read key are the same locked token
- The locked token exactly matches the string in the page JSON
paramsarray
Dates and decimals
-
JsonHelper.WriteDate()/ReadDate()for all JSON date fields -
Localization.FormatDateForDisplay(date)for all user-visible dates -
JsonHelper.WriteDecimal()/ReadDecimal()for all JSON decimal fields - Decimals in display labels passed bare to
StrSubstNo()— not wrapped inFormat(0, 9) -
WorkDate()used everywhere in service code — neverToday
Language setup
- Language Code assigned per user in
Mobile User Assignment 220FDW - App Locale Code set in
Mobile Setup 220FDWfor the company's regional format - New labels extracted via NAB Refresh XLF before shipping