App Configuration Fields
Revision History
Revision |
Date of Changes |
Matter of Change |
Note |
Editor |
|---|---|---|---|---|
1.00 |
18.08.2026 |
Creating: developer-declared configuration fields, the on-watch values file, the app-side reader, and the companion-app specification |
Ross Ryles |
|
1.01 |
19.08.2026 |
The declaration moved with its file: package metadata is now |
Ross Ryles |
1. Overview
An app can declare a list of configuration fields in its app-manifest.json. The companion
app asks the user to fill them in when the app is installed, writes the answers to a JSON
file next to the .uapp on the watch, and lets the user edit them again at any time. The
watch app reads that file when it starts, through SDK::AppConfig.
This gives an app the settings that are painful to enter with four buttons — names, coordinates, calibration constants, thresholds — without the app having to build an on-watch editor for them.
The feature needs no kernel firmware change. It is built entirely from mechanisms that already exist:
Mechanism |
Already provides |
|---|---|
BLE File Transfer Service |
Writing and reading arbitrary files under |
|
Sandbox-rooted file access; |
|
coreJSON-backed parsing and serialisation |
1.1 What this is not
Not a replacement for on-watch settings. Config fields are set rarely, from the phone. Anything the user changes in the moment — an alert distance mid-run, a timer duration — belongs in the app’s own on-watch settings. No value should live in both places.
Not a place to re-ask for data the kernel already owns. SDK::Interface::ISettings
already gives every app the user’s unit system (isUnitsImperial()), their heart-rate zone
thresholds (getHrThresholds()) and their daily goals (getDailyGoals()), and the kernel
sends profile data such as height to the apps that need it. Declaring a config field for any
of these duplicates a value the user has already entered once, and the two copies will
eventually disagree.
Not secure storage. The values file is plaintext on a FAT volume that is readable over
USB mass storage and over BLE. Do not use config fields for passwords, API tokens or
anything else whose disclosure matters. That is why there is no secret field type.
Not a runtime channel. A change made on the phone is picked up the next time the app starts (section 7.2). It is not a live data feed.
1.2 Data flow
app-manifest.json Companion app (phone)
+---------------------+ +----------------------------+
| "configFile" | 1. read the | |
| "configFields": [ |---- package --->| 2. prompt the user |
| {...}, {...} | | 3. validate the input |
| ] | | |
+---------------------+ +-------------+--------------+
(stays on the phone; |
never copied to the watch) 4. FTS WRITE offset 0
|
v
2:/Apps/<AppDir>/<configFile>
{ "schema": 1, "values": { ... } }
|
5. read once at launch
v
+-------------------------+
| SDK::AppConfig |
| (linked into the .uapp) |
+-----------+-------------+
|
6. optional write-back (section 6)
|
+--> same file
app-manifest.json itself is never uploaded to the watch — that rule is unchanged (see
App Manifest JSON). The values file is a separate artifact the companion
app generates.
2. Declaring fields in app-manifest.json
Two keys carry the declaration, and both are optional — an app that wants no configuration
declares neither. They live in the app’s app-manifest.json, the package metadata file
the companion app reads (formerly config.json; see
App Manifest JSON).
Every manifest also declares the format version of the manifest itself:
"manifest_version": 1
It is required, and must be 1. A reader that finds any other value must refuse the
manifest rather than guess at a format it was not built for — the same rule the values file’s
schema key follows (section 4.2). It versions the manifest format, not the app: appVersion
is the app’s own version and is unrelated.
2.1 configFile
The name of the values file the companion app creates in the app’s directory on the watch.
"configFile": "app_config.json"
Rule |
Detail |
|---|---|
Required when |
|
Forbidden when |
|
Pattern |
|
No path separators |
|
Must not be |
|
Length |
<= 63 characters, which the pattern above already enforces. |
Restricting this to a bare filename is deliberate. The app’s sandbox root is the one
directory both the companion app and SDK::AppConfig can always resolve without extra
rules, and it stops a malformed or hostile package from reaching outside the app’s own
directory.
2.2 configFields
An ordered array of field definitions. Array order is display order in the companion app.
"configFields": [
{ "id": "waypointName", "type": "string", ... },
{ "id": "targetLatitude", "type": "float", ... }
]
At most 32 fields (section 8).
2.3 Attributes common to every field
Attribute |
Required |
Type |
Rules |
|---|---|---|---|
|
yes |
string |
|
|
yes |
string |
One of |
|
yes |
string |
Short human-readable name, <= 32 characters. Shown next to the input. Do not put units in the label — use |
|
yes |
string |
Longer human-readable explanation, <= 200 characters. Shown under or beside the input. Write it for someone who has never used the app. |
|
yes |
matches |
The value used when the user has not set one. The companion app pre-fills the input with it, and the app falls back to it when the key is absent. It must itself satisfy every constraint declared on the field. |
|
no (default |
bool |
|
|
no |
string |
<= 120 characters. Shown by the companion app when the entered value fails any of this field’s constraints. Write it as the rule rather than as a complaint: |
default is mandatory on purpose: it gives the companion app something to pre-fill, gives
the app a defined fallback, and — because the app’s field table repeats it (section 5.1) —
gives CI something to cross-check.
One further attribute, unit, applies only to the numeric types and is described with them:
a unit on a name or a switch has nothing to label.
2.4 Attributes by type
bool
No extra attributes. default is true or false.
{
"id": "vibrateOnArrival",
"type": "bool",
"label": "Vibrate on arrival",
"description": "Buzz the watch once when you come within the arrival radius of the target.",
"default": true
}
int
Attribute |
Required |
Rules |
|---|---|---|
|
yes |
Integer. Inclusive lower bound. |
|
yes |
Integer. Inclusive upper bound, |
|
no |
Presentation-only suffix rendered next to the input, <= 8 characters ( |
Both bounds are mandatory. A range is what makes the companion app’s validation and the
app’s clamping (section 5.2) well-defined, and an unbounded integer field is usually one the
developer has not finished thinking about. Values are 32-bit signed:
-2147483648 .. 2147483647.
{
"id": "arrivalRadiusM",
"type": "int",
"label": "Arrival radius",
"description": "How close you need to get before the watch counts you as arrived.",
"default": 25,
"min": 5,
"max": 500,
"unit": "m"
}
float
Attribute |
Required |
Rules |
|---|---|---|
|
yes |
Number. Inclusive lower bound. |
|
yes |
Number. Inclusive upper bound, |
|
no |
Presentation-only suffix rendered next to the input, <= 8 characters ( |
Values are single-precision (IEEE-754 binary32) on the watch, matching the rest of the
platform: the GPS sensor layer reports latitude, longitude and altitude as float, and
SDK::TrackMap::GpsPoint stores them as float. A config value therefore has exactly the
same resolution as the sensor data it is compared against. Near +/-180 degrees that is
roughly 1-2 m of quantisation on a coordinate, which is well inside GPS error. Do not design
for more precision than that.
{
"id": "targetLongitude",
"type": "float",
"label": "Target longitude",
"description": "Longitude of the waypoint in decimal degrees. East is positive.",
"default": -0.1276,
"min": -180.0,
"max": 180.0,
"unit": "deg"
}
string
Attribute |
Required |
Rules |
|---|---|---|
|
yes |
1 .. 128. Length in UTF-8 bytes, not characters. |
|
no (default |
0 .. |
|
no |
Regular expression, <= 256 characters. See section 3.2. |
maxLength is mandatory because the app has to size a buffer for the value, and it is
counted in bytes because that is the unit both the buffer and the file-size limit are
measured in. A companion app must count bytes, not characters: a 16-byte field holds 16
ASCII characters but only 4 four-byte emoji.
{
"id": "waypointName",
"type": "string",
"label": "Waypoint name",
"description": "Shown on the watch while you navigate. Letters, digits and spaces only.",
"default": "Waypoint",
"minLength": 1,
"maxLength": 16,
"pattern": "[A-Za-z0-9 ]+",
"validationMessage": "Up to 16 letters, digits and spaces."
}
2.5 Complete example
{
"type": ["utility"],
"name": "Waypoint",
"icon": "Resources/icon_60x60.png",
"binary": "Waypoint_1.0.0.uapp",
"appVersion": "1.0.0",
"minKernelVersion": "1.4.0",
"requiredHardware": ["GPS"],
"description": "Navigate back to a point you saved earlier.",
"configFile": "app_config.json",
"configFields": [
{
"id": "waypointName",
"type": "string",
"label": "Waypoint name",
"description": "Shown on the watch while you navigate. Letters, digits and spaces only.",
"default": "Waypoint",
"minLength": 1,
"maxLength": 16,
"pattern": "[A-Za-z0-9 ]+",
"validationMessage": "Up to 16 letters, digits and spaces."
},
{
"id": "targetLatitude",
"type": "float",
"label": "Target latitude",
"description": "Latitude of the waypoint in decimal degrees. North is positive.",
"default": 51.5072,
"min": -90.0,
"max": 90.0,
"unit": "deg",
"required": true
},
{
"id": "targetLongitude",
"type": "float",
"label": "Target longitude",
"description": "Longitude of the waypoint in decimal degrees. East is positive.",
"default": -0.1276,
"min": -180.0,
"max": 180.0,
"unit": "deg",
"required": true
},
{
"id": "arrivalRadiusM",
"type": "int",
"label": "Arrival radius",
"description": "How close you need to get before the watch counts you as arrived.",
"default": 25,
"min": 5,
"max": 500,
"unit": "m"
},
{
"id": "vibrateOnArrival",
"type": "bool",
"label": "Vibrate on arrival",
"description": "Buzz the watch once when you come within the arrival radius.",
"default": true
}
]
}
3. Validation
Validation happens in the companion app, before anything is written. The watch app never
sees an invalid value that the companion app produced — but it still defends itself
(section 5.3), because a values file can also arrive from a hand edit over USB, from an
older or newer build of the companion app, or from a downgraded .uapp.
3.1 Order of checks
Checks run in this order, and the first failure is what gets reported:
Type — the entered value is of the declared type.
Required — a
requiredfield is not empty.Length —
minLength/maxLength, in UTF-8 bytes.Range —
min/max, inclusive.Pattern — the regular expression, as a full match.
A fixed order means the same bad input always produces the same message, on both platforms.
3.2 The pattern dialect
A pattern must behave identically on iOS and Android, so the dialect is a deliberately small subset with no constructs whose semantics or performance differ between engines.
Matching is an implicit full match. The pattern must match the entire value. Do not
write ^ and $ — they are unnecessary. Implementations MUST anchor the match themselves:
Platform |
Recipe |
|---|---|
JavaScript |
|
Swift / |
|
Java / Kotlin |
|
The (?:...) wrapper in the JavaScript recipe is not optional: without it, a top-level
alternation such as abc|def would bind as ^abc or def$ and let unintended values
through.
Allowed: literal characters; .; character classes [...] including ranges and
negation; the letter escapes \d \D \w \W \s \S \b \B \n \r \t and backslash-escaped
punctuation (\. \[ \] \( \) \{ \} \| \+ \* \? \- \/ \\); groups (...) and (?:...);
alternation |; the quantifiers * + ? {n} {n,} {n,m} and their lazy forms *? +? ??.
The escape list is a whitelist: any other letter after a backslash is refused. \A and
\Z are the reason — they are anchors in Python, Java and ICU but literal letters in
JavaScript, so a pattern using them matches different strings on iOS and Android.
Forbidden: backreferences (\1, \k<name>); lookahead and lookbehind ((?=, (?!,
(?<=, (?<!); named capture groups; inline flags such as (?i); atomic groups and
possessive quantifiers; Unicode property escapes (\p{...}); and the anchors ^ and $,
which are redundant because matching is already a full match. (A ^ immediately inside a
character class is negation, not an anchor, and remains legal: [^0-9].)
Two more constructs are refused for the same cross-engine reason:
]immediately after[.[]]is a class containing]in Python and Java, but an empty class followed by a literal]in JavaScript. Write[\]].A brace that is not a complete quantifier.
a{,3}is a quantifier in Python, literal text in JavaScript and a syntax error in Java. Writea{0,3}, ora\{,3\}for literal braces.
Every construct that survives means the same thing in all three engines, and the subset is expressible in a linear-time engine, so an implementation may use one.
Nested unbounded quantifiers are also rejected. A quantified group whose body is itself
unbounded — (a+)+, (a*)*, (\d+)*, and the same shapes wrapped in extra parentheses like
((a+))+ — backtracks exponentially in the backtracking engines iOS and Android use, with no
backreference or lookaround involved. A 128-byte input is more than enough to freeze a phone
on one of those, so validate_app_config.py refuses the shape and the developer rewrites it
((a+)+ is just a+).
That check is a safety net, not a proof: other shapes backtrack badly too — (a|a)+ and
(a?)* among them — and detecting them all is not something a linter can promise. Keep
patterns simple, and prefer minLength/maxLength over clever expressions.
Patterns are case-sensitive and are applied to the value exactly as entered. The companion app must not trim whitespace — if leading or trailing spaces should be rejected, the pattern must say so.
3.3 Error messages
A field’s validationMessage is shown for any failure on that field. One message per
field is enough in practice, because a good message states the whole rule
("Up to 16 letters, digits and spaces.") rather than one constraint at a time.
When validationMessage is absent, the companion app supplies its own message for the
constraint that failed (section 9.5), so a field is never reported with a bare “invalid”.
4. The values file
4.1 Location and name
2:/Apps/<AppDir>/<configFile>
<AppDir> is the app’s directory on the watch — the one the companion app created when it
transferred the .uapp. Over BLE FTS the same path is /Apps/<AppDir>/<configFile>,
relative to the file-transfer root. From inside the app, the sandbox makes it simply
/<configFile>.
4.2 Envelope
{
"schema": 1,
"values": {
"waypointName": "Trailhead",
"targetLatitude": 51.5072,
"targetLongitude": -0.1276,
"arrivalRadiusM": 25
}
}
Key |
Rules |
|---|---|
|
Integer, currently |
|
Object. Keys are field |
A reader that finds any schema other than 1 must ignore the whole file and use its
defaults, rather than guess at a format it was not built for. This matches how
SDK::Variant::Config treats an unknown config schema.
The file is strict JSON: UTF-8, no BOM, no comments, no trailing commas. (The examples in
App Manifest JSON carry // comments for the reader’s benefit; real
files must not.)
4.3 Value encoding
Declared type |
JSON encoding |
Reader behaviour |
|---|---|---|
|
JSON string |
Longer than |
|
|
A number or string here is invalid. |
|
JSON integer, no fraction and no exponent |
A fractional number is invalid, not rounded. |
|
JSON number |
An integer literal is accepted ( |
A reader must accept any JSON number, exponent notation included, up to 64 characters
of number text; anything longer is treated as not set. A writer must stay inside that
limit. The companion app should emit plain decimal, because its values come from a numeric
keypad and people read this file: no exponent, no thousands separators, . as the decimal
separator, and no fractional part for an int.
SDK::AppConfig is less tidy when the watch writes a value back: it formats with 9
significant digits, which yields exponent form for magnitudes below 1e-4 or at 1e9 and
above (0.00005 is written 4.99999987e-05). That is valid JSON and every reader must cope
with it, but it means a companion app cannot assume the file it reads back contains only the
plain decimals it wrote.
null, a wrong JSON type, or a malformed number means the key is treated as not set:
the app falls back to that field’s default. A well-formed number outside the field’s
min..max is clamped, not discarded (section 5.2) — a value the user plainly meant
should not silently disappear.
The two are distinguished by representability, not by range: a number a float field cannot
hold at all (1e40, which overflows binary32 to infinity) counts as malformed and falls back
to the default, whereas 120.0 in a -90..90 field is real and gets clamped to 90. The
same applies to an int field and a value outside 32-bit range.
Control characters must not appear in string values. No phone keyboard produces them in a
single-line field, and they cost six bytes each once escaped, which is why the 8 KB budget in
section 8 assumes their absence. SDK::AppConfig still escapes them correctly if a hand-edited
file contains some.
4.4 Which keys are present
The companion app writes the file whenever the app declares any config fields, so its presence is predictable. It contains only the ids the user actually set, so key presence is not predictable:
A
requiredfield is always present, because the user cannot finish the install without it.An optional field the user left alone is absent, and the app uses its default.
An optional field the user set and then cleared is removed from
values, which is how a value is reset to the app’s default.valuesmay legitimately be an empty object{}.
An app must therefore never treat a missing key as an error, and must never require the file to exist at all — a user can delete it over USB.
4.5 Unknown keys
A reader must ignore keys it does not recognise, and a writer must preserve them
(section 6.2). This is what makes a downgrade survivable: an older .uapp that knows fewer
fields does not destroy the values belonging to the newer one.
This applies to unrecognised keys inside values. SDK::AppConfig rewrites the envelope
itself, so anything a companion app adds alongside schema and values is dropped the first
time the watch saves. Keep companion metadata out of this file.
A repeated key is the one thing preservation does not carry through verbatim. JSON allows
{"a":1,"a":2} and leaves the winner undefined, and the two sides here would not pick the
same one: SDK::AppConfig reads the first occurrence, a phone-side JSON.parse keeps the
last. So a save writes only the first copy of a repeated key — declared or not — and the file
afterwards says what the watch acted on. Do not write duplicates in the first place; §9.6
already forbids it.
5. Reading configuration in the app
5.1 Declare the field table once
app-manifest.json never reaches the watch, so the app carries its own copy of the field
contract — one constexpr table, in one place:
#include "SDK/AppConfig/AppConfig.hpp"
namespace {
using SDK::AppConfig;
constexpr AppConfig::Field kFields[] = {
AppConfig::stringField("waypointName", "Waypoint", 1, 16),
AppConfig::floatField ("targetLatitude", 51.5072f, -90.0f, 90.0f),
AppConfig::floatField ("targetLongitude", -0.1276f, -180.0f, 180.0f),
AppConfig::intField ("arrivalRadiusM", 25, 5, 500),
AppConfig::boolField ("vibrateOnArrival", true),
};
} // namespace
Each entry repeats what app-manifest.json declares: the id, the default, and the bounds. That
duplication is intentional and is checked by CI — validate_app_config.py --check-bounds
compares this table against app-manifest.json and fails the build if they disagree
(section 10). Keeping the bounds in the binary is what lets the app clamp a value it should
never have received.
The table is a single greppable declaration rather than bounds scattered through call sites,
so the check does not depend on how the calls happen to be formatted. Two rules keep it
checkable: use plain literals (a named constant or an expression cannot be compared
against JSON), and keep preprocessor conditionals out of the table — the checker reads the
source as text, so an entry inside an #if counts as present whether or not it compiles.
5.2 Read the values
void Service::run()
{
// Not in the constructor -- see the warning below.
mConfig.reset(new SDK::AppConfig(mKernel, "app_config.json", kFields));
mConfig->getString("waypointName", mName, sizeof(mName));
mTargetLat = mConfig->getFloat("targetLatitude");
mTargetLon = mConfig->getFloat("targetLongitude");
mRadiusM = mConfig->getInt("arrivalRadiusM");
mVibrate = mConfig->getBool("vibrateOnArrival");
...
}
Read the configuration on the service thread, not in a constructor.
SDK::AppConfig logs when a file is unusable, and in the host simulator an app’s
service is constructed before TouchGFX’s HAL exists — which is what the SDK logger
writes through. An AppConfig built in a constructor therefore segfaults the simulator
before the app draws anything, the moment it meets a file it cannot use. It works on the
watch, which makes it a trap you only find when someone runs the simulator. That is why
the tutorial holds a std::unique_ptr<SDK::AppConfig> and creates it in run().
Method |
Returns |
|---|---|
|
The stored value, or the field’s default. |
|
The stored value clamped to |
|
The stored value clamped to |
|
Copies a NUL-terminated value into |
|
|
|
|
Reading an id that is not in the table is a programming error: the getter returns a zero/empty value and logs a warning. There is no way to read an undeclared field.
5.3 What happens when things go wrong
Nothing in this path may prevent an app from starting. A config problem must degrade to defaults, never to a failed launch:
Condition |
Result |
|---|---|
File absent |
All defaults. |
File unreadable, or larger than 8 KB |
All defaults. |
Not valid JSON |
All defaults. |
|
All defaults. |
|
All defaults. |
One key of the wrong type, or |
That field only falls back to its default. |
One numeric key out of range |
That field only is clamped to its declared bounds. |
One string longer than |
That field only is truncated at a UTF-8 boundary. |
One string shorter than |
That field only falls back to its default, and |
5.4 In the simulator
The host simulator’s app sandbox is a directory on your PC, so testing configuration means dropping a file into it — no phone required:
<simulator working directory>/app_config.json
Edit it, restart the simulator, and the app picks the values up exactly as it would on the watch. Committing a sample file alongside the app is a good way to document what the configuration is supposed to look like.
6. Writing configuration from the app
An app may write the values file, so a value that is convenient to capture on the watch — “set the target to where I am standing” — does not have to be typed into a phone.
6.1 API
cfg.setFloat("targetLatitude", fix.latitude);
cfg.setFloat("targetLongitude", fix.longitude);
if (!cfg.save()) {
LOG_WARN("Could not save waypoint\n");
}
Method |
Behaviour |
|---|---|
|
Validates against the field table, clamping a numeric value into range. Updates the in-memory value and marks the file dirty. Returns |
|
As above, truncating to |
|
Writes the file if anything is dirty. Returns |
|
Removes the id from the file, so the field falls back to its default on the next launch. |
A setter changes what the app sees immediately; save() is what makes it survive a restart.
6.2 How save() writes
save() never truncates the live file in place, because a reset or a flat battery halfway
through would leave the app with a half-written config and no way back:
Write the complete new document to
<configFile>.tmp.Flush and close it.
remove(<configFile>).rename(<configFile>.tmp, <configFile>).
The document it writes contains:
every id the app explicitly set, or that was already present in the file with a usable value — not every declared id, so the distinction between “the user chose this” and “this is the default” is preserved. A key that was present but unusable (wrong type,
null, a malformed number) is dropped, which quietly cleans up a corrupt entry;every unrecognised key from the previous file, copied through unchanged, except that a repeated key keeps only its first copy (section 4.5).
6.3 Recovering an interrupted write
If a reset lands between steps 3 and 4, the values file is missing but <configFile>.tmp is
intact. On load, SDK::AppConfig detects exactly that — no values file, a parseable
temporary file — and completes the rename before reading. A leftover temporary file that
does not parse is deleted.
6.4 Both sides can write: the conflict rule
The file has two writers, so the rule is simply last writer wins, with one obligation on the companion app: it MUST re-read the file over FTS immediately before showing its edit screen, so what it displays is what is actually on the watch.
Two writers means the phone and the watch — not two writers inside one app. Use one
SDK::AppConfig per app, on one thread: the class is not thread-safe, and two instances would
share one <configFile>.tmp with no locking, where save()’s remove-then-rename is not
reentrant. If both an app’s processes need the values, read them in the service and send them
to the GUI, as the tutorial does.
That leaves one narrow race: the user has the phone’s edit screen open while the app changes the same file on the watch. Whoever saves first loses their change. This is accepted — it needs two simultaneous edits of the same app’s configuration on two devices — and it is deliberately not defended with revision counters or locking, which would cost far more complexity than the case is worth.
A companion app that wants to notice it anyway can do so for free on FTS version 5 or later
(DIGEST must be gated on that version — see
BLE File Transfer Service): DIGEST returns a CRC-32
and size for the file, so comparing them against what it last wrote reveals that the watch
changed the file, without transferring it again.
7. Lifecycle
7.1 Install
The companion app reads
app-manifest.jsonfrom the package.If
configFieldsis non-empty, it shows the configuration screen, pre-filled from each field’sdefault. Required fields must be satisfied before the install can continue.The
.uappis transferred, which creates the app’s directory.The values file is written, containing the ids the user set.
Configuration is collected before the transfer so that abandoning the form costs nothing, and written after it because the directory has to exist first.
7.2 Editing later
The user can edit the configuration at any time. The companion app re-reads the file first (section 6.4), shows the current values, and writes the whole file back from offset 0 — an FTS write at offset 0 truncates, so a shorter document cannot leave stale bytes behind.
Because the app reads its configuration at launch, the companion app must tell the user that changes take effect the next time they open the app. An app that is running when the file changes carries on with the values it started with.
7.3 Updating the app
An update may add, remove or re-specify fields. The companion app merges, then always shows the configuration screen so the user sees the result:
Case |
Behaviour |
|---|---|
Id still declared, same type, value still valid |
Kept. |
Id still declared, but the value now fails validation |
Reset to the new |
Id no longer declared |
Dropped. |
Id declared with a different |
Treated as a new field: the old value is dropped. |
Newly declared id |
Pre-filled from its |
Then the user confirms, and the merged file is written. This is why an id must be stable
across versions: renaming one is indistinguishable from deleting a field and adding another,
and the user’s value is lost.
7.4 Uninstall
Uninstalling deletes the app’s directory, and the values file with it. Nothing is retained, so a later reinstall starts from the app’s defaults.
8. Limits
These are hard limits. Tooling rejects anything past them, and SDK::AppConfig is sized for
them.
Limit |
Value |
Why |
|---|---|---|
Fields per app |
32 |
Keeps the app-side state to a fixed-size table and a 32-bit presence mask. |
|
32 characters |
|
|
32 characters |
Fits a phone form row. |
|
200 characters |
|
|
120 characters |
|
|
8 characters |
|
|
128 bytes |
Bounds the app’s buffers. |
|
256 characters |
|
|
63 characters |
|
Number text in the values file |
64 characters |
Longer number text is treated as not set. |
Values file size |
8192 bytes |
The reader holds the whole file; matches the existing variant-config cap. |
|
-2147483648 .. 2147483647 |
32-bit signed. |
|
IEEE-754 binary32 |
Matches the platform’s GPS and track types. |
9. Companion app specification
This section is normative for the mobile implementation.
9.1 Reading the declaration
Check manifest_version first: it is required, and 1 is the only value this
specification covers. Refuse a package that declares anything else rather than parsing on
regardless. Then parse configFields from the package’s app-manifest.json, validate it
against Utilities/Scripts/app_packer/app-config.schema.json, and refuse to install a
package that does not conform — a malformed declaration is a broken package, not something to work
around. Render fields in array order.
9.2 Install flow
As section 7.1. A field with required: true blocks the install until it is satisfied;
every other field may be left at its default. Write the values file only after the .uapp
transfer has completed, to /Apps/<AppDir>/<configFile>, with a single WRITE at
offset 0. On FTS version 5 or later, a DIGEST afterwards is cheap insurance that the file
arrived intact; on earlier versions it is unavailable and must not be sent.
9.3 Edit flow
Read
/Apps/<AppDir>/<configFile>from the watch.If it is missing or unparseable, fall back to all defaults rather than showing an error — the user’s remedy is the same either way.
Pre-fill each field from
valuesif the key is present, otherwise fromdefault, and indicate which fields are still at their default.Offer a per-field “reset to default” action, which removes the key from
valuesrather than writing the default value.Emptying an optional string field removes its key too — do not write
"". Otherwise two companion apps would represent “the user cleared this” differently, and the watch can tell them apart: an absent key means the app’s default, while""is a real value that fails anyminLengthof 1 or more. Arequiredfield cannot be emptied.On save, write the whole document from offset 0.
Tell the user the change applies the next time they open the app.
9.4 Input controls
Type |
Control |
|---|---|
|
Single-line text field. Show a byte counter against |
|
Switch or checkbox. |
|
Numeric keypad with no decimal separator. Offer a minus sign only when |
|
Decimal keypad. Display the decimal separator in the user’s locale, but always serialise with |
Render label as the row’s name, description as help text below it or behind an info
control, and unit immediately after the input. unit is decoration: never append it to
the stored value.
9.5 Default messages
Used when a field has no validationMessage. Wording is the implementation’s own; these are
the cases that must be covered:
Constraint |
Case to cover |
|---|---|
required |
The field is empty. |
minLength / maxLength |
Too short, too long (state the limit). |
min / max |
Below the minimum, above the maximum (state the bound). |
pattern |
The value does not match the required format. |
type |
The text is not a whole number, or not a number. |
9.6 Rules that must not be broken
Never write an invalid value. Validation failures block the save.
Never write a key the app does not declare, and never drop a key that the watch’s copy had but this version does not declare except through the update merge in section 7.3.
Never write a key twice. JSON permits it and leaves the winner undefined; the watch reads the first copy and a
JSON.parseon your side keeps the last, so a duplicate is a silent disagreement. A save on the watch collapses one it finds (§4.5), but do not rely on that.Never trim whitespace from a string value.
Count string length in UTF-8 bytes, not characters.
Serialise numbers plainly: no exponent, no thousands separators,
.as the decimal separator, and no fractional part for anint.Serialise strings as UTF-8, escaping only what JSON requires (
",\and control characters). Do not\uXXXX-escape ordinary non-ASCII text: the 8 KB file limit is budgeted assuming you do not, andvalidate_app_config.pycomputes each app’s worst-case file size on that basis.Write
schema: 1in every file. (That is the values file’s version; the manifest’s ownmanifest_versionis a separate key and stays on the phone.)Re-read before editing (section 6.4).
10. Tooling
Utilities/Scripts/app_packer/validate_app_config.py is the single implementation of the
rules above:
# Validate a package's declaration
python Utilities/Scripts/app_packer/validate_app_config.py --check app-manifest.json
# Cross-check the app's constexpr field table against app-manifest.json
python Utilities/Scripts/app_packer/validate_app_config.py --check app-manifest.json \
--check-bounds Software/Libs/Sources/AppConfigFields.cpp
It validates app-manifest.json against app-config.schema.json, applies the rules a JSON Schema
cannot express (the regex subset, default satisfying its own constraints, configFile
naming, byte-length limits), and — with --check-bounds — parses the SDK::AppConfig::Field
table out of the given source file and compares every id, type, default and bound against
the declaration.
The apps CI runs both, so a package cannot reach the store with a declaration its own binary disagrees with.
11. Worked example
The Waypoint tutorial is a complete utility app built around this feature: it declares five fields covering all four types (two floats, for the coordinate pair), navigates to the configured coordinate, and writes the file back when the user saves their current position as the new target.