feat(configuration): add persistent watch language selection (#30) #31
Merged@github-anthodev wants to merge
30/feat/configuration/watch-language-selector into developMerged as
1650944d6700.15 files changed
modified
CHANGELOG.md +12 -0 | @@ -1,5 +1,17 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## [Unreleased] | |
| 4 | + | |
| 5 | +The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. | |
| 6 | + | |
| 7 | +### Features | |
| 8 | + | |
| 9 | +- Add an accessible Automatic / Français / English selector and synchronize language-only changes without editing favorites (#30). | |
| 10 | + | |
| 11 | +### Fixes | |
| 12 | + | |
| 13 | +- Keep the previous language and favorites active when a replacement credential's configuration cannot be saved. | |
| 14 | + | |
| 3 | 15 | ## [1.1.0] - 2026-09-19 |
| 4 | 16 | |
| 5 | 17 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. |
| @@ -1,5 +1,17 @@ | |||
| 1 | # Changelog | 1 | # Changelog |
| 2 | 2 | ||
| 3 | +## [Unreleased] | ||
| 4 | + | ||
| 5 | +The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts. | ||
| 6 | + | ||
| 7 | +### Features | ||
| 8 | + | ||
| 9 | +- Add an accessible Automatic / Français / English selector and synchronize language-only changes without editing favorites (#30). | ||
| 10 | + | ||
| 11 | +### Fixes | ||
| 12 | + | ||
| 13 | +- Keep the previous language and favorites active when a replacement credential's configuration cannot be saved. | ||
| 14 | + | ||
| 3 | ## [1.1.0] - 2026-09-19 | 15 | ## [1.1.0] - 2026-09-19 |
| 4 | 16 | ||
| 5 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. | 17 | Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times. |
modified
README.md +1 -1 | @@ -46,7 +46,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | ||
| 46 | 46 | - **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. |
| 47 | 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. |
| 48 | 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). |
| 49 | -- **English and French.** The watch interface and the settings page are both bilingual. | |
| 49 | +- **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language. | |
| 50 | 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
| 51 | 51 | |
| 52 | 52 | ## Compatibility |
| @@ -46,7 +46,7 @@ Lapin Futé puts Île-de-France public-transport arrivals on a Pebble watch. The | |||
| 46 | - **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. | 46 | - **Traffic view.** Select again on an arrival board to read the most recently updated active disruption for that line, using PRIM's `lastUpdate` rather than severity. Works are excluded, including works already under way; future and expired disruptions are excluded as well. If the selected English message lacks a usable title or body, the app uses the complete French message for that same incident. This may require one additional French request when no fresh French response is already available. |
| 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. | 47 | - **Refresh when needed.** Open the app, switch favorite, choose Refresh all, or hold Select to request arrivals. Recent results are reused for 60 seconds; after a failed refresh, the last complete result remains visibly stale for at most 15 minutes. There is no periodic network polling. |
| 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). | 48 | - **Clear data states.** The app distinguishes real-time arrivals, mixed real-time and scheduled results, updates in progress, missing cached data, and trips whose route to your arrival is unconfirmed (`?`). |
| 49 | -- **English and French.** The watch interface and the settings page are both bilingual. | 49 | +- **English and French.** In settings, choose Automatic to follow the watch system language, or force Français or English for the watch app. Save applies the choice even when favorites are unchanged. The phone remembers it across restarts; reopening settings uses the effective language. |
| 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. | 50 | - **Settings that fit your phone.** Light and dark themes follow your system preference. Collapsible sections keep favorite editing, PRIM access, and watch synchronization easy to reach. |
| 51 | 51 | ||
| 52 | ## Compatibility | 52 | ## Compatibility |
modified
packages/companion/src/configuration.js +44 -13 | @@ -2,11 +2,13 @@ | ||
| 2 | 2 | |
| 3 | 3 | var contracts = require("./contracts"); |
| 4 | 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; |
| 5 | +// Phone-local preference version; page envelopes remain CONFIGURATION_VERSION 2. | |
| 6 | +var CONFIG_SCHEMA_VERSION = 3; | |
| 5 | 7 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; |
| 6 | 8 | // Phone-local cache version. Earlier caches did not bind results to an arrival. |
| 7 | 9 | var CACHE_SCHEMA_VERSION = 3; |
| 8 | 10 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; |
| 9 | -var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"]; | |
| 11 | +var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus", "languagePreference"]; | |
| 10 | 12 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; |
| 11 | 13 | var OVERVIEW_ENTRY_KEYS = [ |
| 12 | 14 | "favoriteId", |
| @@ -24,7 +26,7 @@ var INVALID_KEY_STATUS_RECORD_KEYS = [ | ||
| 24 | 26 | "keyStatus", |
| 25 | 27 | "configurationFingerprint" |
| 26 | 28 | ]; |
| 27 | -var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync"]; | |
| 29 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync", "languagePreference"]; | |
| 28 | 30 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; |
| 29 | 31 | var MAX_SAFE_INTEGER = 9007199254740991; |
| 30 | 32 | var FAVORITE_STRING_KEYS = [ |
| @@ -47,19 +49,21 @@ var ROUTING_STRING_KEYS = [ | ||
| 47 | 49 | |
| 48 | 50 | function emptyConfiguration() { |
| 49 | 51 | return { |
| 50 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 52 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 51 | 53 | favorites: [], |
| 52 | 54 | primApiKey: null, |
| 53 | - keyStatus: contracts.KEY_STATUS.MISSING | |
| 55 | + keyStatus: contracts.KEY_STATUS.MISSING, | |
| 56 | + languagePreference: "auto" | |
| 54 | 57 | }; |
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 60 | function copyConfiguration(value) { |
| 58 | 61 | return { |
| 59 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 62 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 60 | 63 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 61 | 64 | primApiKey: value.primApiKey, |
| 62 | - keyStatus: value.keyStatus | |
| 65 | + keyStatus: value.keyStatus, | |
| 66 | + languagePreference: value.languagePreference | |
| 63 | 67 | }; |
| 64 | 68 | } |
| 65 | 69 | |
| @@ -162,10 +166,20 @@ function activeWatchLanguage(Pebble) { | ||
| 162 | 166 | } |
| 163 | 167 | } |
| 164 | 168 | |
| 169 | +function isLanguagePreference(value) { | |
| 170 | + return value === "auto" || value === contracts.WIRE_LANGUAGE.EN || value === contracts.WIRE_LANGUAGE.FR; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function effectiveWatchLanguage(Pebble, preference) { | |
| 174 | + return preference === "auto" ? activeWatchLanguage(Pebble) : preference; | |
| 175 | +} | |
| 176 | + | |
| 165 | 177 | function isStoredConfiguration(value) { |
| 166 | 178 | if (!contracts.isObject(value) |
| 167 | 179 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) |
| 168 | - || value.schemaVersion !== contracts.CONFIGURATION_VERSION | |
| 180 | + || value.schemaVersion !== CONFIG_SCHEMA_VERSION | |
| 181 | + || !Object.prototype.hasOwnProperty.call(value, "languagePreference") | |
| 182 | + || !isLanguagePreference(value.languagePreference) | |
| 169 | 183 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; |
| 170 | 184 | if (value.primApiKey === null) { |
| 171 | 185 | return value.keyStatus === contracts.KEY_STATUS.MISSING; |
| @@ -179,8 +193,13 @@ function isStoredConfiguration(value) { | ||
| 179 | 193 | function recoverStoredConfiguration(value) { |
| 180 | 194 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) |
| 181 | 195 | || value.schemaVersion !== contracts.SCHEMA_VERSION |
| 182 | - && value.schemaVersion !== contracts.CONFIGURATION_VERSION) return null; | |
| 196 | + && value.schemaVersion !== contracts.CONFIGURATION_VERSION | |
| 197 | + && value.schemaVersion !== CONFIG_SCHEMA_VERSION) return null; | |
| 183 | 198 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; |
| 199 | + if (value.schemaVersion !== CONFIG_SCHEMA_VERSION | |
| 200 | + && !Object.prototype.hasOwnProperty.call(value, "languagePreference")) { | |
| 201 | + value.languagePreference = "auto"; | |
| 202 | + } | |
| 184 | 203 | // This freshly parsed record is private to restoration. Routing is replaceable |
| 185 | 204 | // catalog enrichment; preserve every other field for ordinary validation. |
| 186 | 205 | value.favorites.forEach(function (favorite) { |
| @@ -192,7 +211,7 @@ function recoverStoredConfiguration(value) { | ||
| 192 | 211 | && Object.prototype.hasOwnProperty.call(favorite, "routing") |
| 193 | 212 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; |
| 194 | 213 | }); |
| 195 | - value.schemaVersion = contracts.CONFIGURATION_VERSION; | |
| 214 | + value.schemaVersion = CONFIG_SCHEMA_VERSION; | |
| 196 | 215 | return isStoredConfiguration(value) ? value : null; |
| 197 | 216 | } |
| 198 | 217 | |
| @@ -202,6 +221,8 @@ function isConfigurationUpdate(value) { | ||
| 202 | 221 | && value.schemaVersion === contracts.CONFIGURATION_VERSION |
| 203 | 222 | && contracts.isPhoneFavoriteList(value.favorites) |
| 204 | 223 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) |
| 224 | + && (!Object.prototype.hasOwnProperty.call(value, "languagePreference") | |
| 225 | + || isLanguagePreference(value.languagePreference)) | |
| 205 | 226 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") |
| 206 | 227 | || typeof value.forceFullSync === "boolean"); |
| 207 | 228 | } |
| @@ -460,7 +481,7 @@ function loadConfiguration(storage) { | ||
| 460 | 481 | var current = parseStored(storage, CONFIG_STORAGE_KEY); |
| 461 | 482 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; |
| 462 | 483 | var loaded; |
| 463 | - var migration = current.value && current.value.schemaVersion === contracts.SCHEMA_VERSION; | |
| 484 | + var migration = current.value && current.value.schemaVersion !== CONFIG_SCHEMA_VERSION; | |
| 464 | 485 | if (!current.present) return emptyConfiguration(); |
| 465 | 486 | loaded = recoverStoredConfiguration(current.value); |
| 466 | 487 | if (loaded === null) return null; |
| @@ -695,16 +716,19 @@ function applyConfigurationUpdate(current, update) { | ||
| 695 | 716 | keyStatus = contracts.KEY_STATUS.MISSING; |
| 696 | 717 | } |
| 697 | 718 | return { |
| 698 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 719 | + schemaVersion: CONFIG_SCHEMA_VERSION, | |
| 699 | 720 | favorites: update.favorites.map(contracts.copyPhoneFavorite), |
| 700 | 721 | primApiKey: primApiKey, |
| 701 | - keyStatus: keyStatus | |
| 722 | + keyStatus: keyStatus, | |
| 723 | + languagePreference: Object.prototype.hasOwnProperty.call(update, "languagePreference") | |
| 724 | + ? update.languagePreference : current.languagePreference | |
| 702 | 725 | }; |
| 703 | 726 | } |
| 704 | 727 | |
| 705 | 728 | function parseCloseFragment(response) { |
| 706 | 729 | var fragment; |
| 707 | 730 | var parsed; |
| 731 | + var update; | |
| 708 | 732 | var closePrefix = "pebblejs://close#"; |
| 709 | 733 | if (typeof response !== "string" |
| 710 | 734 | || response.length === 0 |
| @@ -725,7 +749,7 @@ function parseCloseFragment(response) { | ||
| 725 | 749 | } |
| 726 | 750 | } |
| 727 | 751 | if (!isConfigurationUpdate(parsed)) return null; |
| 728 | - return { | |
| 752 | + update = { | |
| 729 | 753 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 730 | 754 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), |
| 731 | 755 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" |
| @@ -733,6 +757,10 @@ function parseCloseFragment(response) { | ||
| 733 | 757 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, |
| 734 | 758 | forceFullSync: parsed.forceFullSync === true |
| 735 | 759 | }; |
| 760 | + if (Object.prototype.hasOwnProperty.call(parsed, "languagePreference")) { | |
| 761 | + update.languagePreference = parsed.languagePreference; | |
| 762 | + } | |
| 763 | + return update; | |
| 736 | 764 | } |
| 737 | 765 | |
| 738 | 766 | function configurationPageState(value, language) { |
| @@ -740,6 +768,7 @@ function configurationPageState(value, language) { | ||
| 740 | 768 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 741 | 769 | hasKey: value.primApiKey !== null, |
| 742 | 770 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 771 | + languagePreference: value.languagePreference, | |
| 743 | 772 | language: typeof language === "string" && language.length > 0 ? language : "en" |
| 744 | 773 | }; |
| 745 | 774 | } |
| @@ -764,6 +793,7 @@ function configurationUrl(baseUrl, value, language) { | ||
| 764 | 793 | |
| 765 | 794 | module.exports = { |
| 766 | 795 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, |
| 796 | + CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION, | |
| 767 | 797 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, |
| 768 | 798 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, |
| 769 | 799 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, |
| @@ -776,6 +806,7 @@ module.exports = { | ||
| 776 | 806 | areFavoritesSecretFree: areFavoritesSecretFree, |
| 777 | 807 | normalizeLanguage: normalizeLanguage, |
| 778 | 808 | activeWatchLanguage: activeWatchLanguage, |
| 809 | + effectiveWatchLanguage: effectiveWatchLanguage, | |
| 779 | 810 | loadConfiguration: loadConfiguration, |
| 780 | 811 | saveConfiguration: saveConfiguration, |
| 781 | 812 | saveInvalidConfiguration: saveInvalidConfiguration, |
| @@ -2,11 +2,13 @@ | |||
| 2 | 2 | ||
| 3 | var contracts = require("./contracts"); | 3 | var contracts = require("./contracts"); |
| 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; | 4 | var CONFIG_STORAGE_KEY = "lapinFuteConfig"; |
| 5 | +// Phone-local preference version; page envelopes remain CONFIGURATION_VERSION 2. | ||
| 6 | +var CONFIG_SCHEMA_VERSION = 3; | ||
| 5 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; | 7 | var RESULTS_STORAGE_KEY = "lapinFuteResults"; |
| 6 | // Phone-local cache version. Earlier caches did not bind results to an arrival. | 8 | // Phone-local cache version. Earlier caches did not bind results to an arrival. |
| 7 | var CACHE_SCHEMA_VERSION = 3; | 9 | var CACHE_SCHEMA_VERSION = 3; |
| 8 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; | 10 | var INVALID_KEY_STATUS_STORAGE_KEY = "lapinFuteInvalidKeyStatus"; |
| 9 | -var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus"]; | 11 | +var CONFIG_RECORD_KEYS = ["schemaVersion", "favorites", "primApiKey", "keyStatus", "languagePreference"]; |
| 10 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; | 12 | var CACHE_RECORD_KEYS = ["schemaVersion", "overview", "trafficDetails"]; |
| 11 | var OVERVIEW_ENTRY_KEYS = [ | 13 | var OVERVIEW_ENTRY_KEYS = [ |
| 12 | "favoriteId", | 14 | "favoriteId", |
| @@ -24,7 +26,7 @@ var INVALID_KEY_STATUS_RECORD_KEYS = [ | |||
| 24 | "keyStatus", | 26 | "keyStatus", |
| 25 | "configurationFingerprint" | 27 | "configurationFingerprint" |
| 26 | ]; | 28 | ]; |
| 27 | -var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync"]; | 29 | +var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate", "forceFullSync", "languagePreference"]; |
| 28 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; | 30 | var MAX_CLOSE_RESPONSE_LENGTH = 32768; |
| 29 | var MAX_SAFE_INTEGER = 9007199254740991; | 31 | var MAX_SAFE_INTEGER = 9007199254740991; |
| 30 | var FAVORITE_STRING_KEYS = [ | 32 | var FAVORITE_STRING_KEYS = [ |
| @@ -47,19 +49,21 @@ var ROUTING_STRING_KEYS = [ | |||
| 47 | 49 | ||
| 48 | function emptyConfiguration() { | 50 | function emptyConfiguration() { |
| 49 | return { | 51 | return { |
| 50 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 52 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 51 | favorites: [], | 53 | favorites: [], |
| 52 | primApiKey: null, | 54 | primApiKey: null, |
| 53 | - keyStatus: contracts.KEY_STATUS.MISSING | 55 | + keyStatus: contracts.KEY_STATUS.MISSING, |
| 56 | + languagePreference: "auto" | ||
| 54 | }; | 57 | }; |
| 55 | } | 58 | } |
| 56 | 59 | ||
| 57 | function copyConfiguration(value) { | 60 | function copyConfiguration(value) { |
| 58 | return { | 61 | return { |
| 59 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 62 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 60 | favorites: value.favorites.map(contracts.copyPhoneFavorite), | 63 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 61 | primApiKey: value.primApiKey, | 64 | primApiKey: value.primApiKey, |
| 62 | - keyStatus: value.keyStatus | 65 | + keyStatus: value.keyStatus, |
| 66 | + languagePreference: value.languagePreference | ||
| 63 | }; | 67 | }; |
| 64 | } | 68 | } |
| 65 | 69 | ||
| @@ -162,10 +166,20 @@ function activeWatchLanguage(Pebble) { | |||
| 162 | } | 166 | } |
| 163 | } | 167 | } |
| 164 | 168 | ||
| 169 | +function isLanguagePreference(value) { | ||
| 170 | + return value === "auto" || value === contracts.WIRE_LANGUAGE.EN || value === contracts.WIRE_LANGUAGE.FR; | ||
| 171 | +} | ||
| 172 | + | ||
| 173 | +function effectiveWatchLanguage(Pebble, preference) { | ||
| 174 | + return preference === "auto" ? activeWatchLanguage(Pebble) : preference; | ||
| 175 | +} | ||
| 176 | + | ||
| 165 | function isStoredConfiguration(value) { | 177 | function isStoredConfiguration(value) { |
| 166 | if (!contracts.isObject(value) | 178 | if (!contracts.isObject(value) |
| 167 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) | 179 | || !contracts.hasOnlyKeys(value, CONFIG_RECORD_KEYS) |
| 168 | - || value.schemaVersion !== contracts.CONFIGURATION_VERSION | 180 | + || value.schemaVersion !== CONFIG_SCHEMA_VERSION |
| 181 | + || !Object.prototype.hasOwnProperty.call(value, "languagePreference") | ||
| 182 | + || !isLanguagePreference(value.languagePreference) | ||
| 169 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; | 183 | || !contracts.isPhoneFavoriteList(value.favorites)) return false; |
| 170 | if (value.primApiKey === null) { | 184 | if (value.primApiKey === null) { |
| 171 | return value.keyStatus === contracts.KEY_STATUS.MISSING; | 185 | return value.keyStatus === contracts.KEY_STATUS.MISSING; |
| @@ -179,8 +193,13 @@ function isStoredConfiguration(value) { | |||
| 179 | function recoverStoredConfiguration(value) { | 193 | function recoverStoredConfiguration(value) { |
| 180 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) | 194 | if (!contracts.isObject(value) || !Array.isArray(value.favorites) |
| 181 | || value.schemaVersion !== contracts.SCHEMA_VERSION | 195 | || value.schemaVersion !== contracts.SCHEMA_VERSION |
| 182 | - && value.schemaVersion !== contracts.CONFIGURATION_VERSION) return null; | 196 | + && value.schemaVersion !== contracts.CONFIGURATION_VERSION |
| 197 | + && value.schemaVersion !== CONFIG_SCHEMA_VERSION) return null; | ||
| 183 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; | 198 | var legacy = value.schemaVersion === contracts.SCHEMA_VERSION; |
| 199 | + if (value.schemaVersion !== CONFIG_SCHEMA_VERSION | ||
| 200 | + && !Object.prototype.hasOwnProperty.call(value, "languagePreference")) { | ||
| 201 | + value.languagePreference = "auto"; | ||
| 202 | + } | ||
| 184 | // This freshly parsed record is private to restoration. Routing is replaceable | 203 | // This freshly parsed record is private to restoration. Routing is replaceable |
| 185 | // catalog enrichment; preserve every other field for ordinary validation. | 204 | // catalog enrichment; preserve every other field for ordinary validation. |
| 186 | value.favorites.forEach(function (favorite) { | 205 | value.favorites.forEach(function (favorite) { |
| @@ -192,7 +211,7 @@ function recoverStoredConfiguration(value) { | |||
| 192 | && Object.prototype.hasOwnProperty.call(favorite, "routing") | 211 | && Object.prototype.hasOwnProperty.call(favorite, "routing") |
| 193 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; | 212 | && !contracts.isServiceRouting(favorite.routing)) delete favorite.routing; |
| 194 | }); | 213 | }); |
| 195 | - value.schemaVersion = contracts.CONFIGURATION_VERSION; | 214 | + value.schemaVersion = CONFIG_SCHEMA_VERSION; |
| 196 | return isStoredConfiguration(value) ? value : null; | 215 | return isStoredConfiguration(value) ? value : null; |
| 197 | } | 216 | } |
| 198 | 217 | ||
| @@ -202,6 +221,8 @@ function isConfigurationUpdate(value) { | |||
| 202 | && value.schemaVersion === contracts.CONFIGURATION_VERSION | 221 | && value.schemaVersion === contracts.CONFIGURATION_VERSION |
| 203 | && contracts.isPhoneFavoriteList(value.favorites) | 222 | && contracts.isPhoneFavoriteList(value.favorites) |
| 204 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) | 223 | && contracts.isApiKeyUpdate(value.apiKeyUpdate) |
| 224 | + && (!Object.prototype.hasOwnProperty.call(value, "languagePreference") | ||
| 225 | + || isLanguagePreference(value.languagePreference)) | ||
| 205 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") | 226 | && (!Object.prototype.hasOwnProperty.call(value, "forceFullSync") |
| 206 | || typeof value.forceFullSync === "boolean"); | 227 | || typeof value.forceFullSync === "boolean"); |
| 207 | } | 228 | } |
| @@ -460,7 +481,7 @@ function loadConfiguration(storage) { | |||
| 460 | var current = parseStored(storage, CONFIG_STORAGE_KEY); | 481 | var current = parseStored(storage, CONFIG_STORAGE_KEY); |
| 461 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; | 482 | var invalidMarker = parseStored(storage, INVALID_KEY_STATUS_STORAGE_KEY).value; |
| 462 | var loaded; | 483 | var loaded; |
| 463 | - var migration = current.value && current.value.schemaVersion === contracts.SCHEMA_VERSION; | 484 | + var migration = current.value && current.value.schemaVersion !== CONFIG_SCHEMA_VERSION; |
| 464 | if (!current.present) return emptyConfiguration(); | 485 | if (!current.present) return emptyConfiguration(); |
| 465 | loaded = recoverStoredConfiguration(current.value); | 486 | loaded = recoverStoredConfiguration(current.value); |
| 466 | if (loaded === null) return null; | 487 | if (loaded === null) return null; |
| @@ -695,16 +716,19 @@ function applyConfigurationUpdate(current, update) { | |||
| 695 | keyStatus = contracts.KEY_STATUS.MISSING; | 716 | keyStatus = contracts.KEY_STATUS.MISSING; |
| 696 | } | 717 | } |
| 697 | return { | 718 | return { |
| 698 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 719 | + schemaVersion: CONFIG_SCHEMA_VERSION, |
| 699 | favorites: update.favorites.map(contracts.copyPhoneFavorite), | 720 | favorites: update.favorites.map(contracts.copyPhoneFavorite), |
| 700 | primApiKey: primApiKey, | 721 | primApiKey: primApiKey, |
| 701 | - keyStatus: keyStatus | 722 | + keyStatus: keyStatus, |
| 723 | + languagePreference: Object.prototype.hasOwnProperty.call(update, "languagePreference") | ||
| 724 | + ? update.languagePreference : current.languagePreference | ||
| 702 | }; | 725 | }; |
| 703 | } | 726 | } |
| 704 | 727 | ||
| 705 | function parseCloseFragment(response) { | 728 | function parseCloseFragment(response) { |
| 706 | var fragment; | 729 | var fragment; |
| 707 | var parsed; | 730 | var parsed; |
| 731 | + var update; | ||
| 708 | var closePrefix = "pebblejs://close#"; | 732 | var closePrefix = "pebblejs://close#"; |
| 709 | if (typeof response !== "string" | 733 | if (typeof response !== "string" |
| 710 | || response.length === 0 | 734 | || response.length === 0 |
| @@ -725,7 +749,7 @@ function parseCloseFragment(response) { | |||
| 725 | } | 749 | } |
| 726 | } | 750 | } |
| 727 | if (!isConfigurationUpdate(parsed)) return null; | 751 | if (!isConfigurationUpdate(parsed)) return null; |
| 728 | - return { | 752 | + update = { |
| 729 | schemaVersion: contracts.CONFIGURATION_VERSION, | 753 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 730 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), | 754 | favorites: parsed.favorites.map(contracts.copyPhoneFavorite), |
| 731 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" | 755 | apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE" |
| @@ -733,6 +757,10 @@ function parseCloseFragment(response) { | |||
| 733 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, | 757 | : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }, |
| 734 | forceFullSync: parsed.forceFullSync === true | 758 | forceFullSync: parsed.forceFullSync === true |
| 735 | }; | 759 | }; |
| 760 | + if (Object.prototype.hasOwnProperty.call(parsed, "languagePreference")) { | ||
| 761 | + update.languagePreference = parsed.languagePreference; | ||
| 762 | + } | ||
| 763 | + return update; | ||
| 736 | } | 764 | } |
| 737 | 765 | ||
| 738 | function configurationPageState(value, language) { | 766 | function configurationPageState(value, language) { |
| @@ -740,6 +768,7 @@ function configurationPageState(value, language) { | |||
| 740 | schemaVersion: contracts.CONFIGURATION_VERSION, | 768 | schemaVersion: contracts.CONFIGURATION_VERSION, |
| 741 | hasKey: value.primApiKey !== null, | 769 | hasKey: value.primApiKey !== null, |
| 742 | favorites: value.favorites.map(contracts.copyPhoneFavorite), | 770 | favorites: value.favorites.map(contracts.copyPhoneFavorite), |
| 771 | + languagePreference: value.languagePreference, | ||
| 743 | language: typeof language === "string" && language.length > 0 ? language : "en" | 772 | language: typeof language === "string" && language.length > 0 ? language : "en" |
| 744 | }; | 773 | }; |
| 745 | } | 774 | } |
| @@ -764,6 +793,7 @@ function configurationUrl(baseUrl, value, language) { | |||
| 764 | 793 | ||
| 765 | module.exports = { | 794 | module.exports = { |
| 766 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, | 795 | CONFIG_STORAGE_KEY: CONFIG_STORAGE_KEY, |
| 796 | + CONFIG_SCHEMA_VERSION: CONFIG_SCHEMA_VERSION, | ||
| 767 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, | 797 | RESULTS_STORAGE_KEY: RESULTS_STORAGE_KEY, |
| 768 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, | 798 | INVALID_KEY_STATUS_STORAGE_KEY: INVALID_KEY_STATUS_STORAGE_KEY, |
| 769 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, | 799 | MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH, |
| @@ -776,6 +806,7 @@ module.exports = { | |||
| 776 | areFavoritesSecretFree: areFavoritesSecretFree, | 806 | areFavoritesSecretFree: areFavoritesSecretFree, |
| 777 | normalizeLanguage: normalizeLanguage, | 807 | normalizeLanguage: normalizeLanguage, |
| 778 | activeWatchLanguage: activeWatchLanguage, | 808 | activeWatchLanguage: activeWatchLanguage, |
| 809 | + effectiveWatchLanguage: effectiveWatchLanguage, | ||
| 779 | loadConfiguration: loadConfiguration, | 810 | loadConfiguration: loadConfiguration, |
| 780 | saveConfiguration: saveConfiguration, | 811 | saveConfiguration: saveConfiguration, |
| 781 | saveInvalidConfiguration: saveInvalidConfiguration, | 812 | saveInvalidConfiguration: saveInvalidConfiguration, |
modified
packages/companion/src/contracts.js +2 -2 | @@ -1,8 +1,8 @@ | ||
| 1 | 1 | "use strict"; |
| 2 | 2 | |
| 3 | 3 | var SCHEMA_VERSION = 1; |
| 4 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | |
| 5 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | |
| 4 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion | |
| 5 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate | |
| 6 | 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 7 | 7 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 8 | 8 | // a v1 close. |
| @@ -1,8 +1,8 @@ | |||
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | 2 | ||
| 3 | var SCHEMA_VERSION = 1; | 3 | var SCHEMA_VERSION = 1; |
| 4 | -// CONFIGURATION_VERSION 2 covers only the stored configuration record and the | 4 | +// CONFIGURATION_VERSION 2 covers the page open/close envelopes. The companion |
| 5 | -// page open/close envelopes. Domain objects, nested favorites and ApiKeyUpdate | 5 | +// versions its phone-local stored record separately. Domain objects and ApiKeyUpdate |
| 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty | 6 | // keep SCHEMA_VERSION 1. Versioning the envelope also protects an empty |
| 7 | // backup: a phone that cannot read v2 must never blank the configuration with | 7 | // backup: a phone that cannot read v2 must never blank the configuration with |
| 8 | // a v1 close. | 8 | // a v1 close. |
modified
packages/companion/src/index.js +21 -14 | @@ -306,7 +306,7 @@ Companion.prototype._onShowConfiguration = function () { | ||
| 306 | 306 | var language; |
| 307 | 307 | var url; |
| 308 | 308 | if (!this._configurationValid) return; |
| 309 | - language = configuration.activeWatchLanguage(this._Pebble); | |
| 309 | + language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); | |
| 310 | 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); |
| 311 | 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 312 | 312 | }; |
| @@ -328,12 +328,15 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 328 | 328 | if (this._credentialBlocked |
| 329 | 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { |
| 330 | 330 | pending = { |
| 331 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 331 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 332 | 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), |
| 333 | 333 | primApiKey: next.primApiKey, |
| 334 | - keyStatus: contracts.KEY_STATUS.INVALID | |
| 334 | + keyStatus: contracts.KEY_STATUS.INVALID, | |
| 335 | + languagePreference: next.languagePreference | |
| 335 | 336 | }; |
| 336 | - if (!configuration.saveInvalidConfiguration(this._storage, pending)) return; | |
| 337 | + // A journal alone cannot persist edited favorites or language. Commit the | |
| 338 | + // INVALID candidate before removing the previous credential's authority. | |
| 339 | + if (!configuration.saveConfiguration(this._storage, pending)) return; | |
| 337 | 340 | if (!configuration.clearInvalidKeyStatus(this._storage) |
| 338 | 341 | || !configuration.saveConfiguration(this._storage, next)) next = pending; |
| 339 | 342 | } else if (!configuration.clearInvalidKeyStatus(this._storage) |
| @@ -349,7 +352,7 @@ Companion.prototype._onWebviewClosed = function (event) { | ||
| 349 | 352 | nextWatch = next.favorites.map(contracts.copyFavorite); |
| 350 | 353 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) |
| 351 | 354 | || next.keyStatus !== this._configuration.keyStatus |
| 352 | - || configuration.activeWatchLanguage(this._Pebble) !== this._watchLanguage; | |
| 355 | + || configuration.effectiveWatchLanguage(this._Pebble, next.languagePreference) !== this._watchLanguage; | |
| 353 | 356 | invalidate = update.apiKeyUpdate.action !== "KEEP" |
| 354 | 357 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) |
| 355 | 358 | || this._configuration.favorites.some(function (favorite) { |
| @@ -381,7 +384,7 @@ Companion.prototype._sendConfiguration = function (forceFull) { | ||
| 381 | 384 | var binding = this._sync.binding(); |
| 382 | 385 | if (!this._configurationValid || !binding.epoch |
| 383 | 386 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; |
| 384 | - var language = configuration.activeWatchLanguage(this._Pebble); | |
| 387 | + var language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); | |
| 385 | 388 | var records; |
| 386 | 389 | try { |
| 387 | 390 | records = this._configuration.favorites.map(function (favorite) { |
| @@ -782,10 +785,11 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | ||
| 782 | 785 | if (remaining !== 0) return; |
| 783 | 786 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } |
| 784 | 787 | hydrated = { |
| 785 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 788 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 786 | 789 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 787 | 790 | primApiKey: self._configuration.primApiKey, |
| 788 | - keyStatus: self._configuration.keyStatus | |
| 791 | + keyStatus: self._configuration.keyStatus, | |
| 792 | + languagePreference: self._configuration.languagePreference | |
| 789 | 793 | }; |
| 790 | 794 | hydrated.favorites.forEach(function (favorite) { |
| 791 | 795 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { |
| @@ -800,7 +804,7 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | ||
| 800 | 804 | return; |
| 801 | 805 | } |
| 802 | 806 | if (changed && configuration.configurationUrl( |
| 803 | - self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble) | |
| 807 | + self._configurationUrl, hydrated, configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference) | |
| 804 | 808 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) |
| 805 | 809 | && flight.generation === self._lifecycleGeneration) { |
| 806 | 810 | self._configuration = hydrated; |
| @@ -1028,9 +1032,10 @@ Companion.prototype._startOverviewFlight = function (request) { | ||
| 1028 | 1032 | if (result !== null) { |
| 1029 | 1033 | groups[serviceId] = result.group; |
| 1030 | 1034 | var hydrated = { |
| 1031 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 1035 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 1032 | 1036 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1033 | - primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus | |
| 1037 | + primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus, | |
| 1038 | + languagePreference: self._configuration.languagePreference | |
| 1034 | 1039 | }; |
| 1035 | 1040 | var changed = false; |
| 1036 | 1041 | hydrated.favorites.forEach(function (favorite) { |
| @@ -1043,7 +1048,8 @@ Companion.prototype._startOverviewFlight = function (request) { | ||
| 1043 | 1048 | changed = true; |
| 1044 | 1049 | }); |
| 1045 | 1050 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) |
| 1046 | - && configuration.configurationUrl(self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble)) !== null | |
| 1051 | + && configuration.configurationUrl(self._configurationUrl, hydrated, | |
| 1052 | + configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference)) !== null | |
| 1047 | 1053 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { |
| 1048 | 1054 | self._configuration = hydrated; |
| 1049 | 1055 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); |
| @@ -1148,10 +1154,11 @@ Companion.prototype._invalidateCredential = function () { | ||
| 1148 | 1154 | if (this._configuration.primApiKey === null |
| 1149 | 1155 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; |
| 1150 | 1156 | invalid = { |
| 1151 | - schemaVersion: contracts.CONFIGURATION_VERSION, | |
| 1157 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, | |
| 1152 | 1158 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1153 | 1159 | primApiKey: this._configuration.primApiKey, |
| 1154 | - keyStatus: contracts.KEY_STATUS.INVALID | |
| 1160 | + keyStatus: contracts.KEY_STATUS.INVALID, | |
| 1161 | + languagePreference: this._configuration.languagePreference | |
| 1155 | 1162 | }; |
| 1156 | 1163 | configuration.saveInvalidConfiguration(this._storage, invalid); |
| 1157 | 1164 | this._invalidateLifecycle(); |
| @@ -306,7 +306,7 @@ Companion.prototype._onShowConfiguration = function () { | |||
| 306 | var language; | 306 | var language; |
| 307 | var url; | 307 | var url; |
| 308 | if (!this._configurationValid) return; | 308 | if (!this._configurationValid) return; |
| 309 | - language = configuration.activeWatchLanguage(this._Pebble); | 309 | + language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); |
| 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); | 310 | url = configuration.configurationUrl(this._configurationUrl, this._configuration, language); |
| 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); | 311 | if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url); |
| 312 | }; | 312 | }; |
| @@ -328,12 +328,15 @@ Companion.prototype._onWebviewClosed = function (event) { | |||
| 328 | if (this._credentialBlocked | 328 | if (this._credentialBlocked |
| 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { | 329 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) { |
| 330 | pending = { | 330 | pending = { |
| 331 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 331 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), | 332 | favorites: next.favorites.map(contracts.copyPhoneFavorite), |
| 333 | primApiKey: next.primApiKey, | 333 | primApiKey: next.primApiKey, |
| 334 | - keyStatus: contracts.KEY_STATUS.INVALID | 334 | + keyStatus: contracts.KEY_STATUS.INVALID, |
| 335 | + languagePreference: next.languagePreference | ||
| 335 | }; | 336 | }; |
| 336 | - if (!configuration.saveInvalidConfiguration(this._storage, pending)) return; | 337 | + // A journal alone cannot persist edited favorites or language. Commit the |
| 338 | + // INVALID candidate before removing the previous credential's authority. | ||
| 339 | + if (!configuration.saveConfiguration(this._storage, pending)) return; | ||
| 337 | if (!configuration.clearInvalidKeyStatus(this._storage) | 340 | if (!configuration.clearInvalidKeyStatus(this._storage) |
| 338 | || !configuration.saveConfiguration(this._storage, next)) next = pending; | 341 | || !configuration.saveConfiguration(this._storage, next)) next = pending; |
| 339 | } else if (!configuration.clearInvalidKeyStatus(this._storage) | 342 | } else if (!configuration.clearInvalidKeyStatus(this._storage) |
| @@ -349,7 +352,7 @@ Companion.prototype._onWebviewClosed = function (event) { | |||
| 349 | nextWatch = next.favorites.map(contracts.copyFavorite); | 352 | nextWatch = next.favorites.map(contracts.copyFavorite); |
| 350 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) | 353 | wireChanged = JSON.stringify(nextWatch) !== JSON.stringify(this._watchFavorites) |
| 351 | || next.keyStatus !== this._configuration.keyStatus | 354 | || next.keyStatus !== this._configuration.keyStatus |
| 352 | - || configuration.activeWatchLanguage(this._Pebble) !== this._watchLanguage; | 355 | + || configuration.effectiveWatchLanguage(this._Pebble, next.languagePreference) !== this._watchLanguage; |
| 353 | invalidate = update.apiKeyUpdate.action !== "KEEP" | 356 | invalidate = update.apiKeyUpdate.action !== "KEEP" |
| 354 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) | 357 | || !sameFavoriteContentSet(this._watchFavorites, nextWatch) |
| 355 | || this._configuration.favorites.some(function (favorite) { | 358 | || this._configuration.favorites.some(function (favorite) { |
| @@ -381,7 +384,7 @@ Companion.prototype._sendConfiguration = function (forceFull) { | |||
| 381 | var binding = this._sync.binding(); | 384 | var binding = this._sync.binding(); |
| 382 | if (!this._configurationValid || !binding.epoch | 385 | if (!this._configurationValid || !binding.epoch |
| 383 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; | 386 | || !configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.primApiKey, null)) return; |
| 384 | - var language = configuration.activeWatchLanguage(this._Pebble); | 387 | + var language = configuration.effectiveWatchLanguage(this._Pebble, this._configuration.languagePreference); |
| 385 | var records; | 388 | var records; |
| 386 | try { | 389 | try { |
| 387 | records = this._configuration.favorites.map(function (favorite) { | 390 | records = this._configuration.favorites.map(function (favorite) { |
| @@ -782,10 +785,11 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | |||
| 782 | if (remaining !== 0) return; | 785 | if (remaining !== 0) return; |
| 783 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } | 786 | if (Object.keys(recovered).length === 0) { complete(recovered); return; } |
| 784 | hydrated = { | 787 | hydrated = { |
| 785 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 788 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 786 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), | 789 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 787 | primApiKey: self._configuration.primApiKey, | 790 | primApiKey: self._configuration.primApiKey, |
| 788 | - keyStatus: self._configuration.keyStatus | 791 | + keyStatus: self._configuration.keyStatus, |
| 792 | + languagePreference: self._configuration.languagePreference | ||
| 789 | }; | 793 | }; |
| 790 | hydrated.favorites.forEach(function (favorite) { | 794 | hydrated.favorites.forEach(function (favorite) { |
| 791 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { | 795 | if (!favorite.routing && recovered[favorite.serviceId] && favorites.some(function (captured) { |
| @@ -800,7 +804,7 @@ Companion.prototype._hydrateRouting = function (flight, favorites, complete) { | |||
| 800 | return; | 804 | return; |
| 801 | } | 805 | } |
| 802 | if (changed && configuration.configurationUrl( | 806 | if (changed && configuration.configurationUrl( |
| 803 | - self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble) | 807 | + self._configurationUrl, hydrated, configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference) |
| 804 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) | 808 | ) !== null && configuration.saveConfiguration(self._storage, hydrated) |
| 805 | && flight.generation === self._lifecycleGeneration) { | 809 | && flight.generation === self._lifecycleGeneration) { |
| 806 | self._configuration = hydrated; | 810 | self._configuration = hydrated; |
| @@ -1028,9 +1032,10 @@ Companion.prototype._startOverviewFlight = function (request) { | |||
| 1028 | if (result !== null) { | 1032 | if (result !== null) { |
| 1029 | groups[serviceId] = result.group; | 1033 | groups[serviceId] = result.group; |
| 1030 | var hydrated = { | 1034 | var hydrated = { |
| 1031 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 1035 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 1032 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), | 1036 | favorites: self._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1033 | - primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus | 1037 | + primApiKey: self._configuration.primApiKey, keyStatus: self._configuration.keyStatus, |
| 1038 | + languagePreference: self._configuration.languagePreference | ||
| 1034 | }; | 1039 | }; |
| 1035 | var changed = false; | 1040 | var changed = false; |
| 1036 | hydrated.favorites.forEach(function (favorite) { | 1041 | hydrated.favorites.forEach(function (favorite) { |
| @@ -1043,7 +1048,8 @@ Companion.prototype._startOverviewFlight = function (request) { | |||
| 1043 | changed = true; | 1048 | changed = true; |
| 1044 | }); | 1049 | }); |
| 1045 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) | 1050 | if (changed && configuration.areFavoritesSecretFree(hydrated.favorites, hydrated.primApiKey, null) |
| 1046 | - && configuration.configurationUrl(self._configurationUrl, hydrated, configuration.activeWatchLanguage(self._Pebble)) !== null | 1051 | + && configuration.configurationUrl(self._configurationUrl, hydrated, |
| 1052 | + configuration.effectiveWatchLanguage(self._Pebble, hydrated.languagePreference)) !== null | ||
| 1047 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { | 1053 | && configuration.saveConfiguration(self._storage, hydrated) && current()) { |
| 1048 | self._configuration = hydrated; | 1054 | self._configuration = hydrated; |
| 1049 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); | 1055 | self._sync.enrichPhoneFavorites(hydrated.favorites, flight.generation); |
| @@ -1148,10 +1154,11 @@ Companion.prototype._invalidateCredential = function () { | |||
| 1148 | if (this._configuration.primApiKey === null | 1154 | if (this._configuration.primApiKey === null |
| 1149 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; | 1155 | || this._configuration.keyStatus === contracts.KEY_STATUS.INVALID) return; |
| 1150 | invalid = { | 1156 | invalid = { |
| 1151 | - schemaVersion: contracts.CONFIGURATION_VERSION, | 1157 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, |
| 1152 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), | 1158 | favorites: this._configuration.favorites.map(contracts.copyPhoneFavorite), |
| 1153 | primApiKey: this._configuration.primApiKey, | 1159 | primApiKey: this._configuration.primApiKey, |
| 1154 | - keyStatus: contracts.KEY_STATUS.INVALID | 1160 | + keyStatus: contracts.KEY_STATUS.INVALID, |
| 1161 | + languagePreference: this._configuration.languagePreference | ||
| 1155 | }; | 1162 | }; |
| 1156 | configuration.saveInvalidConfiguration(this._storage, invalid); | 1163 | configuration.saveInvalidConfiguration(this._storage, invalid); |
| 1157 | this._invalidateLifecycle(); | 1164 | this._invalidateLifecycle(); |
modified
packages/companion/test/companion.test.js +218 -15 | @@ -30,11 +30,12 @@ function withoutRouting(favorite) { | ||
| 30 | 30 | return copy; |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | -function configuredStorage(favorites, status) { | |
| 33 | +function configuredStorage(favorites, status, languagePreference) { | |
| 34 | 34 | var storage = new fakes.FakeStorage(); |
| 35 | 35 | assert.equal(configuration.saveConfiguration(storage, { |
| 36 | - schemaVersion: 2, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | |
| 37 | - keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED | |
| 36 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | |
| 37 | + keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED, | |
| 38 | + languagePreference: typeof languagePreference === "undefined" ? "auto" : languagePreference | |
| 38 | 39 | }), true); |
| 39 | 40 | storage.writes.length = 0; |
| 40 | 41 | return storage; |
| @@ -208,13 +209,19 @@ function resolveProduction(target, favorites) { | ||
| 208 | 209 | } else respond(target, xhr, 200, trafficBody(favorites)); |
| 209 | 210 | }); |
| 210 | 211 | } |
| 211 | -function closeWith(target, action, favorites, value, forceFull) { | |
| 212 | +function closeWith(target, action, favorites, value, forceFull, languagePreference) { | |
| 212 | 213 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; |
| 213 | 214 | if (action === "REPLACE") update.apiKeyUpdate.value = value; |
| 214 | 215 | if (forceFull) update.forceFullSync = true; |
| 216 | + if (typeof languagePreference !== "undefined") update.languagePreference = languagePreference; | |
| 215 | 217 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 216 | 218 | } |
| 217 | 219 | |
| 220 | +function openConfiguration(target) { | |
| 221 | + target.Pebble.emit("showConfiguration"); | |
| 222 | + return JSON.parse(decodeURIComponent(target.Pebble.openedUrls.at(-1).split("#")[1])); | |
| 223 | +} | |
| 224 | + | |
| 218 | 225 | test("Core Android decoded close responses preserve favorites with line colors", function () { |
| 219 | 226 | var target = harness({ storage: configuredStorage([]) }); |
| 220 | 227 | var update = { |
| @@ -229,6 +236,108 @@ test("Core Android decoded close responses preserve favorites with line colors", | ||
| 229 | 236 | target.companion.stop(); |
| 230 | 237 | }); |
| 231 | 238 | |
| 239 | +test("an explicit choice matching the system persists without a sync and returning to Automatic follows fresh locales", function () { | |
| 240 | + var storage = configuredStorage(), target = harness({ storage: storage }), systemLanguage = "fr_FR"; | |
| 241 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | |
| 242 | + ready(target); | |
| 243 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 244 | + assert.equal(openConfiguration(target).languagePreference, "auto"); | |
| 245 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 246 | + closeWith(target, "KEEP", [FIRST], undefined, false, "fr"); | |
| 247 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 248 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 249 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 250 | + assert.equal(target.xhr.instances.length, 0); | |
| 251 | + target.companion.stop(); | |
| 252 | + | |
| 253 | + systemLanguage = "en_US"; | |
| 254 | + target = harness({ storage: storage }); | |
| 255 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | |
| 256 | + ready(target); | |
| 257 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 258 | + var opening = openConfiguration(target); | |
| 259 | + assert.equal(opening.language, "fr"); | |
| 260 | + assert.equal(opening.languagePreference, "fr"); | |
| 261 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 262 | + assert.equal(target.Pebble.openedUrls.at(-1).includes(TEST_KEY), false); | |
| 263 | + before = messages(target, T.CONFIG_BEGIN).length; | |
| 264 | + closeWith(target, "KEEP", [FIRST]); | |
| 265 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 266 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 267 | + | |
| 268 | + closeWith(target, "KEEP", [FIRST], undefined, false, "auto"); | |
| 269 | + acknowledgeConfiguration(target); | |
| 270 | + assert.equal(target.binding.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | |
| 271 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 272 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "auto"); | |
| 273 | + systemLanguage = "FR-ca"; | |
| 274 | + opening = openConfiguration(target); | |
| 275 | + assert.equal(opening.language, "fr"); | |
| 276 | + assert.equal(opening.languagePreference, "auto"); | |
| 277 | + closeWith(target, "KEEP", [FIRST]); | |
| 278 | + acknowledgeConfiguration(target); | |
| 279 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 280 | + assert.equal(target.xhr.instances.length, 0); | |
| 281 | + target.companion.stop(); | |
| 282 | +}); | |
| 283 | + | |
| 284 | +test("language-only saves send rehashed appearance records through DIFF without changing favorites or fetching", function () { | |
| 285 | + var favorite = Object.assign({}, FIRST, { displayName: "Mon trajet", destinationLabel: "Gare d’arrivée" }); | |
| 286 | + var target = harness({ storage: configuredStorage([favorite]) }); | |
| 287 | + target.Pebble.getActiveWatchInfo = function () { return { language: "en_US" }; }; | |
| 288 | + ready(target); | |
| 289 | + var hash = messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH; | |
| 290 | + var appearance = messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD; | |
| 291 | + closeWith(target, "KEEP", [favorite], undefined, false, "fr"); | |
| 292 | + var begin = messages(target, T.CONFIG_BEGIN).at(-1); | |
| 293 | + assert.equal(begin.LANGUAGE, "fr"); | |
| 294 | + assert.equal(begin.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | |
| 295 | + assert.notEqual(messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH, hash); | |
| 296 | + acknowledgeConfiguration(target); | |
| 297 | + assert.notEqual(messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD, appearance); | |
| 298 | + assert.equal(messages(target, T.CONFIG_COMMIT).at(-1).REQUEST_ID, begin.REQUEST_ID); | |
| 299 | + var saved = configuration.loadConfiguration(target.storage); | |
| 300 | + assert.deepEqual(saved.favorites, [favorite]); | |
| 301 | + assert.equal(saved.primApiKey, TEST_KEY); | |
| 302 | + assert.equal(saved.languagePreference, "fr"); | |
| 303 | + assert.equal(openConfiguration(target).language, "fr"); | |
| 304 | + assert.equal(target.xhr.instances.length, 0); | |
| 305 | + target.companion.stop(); | |
| 306 | +}); | |
| 307 | + | |
| 308 | +test("cancelled, invalid and unpersisted language saves leave the prior configuration active", function () { | |
| 309 | + var target = harness({ storage: configuredStorage([FIRST], undefined, "fr") }); | |
| 310 | + ready(target); | |
| 311 | + var original = target.storage.getItem(configuration.CONFIG_STORAGE_KEY); | |
| 312 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 313 | + ["CANCELLED", "", "pebblejs://close#CANCELLED", "%invalid"].forEach(function (response) { | |
| 314 | + target.Pebble.emit("webviewclosed", { response: response }); | |
| 315 | + }); | |
| 316 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify({ | |
| 317 | + schemaVersion: 2, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, languagePreference: null | |
| 318 | + }) }); | |
| 319 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 320 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 321 | + | |
| 322 | + var write = target.storage.setItem, fail = true; | |
| 323 | + target.storage.setItem = function (key, value) { | |
| 324 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { | |
| 325 | + fail = false; | |
| 326 | + write.call(this, key, "partial write"); | |
| 327 | + } else write.call(this, key, value); | |
| 328 | + }; | |
| 329 | + closeWith(target, "KEEP", [FIRST], undefined, false, "en"); | |
| 330 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 331 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 332 | + var opening = openConfiguration(target); | |
| 333 | + assert.equal(opening.languagePreference, "fr"); | |
| 334 | + assert.equal(opening.language, "fr"); | |
| 335 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 336 | + assert.equal(configuration.loadConfiguration(target.storage).primApiKey, TEST_KEY); | |
| 337 | + assert.equal(target.xhr.instances.length, 0); | |
| 338 | + target.companion.stop(); | |
| 339 | +}); | |
| 340 | + | |
| 232 | 341 | function serviceRow(favorite) { |
| 233 | 342 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 234 | 343 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -438,7 +547,8 @@ test("the failure table deterministically projects unconfigured, stale, or unava | ||
| 438 | 547 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { |
| 439 | 548 | storage = new fakes.FakeStorage(); |
| 440 | 549 | assert.equal(configuration.saveConfiguration(storage, { |
| 441 | - schemaVersion: 2, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | |
| 550 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: [FIRST], primApiKey: null, | |
| 551 | + keyStatus: contracts.KEY_STATUS.MISSING, languagePreference: "auto" | |
| 442 | 552 | }), true); |
| 443 | 553 | } else storage = configuredStorage([FIRST], failure.keyStatus); |
| 444 | 554 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); |
| @@ -571,7 +681,7 @@ test("superseded settled selections get terminal data without launching obsolete | ||
| 571 | 681 | target.companion.stop(); |
| 572 | 682 | }); |
| 573 | 683 | |
| 574 | -test("traffic completion remains token-bound and its line cache is language-specific", function () { | |
| 684 | +test("saved preferences and Automatic use separate traffic caches without refetching a fresh language", function () { | |
| 575 | 685 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); |
| 576 | 686 | seedOverview(storage, favorites, clock); |
| 577 | 687 | var language = "en", target = harness({ storage: storage, clock: clock }); |
| @@ -583,12 +693,64 @@ test("traffic completion remains token-bound and its line cache is language-spec | ||
| 583 | 693 | sendTraffic(target, "same-line", favorites[1]); |
| 584 | 694 | assert.equal(requests(target, "traffic").length, 1); |
| 585 | 695 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); |
| 586 | - language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | |
| 696 | + closeWith(target, "KEEP", favorites, undefined, false, "fr"); acknowledgeConfiguration(target); | |
| 697 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 698 | + assert.equal(requests(target, "traffic").length, 1); | |
| 587 | 699 | sendTraffic(target, "translated", FIRST); |
| 588 | 700 | assert.equal(requests(target, "traffic").length, 2); |
| 589 | 701 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); |
| 590 | 702 | resolveProduction(target, favorites); |
| 591 | 703 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); |
| 704 | + language = "fr"; closeWith(target, "KEEP", favorites, undefined, false, "auto"); | |
| 705 | + language = "en"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | |
| 706 | + sendTraffic(target, "back-to-english", FIRST); | |
| 707 | + assert.equal(requests(target, "traffic").length, 2); | |
| 708 | + assert.deepEqual(finalTransfer(target, "back-to-english").records, finalTransfer(target, "old-traffic").records); | |
| 709 | + target.companion.stop(); | |
| 710 | +}); | |
| 711 | + | |
| 712 | +test("an English override keeps in-flight French fallback and both language caches bound through restart", function () { | |
| 713 | + var favorite = phoneFavorite(1, 200), storage = configuredStorage([favorite]); | |
| 714 | + var clock = new fakes.FakeClock(Date.parse("2026-01-15T09:00:00Z")); | |
| 715 | + var target = harness({ storage: storage, clock: clock }); | |
| 716 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | |
| 717 | + ready(target); | |
| 718 | + sendTraffic(target, "french", favorite); | |
| 719 | + assert.equal(requests(target, "traffic")[0].headers["Accept-Language"], undefined); | |
| 720 | + closeWith(target, "KEEP", [favorite], undefined, false, "en"); | |
| 721 | + assert.equal(requests(target, "traffic").length, 1); | |
| 722 | + acknowledgeConfiguration(target); | |
| 723 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 724 | + sendTraffic(target, "english", favorite); | |
| 725 | + assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "en"); | |
| 726 | + var french = require("../../../fixtures/traffic/global.json"); | |
| 727 | + var english = JSON.parse(JSON.stringify(french)); | |
| 728 | + english.disruptions[0].title = "English title"; | |
| 729 | + delete english.disruptions[0].message; | |
| 730 | + respond(target, requests(target, "traffic")[1], 200, english); | |
| 731 | + assert.equal(requests(target, "traffic").length, 2); | |
| 732 | + assert.equal(transfers(target, "english").length, 0); | |
| 733 | + respond(target, requests(target, "traffic")[0], 200, french); | |
| 734 | + var translated = finalTransfer(target, "english"); | |
| 735 | + assert.notEqual(finalTransfer(target, "french").generation, translated.generation); | |
| 736 | + assert.equal(translated.generation, target.binding.DISPLAY_GENERATION); | |
| 737 | + var cache = configuration.loadCache(storage, [favorite]); | |
| 738 | + var en = configuration.findTrafficDetail(cache, favorite.serviceId, "en", "cached-en", favorite.id); | |
| 739 | + var fr = configuration.findTrafficDetail(cache, favorite.serviceId, "fr", "cached-fr", favorite.id); | |
| 740 | + assert.equal(en.result.title, "Métro 2 : ralentissements"); | |
| 741 | + assert.equal(en.result.text, "Le trafic est ralenti & les temps d’attente sont allongés."); | |
| 742 | + assert.equal(fr.result.title, en.result.title); | |
| 743 | + assert.equal(fr.result.text, en.result.text); | |
| 744 | + target.companion.stop(); | |
| 745 | + | |
| 746 | + target = harness({ storage: storage, clock: clock }); | |
| 747 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | |
| 748 | + ready(target); | |
| 749 | + assert.equal(target.binding.LANGUAGE, "en"); | |
| 750 | + sendTraffic(target, "restored-english", favorite); | |
| 751 | + assert.deepEqual(finalTransfer(target, "restored-english").records, translated.records); | |
| 752 | + assert.equal(target.xhr.instances.length, 0); | |
| 753 | + assert.equal(openConfiguration(target).language, "en"); | |
| 592 | 754 | target.companion.stop(); |
| 593 | 755 | }); |
| 594 | 756 | |
| @@ -629,7 +791,7 @@ test("explicit FULL is one-shot, sends complete inventory, and is not persisted" | ||
| 629 | 791 | |
| 630 | 792 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { |
| 631 | 793 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); |
| 632 | - var storage = configuredStorage([unresolved, duplicate]), target = harness({ storage: storage }); | |
| 794 | + var storage = configuredStorage([unresolved, duplicate], undefined, "fr"), target = harness({ storage: storage }); | |
| 633 | 795 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; |
| 634 | 796 | sendOverview(target, "hydrate"); |
| 635 | 797 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); |
| @@ -637,6 +799,7 @@ test("unresolved routing uses a pinned public revision once per service without | ||
| 637 | 799 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); |
| 638 | 800 | assert.equal(requests(target, "departures").length, 1); |
| 639 | 801 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); |
| 802 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 640 | 803 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); |
| 641 | 804 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); |
| 642 | 805 | resolveProduction(target, [FIRST]); |
| @@ -753,11 +916,12 @@ test("catalog revision mismatch cannot hydrate favorites or send credentials to | ||
| 753 | 916 | }); |
| 754 | 917 | |
| 755 | 918 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { |
| 756 | - var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites) }); | |
| 919 | + var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites, undefined, "fr") }); | |
| 757 | 920 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); |
| 758 | 921 | respond(target, requests(target, "departures")[0], 401, ""); |
| 759 | 922 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 760 | 923 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); |
| 924 | + assert.equal(configuration.loadConfiguration(target.storage).languagePreference, "fr"); | |
| 761 | 925 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); |
| 762 | 926 | acknowledgeConfiguration(target, 0); |
| 763 | 927 | assert.equal(rows(target, "old-screen")[0].error, 3); |
| @@ -766,6 +930,7 @@ test("authentication rejection invalidates credentials durably, cancels network, | ||
| 766 | 930 | sendOverview(target, "blocked"); target.clock.advance(3600000); |
| 767 | 931 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); |
| 768 | 932 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); |
| 933 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 769 | 934 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); |
| 770 | 935 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); |
| 771 | 936 | }); |
| @@ -780,6 +945,42 @@ test("the invalid-key journal blocks restart even when the primary configuration | ||
| 780 | 945 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); |
| 781 | 946 | }); |
| 782 | 947 | |
| 948 | +test("marker-only invalidation cannot apply an unpersisted replacement's favorites or language", function () { | |
| 949 | + var storage = configuredStorage([FIRST], undefined, "fr"), write = storage.setItem; | |
| 950 | + var original = storage.getItem(configuration.CONFIG_STORAGE_KEY), target = harness({ storage: storage }); | |
| 951 | + ready(target); | |
| 952 | + storage.setItem = function (key, value) { | |
| 953 | + if (key === configuration.CONFIG_STORAGE_KEY) throw new Error("primary storage unavailable"); | |
| 954 | + write.call(this, key, value); | |
| 955 | + }; | |
| 956 | + sendTraffic(target, "invalid-before-save"); | |
| 957 | + respond(target, requests(target, "traffic")[0], 403, ""); | |
| 958 | + acknowledgeConfiguration(target, 0); | |
| 959 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 960 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | |
| 961 | + var marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY); | |
| 962 | + var before = messages(target, T.CONFIG_BEGIN).length; | |
| 963 | + closeWith(target, "REPLACE", [Object.assign({}, SECOND, { sortOrder: 0 })], TEST_KEY, false, "en"); | |
| 964 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | |
| 965 | + assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), marker); | |
| 966 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | |
| 967 | + var opening = openConfiguration(target); | |
| 968 | + assert.equal(opening.languagePreference, "fr"); | |
| 969 | + assert.equal(opening.language, "fr"); | |
| 970 | + assert.deepEqual(opening.favorites, [FIRST]); | |
| 971 | + target.companion.stop(); | |
| 972 | + | |
| 973 | + storage.setItem = write; | |
| 974 | + target = harness({ storage: storage }); ready(target, 0); | |
| 975 | + assert.equal(target.binding.LANGUAGE, "fr"); | |
| 976 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | |
| 977 | + assert.deepEqual(openConfiguration(target).favorites, [FIRST]); | |
| 978 | + sendTraffic(target, "still-blocked"); | |
| 979 | + assert.deepEqual(finalTransfer(target, "still-blocked").records, ["e03"]); | |
| 980 | + assert.equal(target.xhr.instances.length, 0); | |
| 981 | + target.companion.stop(); | |
| 982 | +}); | |
| 983 | + | |
| 783 | 984 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { |
| 784 | 985 | var target = harness(); ready(target); sendTraffic(target, "old-key"); |
| 785 | 986 | var xhr = requests(target, "traffic")[0], late = xhr.onload; |
| @@ -810,18 +1011,19 @@ test("credential removal erases the key, retains favorites, and blocks PRIM unti | ||
| 810 | 1011 | |
| 811 | 1012 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 812 | 1013 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 813 | - var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | |
| 1014 | + var storage = configuredStorage([FIRST], undefined, "fr"), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | |
| 1015 | + storage.removeItem = function (key) { | |
| 1016 | + if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | |
| 1017 | + remove.call(this, key); | |
| 1018 | + }; | |
| 814 | 1019 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); |
| 815 | 1020 | storage.setItem = function (key, value) { |
| 816 | 1021 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); |
| 817 | 1022 | write.call(this, key, value); |
| 818 | 1023 | }; |
| 819 | - storage.removeItem = function (key) { | |
| 820 | - if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | |
| 821 | - remove.call(this, key); | |
| 822 | - }; | |
| 823 | 1024 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); |
| 824 | 1025 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 1026 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | |
| 825 | 1027 | }); |
| 826 | 1028 | }); |
| 827 | 1029 | |
| @@ -986,7 +1188,7 @@ function finishCaptured(target, call, favorite, seconds) { | ||
| 986 | 1188 | |
| 987 | 1189 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { |
| 988 | 1190 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); |
| 989 | - var target = harness({ storage: configuredStorage([favorite]), realCatalog: true }); | |
| 1191 | + var target = harness({ storage: configuredStorage([favorite], undefined, "fr"), realCatalog: true }); | |
| 990 | 1192 | ready(target); |
| 991 | 1193 | var calls = captureDepartures(target); |
| 992 | 1194 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -1001,6 +1203,7 @@ test("journey flights migrate a single null arrival through real annexes before | ||
| 1001 | 1203 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; |
| 1002 | 1204 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); |
| 1003 | 1205 | assert.equal(saved.destinationLabel, "Historic label"); |
| 1206 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | |
| 1004 | 1207 | finishCaptured(target, calls[0], resolved, 180); |
| 1005 | 1208 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); |
| 1006 | 1209 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); |
| @@ -30,11 +30,12 @@ function withoutRouting(favorite) { | |||
| 30 | return copy; | 30 | return copy; |
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | -function configuredStorage(favorites, status) { | 33 | +function configuredStorage(favorites, status, languagePreference) { |
| 34 | var storage = new fakes.FakeStorage(); | 34 | var storage = new fakes.FakeStorage(); |
| 35 | assert.equal(configuration.saveConfiguration(storage, { | 35 | assert.equal(configuration.saveConfiguration(storage, { |
| 36 | - schemaVersion: 2, favorites: favorites || [FIRST], primApiKey: TEST_KEY, | 36 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: favorites || [FIRST], primApiKey: TEST_KEY, |
| 37 | - keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED | 37 | + keyStatus: typeof status === "number" ? status : contracts.KEY_STATUS.CONFIGURED, |
| 38 | + languagePreference: typeof languagePreference === "undefined" ? "auto" : languagePreference | ||
| 38 | }), true); | 39 | }), true); |
| 39 | storage.writes.length = 0; | 40 | storage.writes.length = 0; |
| 40 | return storage; | 41 | return storage; |
| @@ -208,13 +209,19 @@ function resolveProduction(target, favorites) { | |||
| 208 | } else respond(target, xhr, 200, trafficBody(favorites)); | 209 | } else respond(target, xhr, 200, trafficBody(favorites)); |
| 209 | }); | 210 | }); |
| 210 | } | 211 | } |
| 211 | -function closeWith(target, action, favorites, value, forceFull) { | 212 | +function closeWith(target, action, favorites, value, forceFull, languagePreference) { |
| 212 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; | 213 | var update = { schemaVersion: 2, favorites: favorites, apiKeyUpdate: { schemaVersion: 1, action: action } }; |
| 213 | if (action === "REPLACE") update.apiKeyUpdate.value = value; | 214 | if (action === "REPLACE") update.apiKeyUpdate.value = value; |
| 214 | if (forceFull) update.forceFullSync = true; | 215 | if (forceFull) update.forceFullSync = true; |
| 216 | + if (typeof languagePreference !== "undefined") update.languagePreference = languagePreference; | ||
| 215 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); | 217 | target.Pebble.emit("webviewclosed", { response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(update)) }); |
| 216 | } | 218 | } |
| 217 | 219 | ||
| 220 | +function openConfiguration(target) { | ||
| 221 | + target.Pebble.emit("showConfiguration"); | ||
| 222 | + return JSON.parse(decodeURIComponent(target.Pebble.openedUrls.at(-1).split("#")[1])); | ||
| 223 | +} | ||
| 224 | + | ||
| 218 | test("Core Android decoded close responses preserve favorites with line colors", function () { | 225 | test("Core Android decoded close responses preserve favorites with line colors", function () { |
| 219 | var target = harness({ storage: configuredStorage([]) }); | 226 | var target = harness({ storage: configuredStorage([]) }); |
| 220 | var update = { | 227 | var update = { |
| @@ -229,6 +236,108 @@ test("Core Android decoded close responses preserve favorites with line colors", | |||
| 229 | target.companion.stop(); | 236 | target.companion.stop(); |
| 230 | }); | 237 | }); |
| 231 | 238 | ||
| 239 | +test("an explicit choice matching the system persists without a sync and returning to Automatic follows fresh locales", function () { | ||
| 240 | + var storage = configuredStorage(), target = harness({ storage: storage }), systemLanguage = "fr_FR"; | ||
| 241 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | ||
| 242 | + ready(target); | ||
| 243 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 244 | + assert.equal(openConfiguration(target).languagePreference, "auto"); | ||
| 245 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 246 | + closeWith(target, "KEEP", [FIRST], undefined, false, "fr"); | ||
| 247 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 248 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 249 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 250 | + assert.equal(target.xhr.instances.length, 0); | ||
| 251 | + target.companion.stop(); | ||
| 252 | + | ||
| 253 | + systemLanguage = "en_US"; | ||
| 254 | + target = harness({ storage: storage }); | ||
| 255 | + target.Pebble.getActiveWatchInfo = function () { return { language: systemLanguage }; }; | ||
| 256 | + ready(target); | ||
| 257 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 258 | + var opening = openConfiguration(target); | ||
| 259 | + assert.equal(opening.language, "fr"); | ||
| 260 | + assert.equal(opening.languagePreference, "fr"); | ||
| 261 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 262 | + assert.equal(target.Pebble.openedUrls.at(-1).includes(TEST_KEY), false); | ||
| 263 | + before = messages(target, T.CONFIG_BEGIN).length; | ||
| 264 | + closeWith(target, "KEEP", [FIRST]); | ||
| 265 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 266 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 267 | + | ||
| 268 | + closeWith(target, "KEEP", [FIRST], undefined, false, "auto"); | ||
| 269 | + acknowledgeConfiguration(target); | ||
| 270 | + assert.equal(target.binding.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | ||
| 271 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 272 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "auto"); | ||
| 273 | + systemLanguage = "FR-ca"; | ||
| 274 | + opening = openConfiguration(target); | ||
| 275 | + assert.equal(opening.language, "fr"); | ||
| 276 | + assert.equal(opening.languagePreference, "auto"); | ||
| 277 | + closeWith(target, "KEEP", [FIRST]); | ||
| 278 | + acknowledgeConfiguration(target); | ||
| 279 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 280 | + assert.equal(target.xhr.instances.length, 0); | ||
| 281 | + target.companion.stop(); | ||
| 282 | +}); | ||
| 283 | + | ||
| 284 | +test("language-only saves send rehashed appearance records through DIFF without changing favorites or fetching", function () { | ||
| 285 | + var favorite = Object.assign({}, FIRST, { displayName: "Mon trajet", destinationLabel: "Gare d’arrivée" }); | ||
| 286 | + var target = harness({ storage: configuredStorage([favorite]) }); | ||
| 287 | + target.Pebble.getActiveWatchInfo = function () { return { language: "en_US" }; }; | ||
| 288 | + ready(target); | ||
| 289 | + var hash = messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH; | ||
| 290 | + var appearance = messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD; | ||
| 291 | + closeWith(target, "KEEP", [favorite], undefined, false, "fr"); | ||
| 292 | + var begin = messages(target, T.CONFIG_BEGIN).at(-1); | ||
| 293 | + assert.equal(begin.LANGUAGE, "fr"); | ||
| 294 | + assert.equal(begin.CONFIG_MODE, contracts.CONFIG_MODE.DIFF); | ||
| 295 | + assert.notEqual(messages(target, T.CONFIG_ENTRY).at(-1).DISPLAY_HASH, hash); | ||
| 296 | + acknowledgeConfiguration(target); | ||
| 297 | + assert.notEqual(messages(target, T.FAVORITE).at(-1).DISPLAY_RECORD, appearance); | ||
| 298 | + assert.equal(messages(target, T.CONFIG_COMMIT).at(-1).REQUEST_ID, begin.REQUEST_ID); | ||
| 299 | + var saved = configuration.loadConfiguration(target.storage); | ||
| 300 | + assert.deepEqual(saved.favorites, [favorite]); | ||
| 301 | + assert.equal(saved.primApiKey, TEST_KEY); | ||
| 302 | + assert.equal(saved.languagePreference, "fr"); | ||
| 303 | + assert.equal(openConfiguration(target).language, "fr"); | ||
| 304 | + assert.equal(target.xhr.instances.length, 0); | ||
| 305 | + target.companion.stop(); | ||
| 306 | +}); | ||
| 307 | + | ||
| 308 | +test("cancelled, invalid and unpersisted language saves leave the prior configuration active", function () { | ||
| 309 | + var target = harness({ storage: configuredStorage([FIRST], undefined, "fr") }); | ||
| 310 | + ready(target); | ||
| 311 | + var original = target.storage.getItem(configuration.CONFIG_STORAGE_KEY); | ||
| 312 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 313 | + ["CANCELLED", "", "pebblejs://close#CANCELLED", "%invalid"].forEach(function (response) { | ||
| 314 | + target.Pebble.emit("webviewclosed", { response: response }); | ||
| 315 | + }); | ||
| 316 | + target.Pebble.emit("webviewclosed", { response: JSON.stringify({ | ||
| 317 | + schemaVersion: 2, favorites: [], apiKeyUpdate: { schemaVersion: 1, action: "REMOVE" }, languagePreference: null | ||
| 318 | + }) }); | ||
| 319 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 320 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 321 | + | ||
| 322 | + var write = target.storage.setItem, fail = true; | ||
| 323 | + target.storage.setItem = function (key, value) { | ||
| 324 | + if (key === configuration.CONFIG_STORAGE_KEY && fail) { | ||
| 325 | + fail = false; | ||
| 326 | + write.call(this, key, "partial write"); | ||
| 327 | + } else write.call(this, key, value); | ||
| 328 | + }; | ||
| 329 | + closeWith(target, "KEEP", [FIRST], undefined, false, "en"); | ||
| 330 | + assert.equal(target.storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 331 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 332 | + var opening = openConfiguration(target); | ||
| 333 | + assert.equal(opening.languagePreference, "fr"); | ||
| 334 | + assert.equal(opening.language, "fr"); | ||
| 335 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 336 | + assert.equal(configuration.loadConfiguration(target.storage).primApiKey, TEST_KEY); | ||
| 337 | + assert.equal(target.xhr.instances.length, 0); | ||
| 338 | + target.companion.stop(); | ||
| 339 | +}); | ||
| 340 | + | ||
| 232 | function serviceRow(favorite) { | 341 | function serviceRow(favorite) { |
| 233 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, | 342 | return { serviceId: favorite.serviceId, stopLabel: favorite.stopLabel, lineLabel: favorite.lineLabel, |
| 234 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, | 343 | destinationLabel: favorite.destinationLabel, lineMode: favorite.lineMode, |
| @@ -438,7 +547,8 @@ test("the failure table deterministically projects unconfigured, stale, or unava | |||
| 438 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { | 547 | if (failure.keyStatus === contracts.KEY_STATUS.MISSING) { |
| 439 | storage = new fakes.FakeStorage(); | 548 | storage = new fakes.FakeStorage(); |
| 440 | assert.equal(configuration.saveConfiguration(storage, { | 549 | assert.equal(configuration.saveConfiguration(storage, { |
| 441 | - schemaVersion: 2, favorites: [FIRST], primApiKey: null, keyStatus: contracts.KEY_STATUS.MISSING | 550 | + schemaVersion: configuration.CONFIG_SCHEMA_VERSION, favorites: [FIRST], primApiKey: null, |
| 551 | + keyStatus: contracts.KEY_STATUS.MISSING, languagePreference: "auto" | ||
| 442 | }), true); | 552 | }), true); |
| 443 | } else storage = configuredStorage([FIRST], failure.keyStatus); | 553 | } else storage = configuredStorage([FIRST], failure.keyStatus); |
| 444 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); | 554 | if (!failure.noCache) seedOverview(storage, [FIRST], clock); |
| @@ -571,7 +681,7 @@ test("superseded settled selections get terminal data without launching obsolete | |||
| 571 | target.companion.stop(); | 681 | target.companion.stop(); |
| 572 | }); | 682 | }); |
| 573 | 683 | ||
| 574 | -test("traffic completion remains token-bound and its line cache is language-specific", function () { | 684 | +test("saved preferences and Automatic use separate traffic caches without refetching a fresh language", function () { |
| 575 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); | 685 | var favorites = [FIRST, phoneFavorite(2, 1)], storage = configuredStorage(favorites), clock = new fakes.FakeClock(); |
| 576 | seedOverview(storage, favorites, clock); | 686 | seedOverview(storage, favorites, clock); |
| 577 | var language = "en", target = harness({ storage: storage, clock: clock }); | 687 | var language = "en", target = harness({ storage: storage, clock: clock }); |
| @@ -583,12 +693,64 @@ test("traffic completion remains token-bound and its line cache is language-spec | |||
| 583 | sendTraffic(target, "same-line", favorites[1]); | 693 | sendTraffic(target, "same-line", favorites[1]); |
| 584 | assert.equal(requests(target, "traffic").length, 1); | 694 | assert.equal(requests(target, "traffic").length, 1); |
| 585 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); | 695 | assert.equal(finalTransfer(target, "same-line").favoriteId, favorites[1].id); |
| 586 | - language = "fr"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | 696 | + closeWith(target, "KEEP", favorites, undefined, false, "fr"); acknowledgeConfiguration(target); |
| 697 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 698 | + assert.equal(requests(target, "traffic").length, 1); | ||
| 587 | sendTraffic(target, "translated", FIRST); | 699 | sendTraffic(target, "translated", FIRST); |
| 588 | assert.equal(requests(target, "traffic").length, 2); | 700 | assert.equal(requests(target, "traffic").length, 2); |
| 589 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); | 701 | assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], undefined); |
| 590 | resolveProduction(target, favorites); | 702 | resolveProduction(target, favorites); |
| 591 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); | 703 | assert.notDeepEqual(finalTransfer(target, "translated").records, finalTransfer(target, "old-traffic").records); |
| 704 | + language = "fr"; closeWith(target, "KEEP", favorites, undefined, false, "auto"); | ||
| 705 | + language = "en"; closeWith(target, "KEEP", favorites); acknowledgeConfiguration(target); | ||
| 706 | + sendTraffic(target, "back-to-english", FIRST); | ||
| 707 | + assert.equal(requests(target, "traffic").length, 2); | ||
| 708 | + assert.deepEqual(finalTransfer(target, "back-to-english").records, finalTransfer(target, "old-traffic").records); | ||
| 709 | + target.companion.stop(); | ||
| 710 | +}); | ||
| 711 | + | ||
| 712 | +test("an English override keeps in-flight French fallback and both language caches bound through restart", function () { | ||
| 713 | + var favorite = phoneFavorite(1, 200), storage = configuredStorage([favorite]); | ||
| 714 | + var clock = new fakes.FakeClock(Date.parse("2026-01-15T09:00:00Z")); | ||
| 715 | + var target = harness({ storage: storage, clock: clock }); | ||
| 716 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | ||
| 717 | + ready(target); | ||
| 718 | + sendTraffic(target, "french", favorite); | ||
| 719 | + assert.equal(requests(target, "traffic")[0].headers["Accept-Language"], undefined); | ||
| 720 | + closeWith(target, "KEEP", [favorite], undefined, false, "en"); | ||
| 721 | + assert.equal(requests(target, "traffic").length, 1); | ||
| 722 | + acknowledgeConfiguration(target); | ||
| 723 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 724 | + sendTraffic(target, "english", favorite); | ||
| 725 | + assert.equal(requests(target, "traffic")[1].headers["Accept-Language"], "en"); | ||
| 726 | + var french = require("../../../fixtures/traffic/global.json"); | ||
| 727 | + var english = JSON.parse(JSON.stringify(french)); | ||
| 728 | + english.disruptions[0].title = "English title"; | ||
| 729 | + delete english.disruptions[0].message; | ||
| 730 | + respond(target, requests(target, "traffic")[1], 200, english); | ||
| 731 | + assert.equal(requests(target, "traffic").length, 2); | ||
| 732 | + assert.equal(transfers(target, "english").length, 0); | ||
| 733 | + respond(target, requests(target, "traffic")[0], 200, french); | ||
| 734 | + var translated = finalTransfer(target, "english"); | ||
| 735 | + assert.notEqual(finalTransfer(target, "french").generation, translated.generation); | ||
| 736 | + assert.equal(translated.generation, target.binding.DISPLAY_GENERATION); | ||
| 737 | + var cache = configuration.loadCache(storage, [favorite]); | ||
| 738 | + var en = configuration.findTrafficDetail(cache, favorite.serviceId, "en", "cached-en", favorite.id); | ||
| 739 | + var fr = configuration.findTrafficDetail(cache, favorite.serviceId, "fr", "cached-fr", favorite.id); | ||
| 740 | + assert.equal(en.result.title, "Métro 2 : ralentissements"); | ||
| 741 | + assert.equal(en.result.text, "Le trafic est ralenti & les temps d’attente sont allongés."); | ||
| 742 | + assert.equal(fr.result.title, en.result.title); | ||
| 743 | + assert.equal(fr.result.text, en.result.text); | ||
| 744 | + target.companion.stop(); | ||
| 745 | + | ||
| 746 | + target = harness({ storage: storage, clock: clock }); | ||
| 747 | + target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; }; | ||
| 748 | + ready(target); | ||
| 749 | + assert.equal(target.binding.LANGUAGE, "en"); | ||
| 750 | + sendTraffic(target, "restored-english", favorite); | ||
| 751 | + assert.deepEqual(finalTransfer(target, "restored-english").records, translated.records); | ||
| 752 | + assert.equal(target.xhr.instances.length, 0); | ||
| 753 | + assert.equal(openConfiguration(target).language, "en"); | ||
| 592 | target.companion.stop(); | 754 | target.companion.stop(); |
| 593 | }); | 755 | }); |
| 594 | 756 | ||
| @@ -629,7 +791,7 @@ test("explicit FULL is one-shot, sends complete inventory, and is not persisted" | |||
| 629 | 791 | ||
| 630 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { | 792 | test("unresolved routing uses a pinned public revision once per service without metadata resynchronization", function () { |
| 631 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); | 793 | var unresolved = withoutRouting(FIRST), duplicate = Object.assign({}, unresolved, { id: "unresolved-copy", sortOrder: 1 }); |
| 632 | - var storage = configuredStorage([unresolved, duplicate]), target = harness({ storage: storage }); | 794 | + var storage = configuredStorage([unresolved, duplicate], undefined, "fr"), target = harness({ storage: storage }); |
| 633 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; | 795 | ready(target); var before = messages(target, T.CONFIG_BEGIN).length; |
| 634 | sendOverview(target, "hydrate"); | 796 | sendOverview(target, "hydrate"); |
| 635 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); | 797 | assert.equal(requests(target, "static").length, 1); assert.equal(requests(target, "departures").length, 0); |
| @@ -637,6 +799,7 @@ test("unresolved routing uses a pinned public revision once per service without | |||
| 637 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); | 799 | requests(target, "static").forEach(function (xhr) { assert.equal(xhr.headers.apikey, undefined); assert.equal(xhr.headers.Authorization, undefined); }); |
| 638 | assert.equal(requests(target, "departures").length, 1); | 800 | assert.equal(requests(target, "departures").length, 1); |
| 639 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); | 801 | assert.deepEqual(configuration.loadConfiguration(storage).favorites[1].routing, FIRST.routing); |
| 802 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 640 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); | 803 | assert.equal(storage.writes.filter(function (write) { return write.key === configuration.CONFIG_STORAGE_KEY; }).length, 1); |
| 641 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | 804 | assert.equal(messages(target, T.CONFIG_BEGIN).length, before); |
| 642 | resolveProduction(target, [FIRST]); | 805 | resolveProduction(target, [FIRST]); |
| @@ -753,11 +916,12 @@ test("catalog revision mismatch cannot hydrate favorites or send credentials to | |||
| 753 | }); | 916 | }); |
| 754 | 917 | ||
| 755 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { | 918 | test("authentication rejection invalidates credentials durably, cancels network, and completes accepted tokens", function () { |
| 756 | - var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites) }); | 919 | + var favorites = [FIRST, SECOND], target = harness({ storage: configuredStorage(favorites, undefined, "fr") }); |
| 757 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); | 920 | ready(target); sendOverview(target, "old-screen"); sendTraffic(target, "current-screen", SECOND); |
| 758 | respond(target, requests(target, "departures")[0], 401, ""); | 921 | respond(target, requests(target, "departures")[0], 401, ""); |
| 759 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); | 922 | assert.equal(configuration.loadConfiguration(target.storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 760 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); | 923 | assert.deepEqual(configuration.loadConfiguration(target.storage).favorites[0].routing, FIRST.routing); |
| 924 | + assert.equal(configuration.loadConfiguration(target.storage).languagePreference, "fr"); | ||
| 761 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); | 925 | assert.equal(requests(target, "departures")[1].aborted, true); assert.equal(requests(target, "traffic")[0].aborted, true); |
| 762 | acknowledgeConfiguration(target, 0); | 926 | acknowledgeConfiguration(target, 0); |
| 763 | assert.equal(rows(target, "old-screen")[0].error, 3); | 927 | assert.equal(rows(target, "old-screen")[0].error, 3); |
| @@ -766,6 +930,7 @@ test("authentication rejection invalidates credentials durably, cancels network, | |||
| 766 | sendOverview(target, "blocked"); target.clock.advance(3600000); | 930 | sendOverview(target, "blocked"); target.clock.advance(3600000); |
| 767 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); | 931 | assert.equal(target.xhr.instances.length, before); assert.equal(rows(target, "blocked")[0].error, 3); |
| 768 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); | 932 | target.companion.stop(); target = harness({ storage: target.storage }); ready(target, 0); |
| 933 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 769 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); | 934 | sendOverview(target, "blocked-after-restart"); assert.equal(target.xhr.instances.length, 0); |
| 770 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); | 935 | assert.equal(rows(target, "blocked-after-restart")[0].error, 3); target.companion.stop(); |
| 771 | }); | 936 | }); |
| @@ -780,6 +945,42 @@ test("the invalid-key journal blocks restart even when the primary configuration | |||
| 780 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); | 945 | assert.deepEqual(finalTransfer(target, "restart").records, ["e03"]); target.companion.stop(); |
| 781 | }); | 946 | }); |
| 782 | 947 | ||
| 948 | +test("marker-only invalidation cannot apply an unpersisted replacement's favorites or language", function () { | ||
| 949 | + var storage = configuredStorage([FIRST], undefined, "fr"), write = storage.setItem; | ||
| 950 | + var original = storage.getItem(configuration.CONFIG_STORAGE_KEY), target = harness({ storage: storage }); | ||
| 951 | + ready(target); | ||
| 952 | + storage.setItem = function (key, value) { | ||
| 953 | + if (key === configuration.CONFIG_STORAGE_KEY) throw new Error("primary storage unavailable"); | ||
| 954 | + write.call(this, key, value); | ||
| 955 | + }; | ||
| 956 | + sendTraffic(target, "invalid-before-save"); | ||
| 957 | + respond(target, requests(target, "traffic")[0], 403, ""); | ||
| 958 | + acknowledgeConfiguration(target, 0); | ||
| 959 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 960 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | ||
| 961 | + var marker = storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY); | ||
| 962 | + var before = messages(target, T.CONFIG_BEGIN).length; | ||
| 963 | + closeWith(target, "REPLACE", [Object.assign({}, SECOND, { sortOrder: 0 })], TEST_KEY, false, "en"); | ||
| 964 | + assert.equal(storage.getItem(configuration.CONFIG_STORAGE_KEY), original); | ||
| 965 | + assert.equal(storage.getItem(configuration.INVALID_KEY_STATUS_STORAGE_KEY), marker); | ||
| 966 | + assert.equal(messages(target, T.CONFIG_BEGIN).length, before); | ||
| 967 | + var opening = openConfiguration(target); | ||
| 968 | + assert.equal(opening.languagePreference, "fr"); | ||
| 969 | + assert.equal(opening.language, "fr"); | ||
| 970 | + assert.deepEqual(opening.favorites, [FIRST]); | ||
| 971 | + target.companion.stop(); | ||
| 972 | + | ||
| 973 | + storage.setItem = write; | ||
| 974 | + target = harness({ storage: storage }); ready(target, 0); | ||
| 975 | + assert.equal(target.binding.LANGUAGE, "fr"); | ||
| 976 | + assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | ||
| 977 | + assert.deepEqual(openConfiguration(target).favorites, [FIRST]); | ||
| 978 | + sendTraffic(target, "still-blocked"); | ||
| 979 | + assert.deepEqual(finalTransfer(target, "still-blocked").records, ["e03"]); | ||
| 980 | + assert.equal(target.xhr.instances.length, 0); | ||
| 981 | + target.companion.stop(); | ||
| 982 | +}); | ||
| 983 | + | ||
| 783 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { | 984 | test("credential replacement ignores saved callbacks from the canceled lifecycle", function () { |
| 784 | var target = harness(); ready(target); sendTraffic(target, "old-key"); | 985 | var target = harness(); ready(target); sendTraffic(target, "old-key"); |
| 785 | var xhr = requests(target, "traffic")[0], late = xhr.onload; | 986 | var xhr = requests(target, "traffic")[0], late = xhr.onload; |
| @@ -810,18 +1011,19 @@ test("credential removal erases the key, retains favorites, and blocks PRIM unti | |||
| 810 | 1011 | ||
| 811 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { | 1012 | test("interrupted same-key replacement never clears durable invalid-key authority", function () { |
| 812 | ["marker-removal", "configured-write"].forEach(function (failure) { | 1013 | ["marker-removal", "configured-write"].forEach(function (failure) { |
| 813 | - var storage = configuredStorage(), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; | 1014 | + var storage = configuredStorage([FIRST], undefined, "fr"), target = harness({ storage: storage }), write = storage.setItem, remove = storage.removeItem; |
| 1015 | + storage.removeItem = function (key) { | ||
| 1016 | + if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | ||
| 1017 | + remove.call(this, key); | ||
| 1018 | + }; | ||
| 814 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); | 1019 | ready(target); sendTraffic(target, "invalid-before-replacement"); respond(target, requests(target, "traffic")[0], 401, ""); |
| 815 | storage.setItem = function (key, value) { | 1020 | storage.setItem = function (key, value) { |
| 816 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); | 1021 | if (failure === "configured-write" && key === configuration.CONFIG_STORAGE_KEY && JSON.parse(value).keyStatus === contracts.KEY_STATUS.CONFIGURED) throw new Error("interrupted"); |
| 817 | write.call(this, key, value); | 1022 | write.call(this, key, value); |
| 818 | }; | 1023 | }; |
| 819 | - storage.removeItem = function (key) { | ||
| 820 | - if (failure === "marker-removal" && key === configuration.INVALID_KEY_STATUS_STORAGE_KEY) throw new Error("retained marker"); | ||
| 821 | - remove.call(this, key); | ||
| 822 | - }; | ||
| 823 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); | 1024 | closeWith(target, "REPLACE", [FIRST], TEST_KEY); target.companion.stop(); |
| 824 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); | 1025 | assert.equal(configuration.loadConfiguration(storage).keyStatus, contracts.KEY_STATUS.INVALID); |
| 1026 | + assert.equal(configuration.loadConfiguration(storage).languagePreference, "fr"); | ||
| 825 | }); | 1027 | }); |
| 826 | }); | 1028 | }); |
| 827 | 1029 | ||
| @@ -986,7 +1188,7 @@ function finishCaptured(target, call, favorite, seconds) { | |||
| 986 | 1188 | ||
| 987 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { | 1189 | test("journey flights migrate a single null arrival through real annexes before PRIM and preserve labels", function () { |
| 988 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); | 1190 | var resolved = journeyFavorite(), favorite = Object.assign({}, resolved, { arrivalPlaceId: null, destinationLabel: "Historic label" }); |
| 989 | - var target = harness({ storage: configuredStorage([favorite]), realCatalog: true }); | 1191 | + var target = harness({ storage: configuredStorage([favorite], undefined, "fr"), realCatalog: true }); |
| 990 | ready(target); | 1192 | ready(target); |
| 991 | var calls = captureDepartures(target); | 1193 | var calls = captureDepartures(target); |
| 992 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); | 1194 | sendOverview(target, "cache-only-null", contracts.REQUEST_TRIGGER.CACHE_ONLY); |
| @@ -1001,6 +1203,7 @@ test("journey flights migrate a single null arrival through real annexes before | |||
| 1001 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; | 1203 | var saved = configuration.loadConfiguration(target.storage).favorites[0]; |
| 1002 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); | 1204 | assert.equal(saved.arrivalPlaceId, resolved.arrivalPlaceId); |
| 1003 | assert.equal(saved.destinationLabel, "Historic label"); | 1205 | assert.equal(saved.destinationLabel, "Historic label"); |
| 1206 | + assert.equal(openConfiguration(target).languagePreference, "fr"); | ||
| 1004 | finishCaptured(target, calls[0], resolved, 180); | 1207 | finishCaptured(target, calls[0], resolved, 180); |
| 1005 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); | 1208 | respond(target, requests(target, "traffic")[0], 200, trafficBody([resolved])); |
| 1006 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); | 1209 | assert.equal(rows(target, "migrated")[0].departures[0].expectedAt, Math.floor(target.clock.now() / 1000) + 180); |
modified
packages/companion/test/configuration-journey.test.js +88 -30 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/index.html +22 -0 | @@ -124,6 +124,28 @@ | ||
| 124 | 124 | </div> |
| 125 | 125 | </details> |
| 126 | 126 | |
| 127 | + <details class="settings-section" id="language-section" aria-labelledby="language-title"> | |
| 128 | + <summary> | |
| 129 | + <span class="section-icon" aria-hidden="true"> | |
| 130 | + <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3c2.5 2.6 3.9 5.7 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.7-3.9-9S9.5 5.6 12 3Z"></path></svg> | |
| 131 | + </span> | |
| 132 | + <span class="summary-copy"> | |
| 133 | + <h2 id="language-title">Watch app language</h2> | |
| 134 | + </span> | |
| 135 | + <span class="disclosure" aria-hidden="true"></span> | |
| 136 | + </summary> | |
| 137 | + <div class="section-body"> | |
| 138 | + <label class="field-label" for="language-select" id="language-label"></label> | |
| 139 | + <select id="language-select"> | |
| 140 | + <option id="language-option-auto" value="auto">Automatic</option> | |
| 141 | + <option id="language-option-french" value="fr">Français</option> | |
| 142 | + <option id="language-option-english" value="en">English</option> | |
| 143 | + </select> | |
| 144 | + <p class="support" id="language-hint"></p> | |
| 145 | + <p class="support" id="language-unsupported" role="status" hidden></p> | |
| 146 | + </div> | |
| 147 | + </details> | |
| 148 | + | |
| 127 | 149 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> |
| 128 | 150 | <summary> |
| 129 | 151 | <span class="section-icon" aria-hidden="true"> |
| @@ -124,6 +124,28 @@ | |||
| 124 | </div> | 124 | </div> |
| 125 | </details> | 125 | </details> |
| 126 | 126 | ||
| 127 | + <details class="settings-section" id="language-section" aria-labelledby="language-title"> | ||
| 128 | + <summary> | ||
| 129 | + <span class="section-icon" aria-hidden="true"> | ||
| 130 | + <svg viewBox="0 0 24 24" fill="none" focusable="false"><circle cx="12" cy="12" r="9"></circle><path d="M3 12h18M12 3c2.5 2.6 3.9 5.7 3.9 9s-1.4 6.4-3.9 9c-2.5-2.6-3.9-5.7-3.9-9S9.5 5.6 12 3Z"></path></svg> | ||
| 131 | + </span> | ||
| 132 | + <span class="summary-copy"> | ||
| 133 | + <h2 id="language-title">Watch app language</h2> | ||
| 134 | + </span> | ||
| 135 | + <span class="disclosure" aria-hidden="true"></span> | ||
| 136 | + </summary> | ||
| 137 | + <div class="section-body"> | ||
| 138 | + <label class="field-label" for="language-select" id="language-label"></label> | ||
| 139 | + <select id="language-select"> | ||
| 140 | + <option id="language-option-auto" value="auto">Automatic</option> | ||
| 141 | + <option id="language-option-french" value="fr">Français</option> | ||
| 142 | + <option id="language-option-english" value="en">English</option> | ||
| 143 | + </select> | ||
| 144 | + <p class="support" id="language-hint"></p> | ||
| 145 | + <p class="support" id="language-unsupported" role="status" hidden></p> | ||
| 146 | + </div> | ||
| 147 | + </details> | ||
| 148 | + | ||
| 127 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> | 149 | <details class="settings-section" id="sync-section" aria-labelledby="sync-title"> |
| 128 | <summary> | 150 | <summary> |
| 129 | <span class="section-icon" aria-hidden="true"> | 151 | <span class="section-icon" aria-hidden="true"> |
modified
packages/config-page/src/config-core.js +77 -10 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/src/config-page.js +28 -1 | @@ -33,6 +33,8 @@ const elements = Object.fromEntries([ | ||
| 33 | 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", |
| 34 | 34 | "about-title", "about-en", "about-fr", |
| 35 | 35 | "add-caption", "sync-caption", |
| 36 | + "language-section", "language-title", "language-label", "language-select", "language-hint", | |
| 37 | + "language-unsupported", "language-option-auto", "language-option-french", "language-option-english", | |
| 36 | 38 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", |
| 37 | 39 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 38 | 40 | |
| @@ -119,6 +121,7 @@ function dispatch(action) { | ||
| 119 | 121 | state = reduceConfigState(state, action); |
| 120 | 122 | renderKey(); |
| 121 | 123 | renderFavorites(); |
| 124 | + renderLanguage(); | |
| 122 | 125 | } |
| 123 | 126 | |
| 124 | 127 | function applyCopy() { |
| @@ -142,6 +145,11 @@ function applyCopy() { | ||
| 142 | 145 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], |
| 143 | 146 | ["syncCaption", elements.sync_caption], |
| 144 | 147 | ["forceSyncLabel", elements.force_sync_label], |
| 148 | + ["languageTitle", elements.language_title], ["languageLabel", elements.language_label], | |
| 149 | + ["languageHint", elements.language_hint], ["languageUnsupported", elements.language_unsupported], | |
| 150 | + ["languageOptionAuto", elements.language_option_auto], | |
| 151 | + ["languageOptionFrench", elements.language_option_french], | |
| 152 | + ["languageOptionEnglish", elements.language_option_english], | |
| 145 | 153 | ]) setText(element, copy[key]); |
| 146 | 154 | elements.key_input.placeholder = copy.keyPlaceholder; |
| 147 | 155 | elements.place_search.placeholder = copy.searchPlaceholder; |
| @@ -164,6 +172,19 @@ function renderKey() { | ||
| 164 | 172 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); |
| 165 | 173 | } |
| 166 | 174 | |
| 175 | +// The selector restores the launch-carried preference and is offered only when | |
| 176 | +// the launch advertised support and the session is editable: an older phone | |
| 177 | +// keeps its stored preference because the close payload then omits the field, | |
| 178 | +// and a read-only session can never save. The compatibility note explains the | |
| 179 | +// disabled selector only where editing is otherwise possible. | |
| 180 | +function renderLanguage() { | |
| 181 | + elements.language_select.value = state.languagePreference; | |
| 182 | + const offered = state.editable === true && state.languagePreferenceSupported === true; | |
| 183 | + elements.language_select.disabled = !offered; | |
| 184 | + elements.language_unsupported.hidden = state.editable !== true | |
| 185 | + || state.languagePreferenceSupported === true; | |
| 186 | +} | |
| 187 | + | |
| 167 | 188 | function actionButton(label, disabled, action, className = "quiet") { |
| 168 | 189 | const button = document.createElement("button"); |
| 169 | 190 | button.type = "button"; |
| @@ -674,6 +695,10 @@ elements.force_sync.addEventListener("change", () => { | ||
| 674 | 695 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); |
| 675 | 696 | }); |
| 676 | 697 | |
| 698 | +elements.language_select.addEventListener("change", () => { | |
| 699 | + dispatch({ type: "language-preference", value: elements.language_select.value }); | |
| 700 | +}); | |
| 701 | + | |
| 677 | 702 | elements.save.addEventListener("click", () => { |
| 678 | 703 | if (!state.editable) return; |
| 679 | 704 | const keyError = apiKeyError(state.keyDraft.value); |
| @@ -709,7 +734,7 @@ elements.save.addEventListener("click", () => { | ||
| 709 | 734 | }); |
| 710 | 735 | |
| 711 | 736 | // Section open/closed state is the only local persistence on this page: |
| 712 | -// four booleans under one namespaced key, keyed by the stable section IDs. | |
| 737 | +// five booleans under one namespaced key, keyed by the stable section IDs. | |
| 713 | 738 | // The key draft, favorites, and catalog data never reach storage, and |
| 714 | 739 | // unavailable or corrupt storage (private mode, blocked cookies, cleared |
| 715 | 740 | // origins) leaves the markup defaults in place and the page functional. |
| @@ -718,6 +743,7 @@ const sectionElements = [ | ||
| 718 | 743 | elements.prim_section, |
| 719 | 744 | elements.favorites_section, |
| 720 | 745 | elements.add_section, |
| 746 | + elements.language_section, | |
| 721 | 747 | elements.sync_section, |
| 722 | 748 | ]; |
| 723 | 749 | |
| @@ -766,6 +792,7 @@ window.addEventListener("pagehide", () => { | ||
| 766 | 792 | |
| 767 | 793 | applyCopy(); |
| 768 | 794 | renderKey(); |
| 795 | +renderLanguage(); | |
| 769 | 796 | renderFavorites(); |
| 770 | 797 | elements.configuration_upgrade.hidden = state.editable; |
| 771 | 798 | for (const element of [ |
| @@ -33,6 +33,8 @@ const elements = Object.fromEntries([ | |||
| 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", | 33 | "force-sync-label", "force-sync", "config-footer", "about-open", "about-view", "about-back", |
| 34 | "about-title", "about-en", "about-fr", | 34 | "about-title", "about-en", "about-fr", |
| 35 | "add-caption", "sync-caption", | 35 | "add-caption", "sync-caption", |
| 36 | + "language-section", "language-title", "language-label", "language-select", "language-hint", | ||
| 37 | + "language-unsupported", "language-option-auto", "language-option-french", "language-option-english", | ||
| 36 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", | 38 | "arrival-step", "arrivals-label", "arrival-select", "journey-hint", "configuration-upgrade", |
| 37 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); | 39 | ].map((id) => [id.replaceAll("-", "_"), byId(id)])); |
| 38 | 40 | ||
| @@ -119,6 +121,7 @@ function dispatch(action) { | |||
| 119 | state = reduceConfigState(state, action); | 121 | state = reduceConfigState(state, action); |
| 120 | renderKey(); | 122 | renderKey(); |
| 121 | renderFavorites(); | 123 | renderFavorites(); |
| 124 | + renderLanguage(); | ||
| 122 | } | 125 | } |
| 123 | 126 | ||
| 124 | function applyCopy() { | 127 | function applyCopy() { |
| @@ -142,6 +145,11 @@ function applyCopy() { | |||
| 142 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], | 145 | ["syncTitle", elements.sync_title], ["syncHint", elements.sync_hint], |
| 143 | ["syncCaption", elements.sync_caption], | 146 | ["syncCaption", elements.sync_caption], |
| 144 | ["forceSyncLabel", elements.force_sync_label], | 147 | ["forceSyncLabel", elements.force_sync_label], |
| 148 | + ["languageTitle", elements.language_title], ["languageLabel", elements.language_label], | ||
| 149 | + ["languageHint", elements.language_hint], ["languageUnsupported", elements.language_unsupported], | ||
| 150 | + ["languageOptionAuto", elements.language_option_auto], | ||
| 151 | + ["languageOptionFrench", elements.language_option_french], | ||
| 152 | + ["languageOptionEnglish", elements.language_option_english], | ||
| 145 | ]) setText(element, copy[key]); | 153 | ]) setText(element, copy[key]); |
| 146 | elements.key_input.placeholder = copy.keyPlaceholder; | 154 | elements.key_input.placeholder = copy.keyPlaceholder; |
| 147 | elements.place_search.placeholder = copy.searchPlaceholder; | 155 | elements.place_search.placeholder = copy.searchPlaceholder; |
| @@ -164,6 +172,19 @@ function renderKey() { | |||
| 164 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); | 172 | setText(elements.key_toggle, keyRevealed ? copy.keyHide : copy.keyShow); |
| 165 | } | 173 | } |
| 166 | 174 | ||
| 175 | +// The selector restores the launch-carried preference and is offered only when | ||
| 176 | +// the launch advertised support and the session is editable: an older phone | ||
| 177 | +// keeps its stored preference because the close payload then omits the field, | ||
| 178 | +// and a read-only session can never save. The compatibility note explains the | ||
| 179 | +// disabled selector only where editing is otherwise possible. | ||
| 180 | +function renderLanguage() { | ||
| 181 | + elements.language_select.value = state.languagePreference; | ||
| 182 | + const offered = state.editable === true && state.languagePreferenceSupported === true; | ||
| 183 | + elements.language_select.disabled = !offered; | ||
| 184 | + elements.language_unsupported.hidden = state.editable !== true | ||
| 185 | + || state.languagePreferenceSupported === true; | ||
| 186 | +} | ||
| 187 | + | ||
| 167 | function actionButton(label, disabled, action, className = "quiet") { | 188 | function actionButton(label, disabled, action, className = "quiet") { |
| 168 | const button = document.createElement("button"); | 189 | const button = document.createElement("button"); |
| 169 | button.type = "button"; | 190 | button.type = "button"; |
| @@ -674,6 +695,10 @@ elements.force_sync.addEventListener("change", () => { | |||
| 674 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); | 695 | dispatch({ type: "force-full-sync", value: elements.force_sync.checked }); |
| 675 | }); | 696 | }); |
| 676 | 697 | ||
| 698 | +elements.language_select.addEventListener("change", () => { | ||
| 699 | + dispatch({ type: "language-preference", value: elements.language_select.value }); | ||
| 700 | +}); | ||
| 701 | + | ||
| 677 | elements.save.addEventListener("click", () => { | 702 | elements.save.addEventListener("click", () => { |
| 678 | if (!state.editable) return; | 703 | if (!state.editable) return; |
| 679 | const keyError = apiKeyError(state.keyDraft.value); | 704 | const keyError = apiKeyError(state.keyDraft.value); |
| @@ -709,7 +734,7 @@ elements.save.addEventListener("click", () => { | |||
| 709 | }); | 734 | }); |
| 710 | 735 | ||
| 711 | // Section open/closed state is the only local persistence on this page: | 736 | // Section open/closed state is the only local persistence on this page: |
| 712 | -// four booleans under one namespaced key, keyed by the stable section IDs. | 737 | +// five booleans under one namespaced key, keyed by the stable section IDs. |
| 713 | // The key draft, favorites, and catalog data never reach storage, and | 738 | // The key draft, favorites, and catalog data never reach storage, and |
| 714 | // unavailable or corrupt storage (private mode, blocked cookies, cleared | 739 | // unavailable or corrupt storage (private mode, blocked cookies, cleared |
| 715 | // origins) leaves the markup defaults in place and the page functional. | 740 | // origins) leaves the markup defaults in place and the page functional. |
| @@ -718,6 +743,7 @@ const sectionElements = [ | |||
| 718 | elements.prim_section, | 743 | elements.prim_section, |
| 719 | elements.favorites_section, | 744 | elements.favorites_section, |
| 720 | elements.add_section, | 745 | elements.add_section, |
| 746 | + elements.language_section, | ||
| 721 | elements.sync_section, | 747 | elements.sync_section, |
| 722 | ]; | 748 | ]; |
| 723 | 749 | ||
| @@ -766,6 +792,7 @@ window.addEventListener("pagehide", () => { | |||
| 766 | 792 | ||
| 767 | applyCopy(); | 793 | applyCopy(); |
| 768 | renderKey(); | 794 | renderKey(); |
| 795 | +renderLanguage(); | ||
| 769 | renderFavorites(); | 796 | renderFavorites(); |
| 770 | elements.configuration_upgrade.hidden = state.editable; | 797 | elements.configuration_upgrade.hidden = state.editable; |
| 771 | for (const element of [ | 798 | for (const element of [ |
modified
packages/config-page/src/generated/journey-patterns.js +2 -2 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/styles/config-page.css +1 -0 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/config-page/test/config-page.test.ts +173 -16 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/contracts/src/index.ts +2 -2 Large diff collapsed to keep the page fast — load it when you need it.
modified
packages/watch/test/fixture-boot.test.js +2 -1 Large diff collapsed to keep the page fast — load it when you need it.