Skip to content
Open
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
21 changes: 21 additions & 0 deletions .changeset/182-eleven-locales.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"mobile": patch
---

Clause-②: no
Metadata-protocol: none

The native screens now ship in the platform's one list of 11 languages, keyed
by the platform table's tags: en, zh-Hans, zh-Hant, es, ar, fr, pt-BR, ru, de,
ja, ko. Traditional Chinese is new: a phone set to 繁體中文 (`zh-Hant-*`, or
`zh-TW` / `zh-HK` / `zh-MO`) now gets Traditional Chinese native screens and a
Traditional Chinese iOS camera, photo-library and Face ID prompt, where it got
Simplified Chinese before. The bundles `zh` and `pt` are renamed `zh-Hans` and
`pt-BR`.

The phone language now resolves by script: an explicit Chinese script subtag
decides, `zh-TW` / `zh-HK` / `zh-MO` resolve to `zh-Hant`, any other Chinese
tag to `zh-Hans`, every Portuguese tag (`pt-PT` included) to `pt-BR`, and any
other tag to its primary subtag, else English. This replaces the port of the
Console's exact-then-base `matchLanguageTag`, which is no longer exported;
`resolveLanguageTag(tag)` takes its place.
2 changes: 1 addition & 1 deletion __tests__/components/ConnectionSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe("ConnectionSwitcher", () => {
);

await act(async () => {
await i18n.changeLanguage("zh");
await i18n.changeLanguage("zh-Hans");
});
expect(screen.queryByText("添加组织或公司服务器")).toBeNull();
});
Expand Down
4 changes: 2 additions & 2 deletions __tests__/components/RtlLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ afterEach(async () => {
});

