Skip to main content

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.

System-level UI is outside this pipeline

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

LanguageBC Language CodeIETF Tag
English (default)ENUen-US
DanishDANda-DK
GermanDEUde-DE
SpanishESPes-ES
FrenchFRAfr-FR
DutchNLDnl-NL
SwedishSVEsv-SE
Chinese SimplifiedCHSzh-CN

How the System Works

Two mechanisms, two purposes

MechanismWhat it controlsWhere configured
Accept-Language headerWhich XLIFF translations BC picks for all Label values in JSON responsesMobile User Assignment 220FDW → Language Code (per user)
App Locale CodeNumber and date display formatting for all users in the companyMobile Setup 220FDW → App Locale Code (company-wide)

Startup sequence

When the mobile app starts, it calls three RPC methods:

  1. GetUserInformation — returns the user's languageCode (IETF tag from their assigned Language Code) and localeCode (company-wide App Locale Code). The client uses languageCode as the Accept-Language header on all subsequent requests, which drives XLIFF resolution in BC.

  2. GetClientStrings — returns a flat JSON string map of all Android UI strings (button labels, error messages, accessibility descriptions) translated for the user's language.

  3. GetPageFlow — returns all page definitions. Before delivering each page, MobileRPCRegistry220FDW calls TranslatePageJson() 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 #
Missing # = string never translated

If 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:

PurposeLockedSuffixExample
JSON data key, State.GetInput() key, API tokenLocked = trueTokLotNoKeyTok
User-visible display text, InfoPane label, error message(none)LblInfoPaneLotLbl

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;
Check for key collisions

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:

  1. In VS Code Command Palette: NAB: Refresh XLF files from g.XLF
    This scans all AL source and adds <trans-unit> entries with state="needs-translation" to every language file.

  2. Fill in translations in the 7 XLIFF files under Mesh/Translations/:

    FileLanguage
    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
  3. 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

This is the most common source of translation bugs in Mesh

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 caseMethodResult
Date in a JSON API fieldJsonHelper.WriteDate()"2025-12-31" (ISO)
Date in a prefill Text variableFormat(date, 0, 9)"2025-12-31" (ISO)
Date shown to the userLocalization.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

RuleWhy
Use Locked = true on all JSON data keys, binding tokens, and API namesPrevents the key from changing with session language
Add Comment to every Label with %1, %2 placeholdersTranslators cannot correctly place placeholders without context
Use "#TokenName" in page JSON for all translatable stringsRequired 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 fieldsGuarantees ISO 8601 wire format
Use JsonHelper.WriteDecimal() / ReadDecimal() for all decimal JSON fieldsGuarantees JSON number (not string)
Use Localization.FormatDateForDisplay() for user-visible datesFormats using the company's App Locale Code
Run NAB: Refresh XLF after adding new LabelsNew 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-patternWhat breaks
Non-locked Label as a JsonObject.Add() keyKey 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 labelLabel is never sent to the XLIFF translator
Format(Qty, 0, 9) in StrSubstNo for displayGerman 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() keySecond registration silently overwrites the first — no error
Hardcoding filter type token stringsFuture token changes silently break your subscriber

Best Practices Checklist

Labels

  • All user-visible text declared as a Label variable — no hardcoded strings in JSON
  • Every Label with a placeholder (%1, %2, ...) has a Comment field
  • Labels used as JSON data keys or API tokens have Locked = true and a Tok suffix
  • Labels used for display text have no Locked and a Lbl suffix

Page JSON tokens

  • Every translatable string in page JSON uses "#EnglishText" format
  • Every #token has a matching StringMap.Set('EnglishText', ...) entry
  • No duplicate keys — checked against BuildSduiStrings() and BuildAndroidStrings()
  • XLIFF entries exist for all 7 supported languages with state="translated"

Data binding

  • Every Label used as a JsonObject.Add() key has Locked = true
  • Every Label used in State.GetInput() has Locked = true
  • The write key and read key are the same locked token
  • The locked token exactly matches the string in the page JSON params array

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 in Format(0, 9)
  • WorkDate() used everywhere in service code — never Today

Language setup

  • Language Code assigned per user in Mobile User Assignment 220FDW
  • App Locale Code set in Mobile Setup 220FDW for the company's regional format
  • New labels extracted via NAB Refresh XLF before shipping