describe("layoutDirection", () => {
it("is rtl for Arabic and ltr for the other nine shell languages", () => {
it("is rtl for Arabic and ltr for the other ten shell languages", () => {
expect(layoutDirection("ar")).toBe("rtl");
for (const code of ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru"]) {
for (const code of ["en", "zh-Hans", "zh-Hant", "es", "fr", "pt-BR", "ru", "de", "ja", "ko"]) {
expect(layoutDirection(code)).toBe("ltr");
}
});
Expand Down
2 changes: 1 addition & 1 deletion __tests__/components/ServerConfigScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ describe("ServerConfigScreen — managed deployment (MDM)", () => {
it("shows 「请联系 IT 管理员」 in Chinese", async () => {
const i18n = require("~/lib/i18n").default;
await act(async () => {
await i18n.changeLanguage("zh");
await i18n.changeLanguage("zh-Hans");
});
try {
managedBy(LOCKED);
Expand Down
2 changes: 1 addition & 1 deletion __tests__/components/SignInExcursionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe("SignInExcursionBar", () => {

it("reads 正在外部页面登录 · <server label> and 取消 in Chinese", async () => {
await act(async () => {
await i18n.changeLanguage("zh");
await i18n.changeLanguage("zh-Hans");
});
render(
<SignInExcursionBar host="sso.corp.example" serverLabel="Acme ERP" onCancel={jest.fn()} />
Expand Down
23 changes: 17 additions & 6 deletions __tests__/lib/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
*/
import appConfig from "~/app.config";
import en from "~/locales/en.json";
import zh from "~/locales/zh.json";
import ptBR from "~/locales/pt-BR.json";
import zhHans from "~/locales/zh-Hans.json";
import zhHant from "~/locales/zh-Hant.json";

type Plugin = string | [string, Record<string, unknown>];

Expand All @@ -26,11 +28,20 @@ describe("app.config permissions", () => {
expect(ios.NSFaceIDUsageDescription).not.toBe(en.permissions.faceId);
}
}
expect(locales["zh-Hant"].ios).toEqual({
NSCameraUsageDescription: zh.permissions.camera,
NSPhotoLibraryUsageDescription: zh.permissions.photos,
NSFaceIDUsageDescription: zh.permissions.faceId,
});
// Each Chinese script carries its own bundle's text, never the other's.
for (const [lproj, bundle] of [
["zh-Hans", zhHans],
["zh-Hant", zhHant],
] as const) {
expect(locales[lproj].ios).toEqual({
NSCameraUsageDescription: bundle.permissions.camera,
NSPhotoLibraryUsageDescription: bundle.permissions.photos,
NSFaceIDUsageDescription: bundle.permissions.faceId,
});
}
expect(locales["zh-Hant"].ios.NSCameraUsageDescription).not.toBe(zhHans.permissions.camera);
// Portuguese is written as the unqualified `pt`, so every variant falls back to the pt-BR text.
expect(locales.pt.ios.NSCameraUsageDescription).toBe(ptBR.permissions.camera);
});

it("gives the app lock its Face ID text, and blocks only the screen-capture module's unused permissions", () => {
Expand Down
128 changes: 91 additions & 37 deletions __tests__/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,30 @@ import i18n, {
followDeviceLanguage,
followDeviceLanguageOnForeground,
isRTL,
matchLanguageTag,
resolveLanguage,
resolveLanguageTag,
SUPPORTED_LANGUAGES,
RTL_LANGUAGES,
} from "~/lib/i18n";

/**
* The Console UI's ten built-in catalogue codes, in its order (objectstack
* `ui/packages/i18n/src/locales/registry.ts`, `BUILT_IN_LANGUAGE_CODES`).
* The shell must match this list one-to-one.
* The platform's one list of 11 language tags, in the table's order
* (objectstack-ai/objectstack#29428, ruling F of #29349). The shell must match
* this list one-to-one.
*/
const CONSOLE_LANGUAGE_CODES = ["en", "zh", "ja", "ko", "de", "fr", "es", "pt", "ru", "ar"];
const PLATFORM_LANGUAGE_TAGS = [
"en",
"zh-Hans",
"zh-Hant",
"es",
"ar",
"fr",
"pt-BR",
"ru",
"de",
"ja",
"ko",
];

// jest.setup.ts mocks this module without a `default`, which is what
// `react-native`'s `AppState` getter reads; give it one for this file.
Expand Down Expand Up @@ -57,16 +69,20 @@ describe("i18n configuration", () => {
});

it("translates through the active bundle", async () => {
await i18n.changeLanguage("zh");
await i18n.changeLanguage("zh-Hans");
expect(i18n.t("common.ok")).toBe("确定");
await i18n.changeLanguage("zh-Hant");
expect(i18n.t("common.ok")).toBe("確定");
await i18n.changeLanguage("pt-BR");
expect(i18n.t("common.cancel")).toBe("Cancelar");
await i18n.changeLanguage("ja");
expect(i18n.t("serverConfirm.signIn.emailPassword")).toBe("メールアドレスとパスワード");
});
});

describe("SUPPORTED_LANGUAGES", () => {
it("is exactly the Console UI's ten locale codes, in the Console's order", () => {
expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(CONSOLE_LANGUAGE_CODES);
it("is exactly the platform's 11 language tags, in the table's order", () => {
expect(SUPPORTED_LANGUAGES.map((l) => l.code)).toEqual(PLATFORM_LANGUAGE_TAGS);
});

it("names every language in its own language", () => {
Expand All @@ -78,7 +94,7 @@ describe("SUPPORTED_LANGUAGES", () => {
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""))
.sort();
expect(bundles).toEqual([...CONSOLE_LANGUAGE_CODES].sort());
expect(bundles).toEqual([...PLATFORM_LANGUAGE_TAGS].sort());
});
});

Expand All @@ -97,7 +113,7 @@ describe("locale bundles", () => {

const en = flatten(read("en"));

it.each(CONSOLE_LANGUAGE_CODES.filter((c) => c !== "en"))(
it.each(PLATFORM_LANGUAGE_TAGS.filter((c) => c !== "en"))(
"%s carries every native string key en has, and no other",
(code) => {
const bundle = flatten(read(code));
Expand All @@ -107,7 +123,7 @@ describe("locale bundles", () => {
}
);

it.each(CONSOLE_LANGUAGE_CODES)("%s has no empty string and keeps every placeholder", (code) => {
it.each(PLATFORM_LANGUAGE_TAGS)("%s has no empty string and keeps every placeholder", (code) => {
const bundle = flatten(read(code));
for (const [key, value] of Object.entries(bundle)) {
expect({ key, empty: value.trim() === "" }).toEqual({ key, empty: false });
Expand All @@ -119,58 +135,88 @@ describe("locale bundles", () => {
});
});

describe("matchLanguageTag — the Console's exact-then-base rule", () => {
// The Console's own pins (region-locale-fallback-11326.test.tsx), ported.
it("prefers an exact match over the base language, and falls back to the base", () => {
const has = (code: string) => code === "pt" || code === "pt-BR";
expect(matchLanguageTag("pt-BR", has)).toBe("pt-BR");
expect(matchLanguageTag("pt-PT", has)).toBe("pt");
expect(matchLanguageTag("pt", has)).toBe("pt");
expect(matchLanguageTag("xx-YY", has)).toBeNull();
describe("resolveLanguageTag — the platform's alias rule, by script", () => {
// objectstack-ai/objectstack#29428, phase 1: each read alias it declares.
it.each([
["zh", "zh-Hans"],
["zh-CN", "zh-Hans"],
["zh-SG", "zh-Hans"],
["zh-TW", "zh-Hant"],
["zh-HK", "zh-Hant"],
["zh-MO", "zh-Hant"],
["ja-JP", "ja"],
["es-ES", "es"],
["pt", "pt-BR"],
["en-US", "en"],
])("resolves the platform alias %s to %s", (tag, code) => {
expect(resolveLanguageTag(tag)).toBe(code);
});

it("lets an explicit script subtag decide over the region", () => {
expect(resolveLanguageTag("zh-Hant-TW")).toBe("zh-Hant");
expect(resolveLanguageTag("zh-Hant-CN")).toBe("zh-Hant");
expect(resolveLanguageTag("zh-Hans-HK")).toBe("zh-Hans");
expect(resolveLanguageTag("zh-Hans-MO")).toBe("zh-Hans");
});

it("compares subtags case-insensitively, as BCP 47 defines them", () => {
expect(resolveLanguageTag("ZH-hant")).toBe("zh-Hant");
expect(resolveLanguageTag("zh-tw")).toBe("zh-Hant");
expect(resolveLanguageTag("PT-pt")).toBe("pt-BR");
expect(resolveLanguageTag("EN")).toBe("en");
});

it("never reads an extension value as the region", () => {
expect(resolveLanguageTag("zh-u-rg-twzzzz")).toBe("zh-Hans");
expect(resolveLanguageTag("zh-CN-u-nu-hanidec")).toBe("zh-Hans");
expect(resolveLanguageTag("zh-x-tw")).toBe("zh-Hans");
});

it("answers null for a language with no bundle", () => {
expect(resolveLanguageTag("it-IT")).toBeNull();
expect(resolveLanguageTag("yue-HK")).toBeNull();
expect(resolveLanguageTag("")).toBeNull();
});
});

describe("detectDeviceLanguage", () => {
it.each(CONSOLE_LANGUAGE_CODES)("resolves the bare code %s to itself", (code) => {
it.each(PLATFORM_LANGUAGE_TAGS)("resolves the table tag %s to itself", (code) => {
expect(detectDeviceLanguage(tags(code))).toBe(code);
});

it.each([
["en-US", "en"],
["zh-CN", "zh"],
["ja-JP", "ja"],
["ko-KR", "ko"],
["de-DE", "de"],
["fr-FR", "fr"],
["es-ES", "es"],
["pt-PT", "pt"],
["es-MX", "es"],
["ru-RU", "ru"],
["ar-SA", "ar"],
])("resolves the region tag %s to %s", (tag, code) => {
])("resolves the region tag %s to its primary subtag %s", (tag, code) => {
expect(detectDeviceLanguage(tags(tag))).toBe(code);
});

it.each([
["zh-Hans-CN", "zh"],
["pt-BR", "pt"],
["es-MX", "es"],
])("resolves %s to %s", (tag, code) => {
expect(detectDeviceLanguage(tags(tag))).toBe(code);
it.each(["zh-Hans-CN", "zh-CN", "zh-SG", "zh"])("resolves Simplified Chinese %s to zh-Hans", (tag) => {
expect(detectDeviceLanguage(tags(tag))).toBe("zh-Hans");
});

// The Console has no Traditional Chinese catalogue, and its rule resolves a
// `zh-*` tag to its base `zh` (registry.ts `matchLanguageTag`). The shell
// follows that rule rather than inventing a different one.
it.each(["zh-TW", "zh-HK", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])(
"resolves Traditional Chinese %s to zh, as the Console does",
it.each(["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK"])(
"resolves Traditional Chinese %s to zh-Hant",
(tag) => {
expect(detectDeviceLanguage(tags(tag))).toBe("zh");
expect(detectDeviceLanguage(tags(tag))).toBe("zh-Hant");
}
);

it.each(["pt-PT", "pt-BR", "pt-AO", "pt"])("resolves Portuguese %s to pt-BR", (tag) => {
expect(detectDeviceLanguage(tags(tag))).toBe("pt-BR");
});

it("falls through an unsupported first language to the next supported one", () => {
expect(detectDeviceLanguage(tags("he-IL", "ja-JP", "en-US"))).toBe("ja");
expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-BR"))).toBe("pt");
expect(detectDeviceLanguage(tags("tlh", "it-IT", "pt-PT"))).toBe("pt-BR");
expect(detectDeviceLanguage(tags("yue-HK", "zh-HK", "en-US"))).toBe("zh-Hant");
});

it("falls back to en when no preferred language is supported", () => {
Expand All @@ -186,6 +232,14 @@ describe("detectDeviceLanguage", () => {
expect(detectDeviceLanguage([{ languageTag: "", languageCode: "ko" }])).toBe("ko");
});

it("keeps the script and region of a locale that has no languageTag", () => {
expect(
detectDeviceLanguage([{ languageCode: "zh", languageScriptCode: "Hant", regionCode: "TW" }])
).toBe("zh-Hant");
expect(detectDeviceLanguage([{ languageCode: "zh", regionCode: "HK" }])).toBe("zh-Hant");
expect(detectDeviceLanguage([{ languageCode: "zh", regionCode: "CN" }])).toBe("zh-Hans");
});

it("reads the phone's languages when none are passed", () => {
const spy = jest
.spyOn(Localization, "getLocales")
Expand Down Expand Up @@ -255,7 +309,7 @@ describe("isRTL", () => {
});

it("returns false for every other shell language", () => {
for (const code of CONSOLE_LANGUAGE_CODES.filter((c) => c !== "ar")) {
for (const code of PLATFORM_LANGUAGE_TAGS.filter((c) => c !== "ar")) {
expect(isRTL(code)).toBe(false);
}
});
Expand Down
30 changes: 16 additions & 14 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,36 @@ import es from "./locales/es.json";
import fr from "./locales/fr.json";
import ja from "./locales/ja.json";
import ko from "./locales/ko.json";
import pt from "./locales/pt.json";
import ptBR from "./locales/pt-BR.json";
import ru from "./locales/ru.json";
import zh from "./locales/zh.json";
import zhHans from "./locales/zh-Hans.json";
import zhHant from "./locales/zh-Hant.json";

// System permission prompts, localized from the app's own bundles (keys
// `permissions.camera`, `permissions.photos` and `permissions.faceId`) so one
// file holds each language's copy. iOS reads them
// from `<lproj>/InfoPlist.strings`; Android's camera prompt is the system's own
// text. Keys are iOS localization names, written verbatim as `<key>.lproj`:
// one per shell language (lib/i18n.ts). `zh` is Simplified Chinese, so it is
// `zh-Hans`; Traditional Chinese (`zh-Hant`) gets the same `zh` copy, because
// the shell resolves `zh-TW`/`zh-HK` to `zh` as the Console does. `pt` and `es`
// stay unqualified so every regional variant falls back to them.
// one per shell language (lib/i18n.ts), each with its own bundle's text —
// Simplified Chinese is `zh-Hans` and Traditional Chinese is `zh-Hant`. The
// Portuguese bundle is `pt-BR`, written as the unqualified `pt` so every
// Portuguese variant falls back to it, as the shell's own `pt-*` → `pt-BR`
// rule does; `es` stays unqualified for the same reason.
const PERMISSION_LOCALES: Record<
string,
{ permissions: { camera: string; photos: string; faceId: string } }
> = {
en,
"zh-Hans": zh,
"zh-Hant": zh,
ja,
ko,
de,
fr,
"zh-Hans": zhHans,
"zh-Hant": zhHant,
es,
pt,
ru,
ar,
fr,
pt: ptBR,
ru,
de,
ja,
ko,
};

// The EAS project this app builds and publishes under: `@objectstack/objectos`.
Expand Down
3 changes: 2 additions & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pnpm run lint # tsc + ESLint

- TypeScript strict; keep policy in pure modules under `lib/` with unit tests,
and keep components thin.
- Every user-visible string in `locales/{en,zh,ar}.json`.
- Every user-visible string in every bundle under `locales/` (the 11 languages in
`lib/i18n.ts`); regenerate `zh-Hant.json` from `zh-Hans.json` when that changes.
- Add a changeset (`pnpm changeset`) for user-facing changes.
- Branch from `main`, open a PR, keep CI green.
2 changes: 1 addition & 1 deletion docs/DEVICE-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ card. "Dev" means it needs a development build for the tooling named.
| #162 Ready signals | kill the app, tap a push notification: the target record opens with no second cover or full page reload |
| #163 Device registration | after a push token rotates, a signed-in Console receives `objectstack-native:pushTokenChanged`; `window.ObjectStackNative.installId` survives clearing WebView storage |
| #156 Connect | scan a real connect QR code; open an `objectos://connect` link from mail or notes; deny the camera permission |
| #159 Languages | set the phone to Japanese, Arabic and Traditional Chinese (`zh-TW`) in turn: native pages follow, `zh-TW` shows Simplified Chinese, Arabic reads right to left |
| #159 / #182 Languages | set the phone to Japanese, Arabic, 繁體中文 (Taiwan) and Português (Brasil) in turn: native pages follow, Taiwan shows Traditional Chinese (確定, not 确定), Português (Brasil) shows Portuguese, Arabic reads right to left; under 繁體中文 the camera permission prompt (open the QR scanner) is in Traditional Chinese |
| #146 Scan | in a Console page that calls `scan()`, a scanned code fills the field; closing the scanner returns `null`; a denied camera permission fails with `failed` |
| #152 Saved connections | add two connections and switch between them; remove one and confirm it is signed out; upgrade from a version that saved one server |
| #157 MDM | on Android with Test DPC or an EMM, push `serverUrl` and `lockServer=true`: the app goes straight to the confirm page, the switcher has no add-server row, the managed connection cannot be swiped away, and a failed probe says to contact the IT administrator |
Expand Down
Loading
Loading