Remote Assets¶
Remote assets let a deployment ship large media (images, audio, video) outside the app bundle and fetch it on demand, instead of every install carrying files that most users may never see. Assets are grouped into named asset packs hosted in cloud storage. A template asks for a pack, the app fetches it, and from that point the assets in it resolve exactly like bundled ones.
Where to start
Using packs from templates: Referencing remote assets and Downloading asset packs. Setting a deployment up: Defining asset packs, Uploading and Deployment and storage setup.
How it works¶
At startup the app knows only about its bundled assets. Downloading a pack merges that pack's assets
into the same lookup the bundled ones use, so an author writes images/welcome.png without needing to
know whether that file shipped in the app or arrived from a pack.
What "downloading" means depends on the platform:
| Platform | Behaviour |
|---|---|
| Android / iOS | The pack's files are fetched and saved to the device. Interrupted downloads resume on the next app launch |
| Web | No files are saved. The pack's assets are pointed at the storage provider's CDN and streamed by the browser |
Important
A pack's assets are unavailable on both platforms until a template has run an asset_pack
download action for it at least once — on web that action rewrites the asset paths to CDN URLs
rather than transferring files. Only the file storage and resume behaviour is native-only.
First download, and later updates¶
On Android and iOS, how a pack is fetched depends on whether the device has it already:
| Situation | How it is fetched |
|---|---|
| The device has never had this pack | As one compressed file, which is much faster than a request per asset |
| The pack is being updated | File by file, fetching only those files whose content has changed |
This is decided automatically and needs nothing from authoring, but two things follow from it:
- Interrupting a first download means its transfer starts again on the next attempt. Once a pack has completed, later updates only fetch what changed.
- If a deployment has not uploaded the compressed file for a pack (see Uploading asset packs), packs still download correctly, just more slowly.
Referencing remote assets in templates¶
Nothing special is needed. Reference the asset exactly as a bundled one, using its path relative to the assets folder:
| type | value |
|---|---|
| image | images/welcome.png |
Warning
Do not prefix the path with the asset pack name. The pack name is only used when talking to storage; in authoring the path is the same whether the file is bundled or remote.
Before the pack has been downloaded, the reference falls back to a bundled asset of the same path if one exists — so a pack can be used to replace low-resolution bundled placeholders with full-size versions, for example — and is otherwise unavailable until the pack arrives. Theme and language overrides authored in the usual asset folder structure are included in the pack and fetched alongside the base file.
Downloading asset packs¶
Downloads are triggered from templates with the asset_pack action:
| Action | Behaviour |
|---|---|
download |
Download a single named pack. Always runs, even if the pack has already been downloaded, and always blocks the action queue until finished (may be deprecated in future) |
ensure_downloaded |
Download those packs not already completed, and check the rest for a published update. Does not wait for downloads unless await: true is set |
cancel_download |
Abort any active download and mark it cancelled |
reset |
Return every pack to its pre-download state |
ensure_downloaded does not wait for downloads to finish
Unlike other actions, ensure_downloaded does not block the action queue by default. It starts
its downloads in the background and the actions after it run straight away, so those actions
cannot rely on the pack's assets being present yet. Gate content on the pack's download_status
instead (see Showing download progress).
To wait until downloads finish, pass await: true. Use it sparingly: a download that loses
connectivity parks itself until connectivity returns, so an awaited call can block the action
queue indefinitely.
Naming the pack
download accepts the pack name either as an argument (after a colon) or as an asset_pack
parameter (after a pipe), so it can be authored the same way as ensure_downloaded. The
argument wins if both are given. ensure_downloaded reads the parameter only —
asset_pack: ensure_downloaded: my_pack logs an error and does nothing.
| type | value | action_list |
|---|---|---|
| button | Download pack | click | asset_pack: download: my_asset_pack |
| button | Download pack (parameter form) | click | asset_pack: download | asset_pack: my_asset_pack |
| button | Ensure single pack | click | asset_pack: ensure_downloaded | asset_pack: my_asset_pack |
| button | Ensure multiple packs | click | asset_pack: ensure_downloaded | asset_pack_list: @field.my_pack_list |
| button | Ensure and block until done | click | asset_pack: ensure_downloaded | asset_pack: my_asset_pack | await: true |
| button | Cancel download | click | asset_pack: cancel_download |
| button | Reset all packs | click | asset_pack: reset |
asset_pack_list accepts an array (e.g. a field set via set_field) or a JSON array string.
Parameters¶
| Parameter | Action | Description |
|---|---|---|
asset_pack |
download, ensure_downloaded |
Single pack name. For download, an alternative to giving the name as an argument |
asset_pack_list |
ensure_downloaded |
One or more pack names, as an array or JSON array string |
await |
ensure_downloaded |
Default false, so downloads start in the background. When true, blocks the action queue instead |
check_for_updates |
ensure_downloaded |
Default true. When false, skips the version check on packs already downloaded |
debug_download_delay_ms |
download, ensure_downloaded |
Testing aid, see Testing and debugging |
download has no await parameter — it always blocks the action queue until the download finishes.
One at a time¶
Downloads run one pack at a time, and one file at a time within a pack:
| Situation | Result |
|---|---|
| Same pack requested again while downloading | Joins the download already in progress |
| Different pack requested (default) | Refused for now, retried once the active download finishes |
Different pack requested, await: true |
Refused and returns immediately, not retried |
Updating a pack that users already have¶
Republishing a pack does reach users who already have it. ensure_downloaded compares the published
pack's version against the one on the device, and if they differ re-reads the manifest and fetches
only those files whose content actually changed — not the whole pack.
That version is a content hash generated at sync time, so it changes if and only if pack content changes. Nothing has to be incremented or remembered when republishing.
Version checks are throttled to once an hour per pack
ensure_downloaded is authored on template entry and on buttons, so an un-throttled check would
mean a manifest fetch every time one runs. A check therefore happens at most once an hour per
pack, or 15 minutes after a check that reached the server and failed.
A successful download sets that clock too. So immediately after downloading a pack,
ensure_downloaded will not check again for an hour — which catches out testing far more than it
catches out users: publish an update, press the button, and nothing appears to happen.
Use download to force a check; it bypasses the throttle. That is not an expensive thing to do —
it re-reads the manifest, but still transfers only files whose content changed.
A pack already known to have an update outstanding is never throttled, so an update that failed, was
cancelled, or was interrupted is retried on the next ensure_downloaded.
To opt a call out of checking altogether, pass check_for_updates: false:
| type | value | action_list |
|---|---|---|
| button | Ensure, no update check | click | asset_pack: ensure_downloaded | asset_pack: my_asset_pack | check_for_updates: false |
Further behaviour worth knowing:
- Checks never block the action queue, whatever
awaitsays.ensure_downloadedpromises that a pack is usable, not that it is the latest; usedownloadto block until latest. - A failed check never changes
download_status. A downloaded, working pack is not made to look broken because a check failed — the failure shows up inversion_check_statusinstead. - Being offline records nothing at all, so
failedkeeps meaning "the server was reached and there is something wrong with the published pack". Staleness shows up asversion_checked_atnot advancing. versiononly advances once every file has downloaded successfully, so a partly applied update stays at the old version and is retried on the next check. Every asset still resolves meanwhile.- A failed or cancelled attempt on a pack that had completed before leaves it
completedwith itsversionuntouched, because the pack is still usable. One consequence: an update cannot be permanently dismissed by cancelling it — usecheck_for_updates: falseto stop a refresh recurring.
Progress looks like a full re-download
Each attempt resets assets_downloaded_count to 0, and files skipped as unchanged still count
towards it. A 200-file pack with one changed file therefore sweeps up to 199 and then pauses on
the single real fetch.
reset affects every pack
asset_pack: reset is global, not per-pack: it cancels any active download, deletes all downloaded
files from the device, and returns every asset reference to its bundled version (or to unavailable,
where no bundled version exists). It is all-or-nothing — if the files cannot be deleted, nothing is
changed. Treat it as a debugging / support escape hatch rather than a user-facing button.
Showing download progress¶
Two things are exposed to authoring:
asset_pack_download_in_progress — a system variable holding "true" or "false" while any
download is running, useful for showing or hiding UI. Reference it as
@fields._asset_pack_download_in_progress, or as @system.asset_pack_download_in_progress in
deployments using useReactiveTemplates.
The _asset_packs data list — one row per pack, which can be read with data_items to drive a
progress bar or status display:
| Column | Description |
|---|---|
id / name |
Asset pack name |
download_status |
in_progress, waiting_for_connection, completed, error or cancelled |
assets_total_count |
Total number of files in the pack |
assets_downloaded_count |
Number processed so far in the current attempt |
download_progress_percent |
Percentage complete for the current attempt, 0-100 |
download_started_at |
ISO timestamp of when the current attempt started |
download_completed_at |
ISO timestamp of completion, empty until completed |
download_status_updated_at |
ISO timestamp of the most recent status change |
version |
Version of the pack currently on the device, empty for pre-versioning packs |
available_version |
Version last seen in cloud storage, empty until a check has succeeded |
update_available |
A check found a published version differing from the downloaded one |
has_completed_download |
The pack has been fully downloaded at least once |
version_checked_at |
ISO timestamp of the last successful version check |
version_check_attempted_at |
ISO timestamp of the last check attempt, successful or not |
version_check_status |
never, ok, or failed |
For example, looping over the list (see looping data) to show each pack's progress:
| type | name | value |
|---|---|---|
| begin_data_items | @data._asset_packs | |
| text | pack_progress_@item.id | @item.name: @item.assets_downloaded_count / @item.assets_total_count |
| end_data_items |
Counts or percentage?
assets_downloaded_count is always a real file count, so "x of y files" is accurate however a
pack was fetched. It steps unevenly though, because files in a pack range from under a kilobyte
to a couple of megabytes.
For a progress bar, prefer download_progress_percent. It tracks transferred bytes when a
pack is downloaded as one compressed file and files otherwise, so it moves smoothly either way.
Note
The list is not a catalogue of available packs: a row only appears once a pack has been requested for the first time. Counts are per file, including each theme/language override, so a pack of 10 images with a translated version of each reports 20. Files skipped as already-downloaded still count towards the total.
The statuses behave as follows:
completedis only reached when every file in the pack succeeded.waiting_for_connectionmeans the download is parked offline and will continue by itself when connectivity returns.in_progressandwaiting_for_connectionare picked up automatically on the next app launch if the app was closed mid-download.- Individual files are retried a few times before a pack is given up on, so
errormeans a persistent problem rather than one failed request. errorandcancelledare not retried automatically, but eitherdownloadorensure_downloadedwill retry them (ensure_downloadedonly skips packs that arecompleted).
Ready-made download button¶
The asset_pack_download_button component wraps a download in a button that shows its own progress
and result:
| type | name | parameter_list |
|---|---|---|
| asset_pack_download_button | download_button | asset_pack: my_asset_pack |
| Parameter | Value | Description |
|---|---|---|
asset_pack |
pack name | Pack to download |
default_text |
text | Button text before download (default Download asset pack) |
progress_text |
text | Button text while downloading (default Downloading...) |
success_text |
text | Button text after a successful download (default Success) |
error_text |
text | Button text after a failed download (default Error) |
progress_display |
count (default), percent, spinner |
Show a downloaded/total file count, a percentage, or a spinner |
The button behaves like the download action, i.e. it always re-downloads the pack rather than
skipping one already held. Its value is set to the current status (initial, downloading, success
or error), and any authored click actions run after the download attempt completes.
Recommended pattern¶
- Call
ensure_downloadedat a known entry point (e.g. after onboarding, or when a section is first opened). By default it runs in the background, so navigation isn't held up. - Gate media screens on
@fields._asset_pack_download_in_progressand/or the pack'sdownload_statusfrom_asset_packs, rather than assuming the assets are present. - Use
downloadonly where content needs to be refreshed after a pack has been replaced in storage. - Keep
resetout of normal user flows.
Defining asset packs¶
Asset packs are produced at sync time: assets destined for a pack are held out of the bundled app
assets and written to app_data/remote_assets/{packName}/ instead. There are two ways to declare one.
A Google Drive assets folder marked remote: true. The whole folder becomes one pack, and the
folder's name becomes the pack name:
config.google_drive = {
assets_folders: [
{ id: "1bnGPB70wPp9hzVOpT4LkPX9h3FwIrqfs", name: "Debug Assets" },
{ id: "19Kbwidpb_nOKpJAuRCkVLpAzWbKPzNm1", name: "debug_asset_pack_1", remote: true },
],
};
Warning
A remote folder's name is used as the pack id in templates, in _asset_packs and as the folder
name in cloud storage, so use a stable identifier without spaces (unlike ordinary assets folders,
where the name is only a local label).
A Canto source folder with remote_assets entries. Each entry declares a pack name plus a
condition selecting which of that folder's files belong to it. Files matching no condition remain
bundled assets, and packs are not exclusive — a file is copied into every pack whose condition it
matches:
config.canto = {
url: "https://parentingforlifelonghealth.canto.com",
sourceFolders: [
{
id: "V0DQB",
name: "Debug Canto Assets",
remote_assets: [
{
name: "female_caregiver_pack",
condition: { type: "custom_field", field: "Caregiver Gender", value: "Female" },
},
{
name: "english_no_age_category_pack",
condition: {
type: "and",
conditions: [
{ type: "custom_field", field: "Language", value: "English" },
{ type: "field_empty", field: "Child Age Category" },
],
},
},
],
},
],
};
The supported leaf conditions are custom_field (field name plus value to match) and field_empty
(field has no value set). Both can be combined with { type: "and", conditions: [...] } and
{ type: "or", conditions: [...] }.
Assets shared between packs
The same asset can belong to several packs — with Canto in particular, a file is copied into every pack whose condition it matches. This is by design and costs nothing extra: on device an asset is stored once, keyed by its path, so a pack containing assets that another pack has already fetched skips re-downloading them.
The one thing to avoid is two packs unintentionally shipping different content at the same path, since they share that single stored file and the entry pointing at it — whichever pack downloaded last wins.
Uploading asset packs¶
Sync writes each pack to the deployment's app_data/remote_assets/{packName}/ folder, containing:
- every asset file, in the same folder structure as the source assets folder
{packName}.json— the manifest the app reads, listing each asset's size and checksum{packName}.{version}.zip— every asset in one archive, used on a device installing the pack for the first time. The{version}matches the manifest's ownversionfield
Uploading is currently a manual process. Upload the pack folder as-is (via the Firebase console,
or gsutil cp -r ./app_data/remote_assets/my_asset_pack gs://my-bucket/asset_packs/), so that storage
ends up with:
{folderName}/{packName}/{packName}.json <- manifest
{folderName}/{packName}/{packName}.{version}.zip <- archive of every asset
{folderName}/{packName}/{relativePath} <- each asset file
The folder name in storage must exactly match the pack name used in templates.
Re-uploading a changed pack
Upload the asset files and the new {packName}.{version}.zip first, and {packName}.json
last. The manifest is what tells the app a new version exists, so if it arrives first the app
goes looking for content that is not there yet. It recovers on its own once the rest lands, but
it looks like a bug in the meantime.
The archive filename changes with every content change, so a new upload never overwrites the
archive that in-flight installs are still reading, and a stale archive can never be served for a
new manifest. Sync only ever leaves the current archive in the pack folder, so you will not
upload an old one by accident — but uploading adds to the bucket rather than replacing, so
delete superseded {packName}.{version}.zip objects once no device is still installing from
them, or storage grows by the size of the pack on every publish.
Deployment and storage setup¶
The feature is inactive until the deployment config opts in:
config.remote_assets = {
provider: "firebase",
// bucketName: "my-bucket", // required for supabase; ignored for firebase
folderName: "asset_packs",
};
| Property | Description |
|---|---|
provider |
"firebase" or "supabase". The chosen provider must also be configured in the same config (config.firebase / config.supabase) |
bucketName |
Storage bucket name. Required for supabase; ignored for firebase, which reads the bucket from the firebase config |
folderName |
Path prefix inside the bucket that all asset packs live under. There is no default — it must match the prefix actually used in the bucket |
Without remote_assets.provider, the asset_pack actions still exist but log an explanatory error
instead of doing anything.
Firebase¶
Firebase storage must be enabled for the firebase project associated with the relevant deployment, via the firebase console. See also Firebase management for configuring firebase itself.
The folder structure in the bucket should mirror the folderName given in the deployment config.
Unlike supabase, the bucketName value is irrelevant for firebase: the app uses the storageBucket
value from config.firebase.config, read from the firebase instance at runtime (typically
{projectId}.firebasestorage.app, or {projectId}.appspot.com on older projects). So asset packs
simply need to be nested inside a folder within firebase storage in the following way:
{config.remote_assets.folderName}/{my_asset_pack_name}
Two separate things then have to be allowed, or assets fail to load with permission or CORS errors:
1. Read access. Assets are fetched from public download URLs, so the bucket's storage rules must
allow unauthenticated reads of the asset pack folder, e.g. for folderName: "asset_packs":
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /asset_packs/{allPaths=**} {
allow read: if true;
allow write: if false;
}
}
}
2. CORS. On web the browser fetches those URLs cross-origin, so the bucket's CORS policy must
permit GET from the app's origin. This is configured with the gsutil CLI tool, which comes bundled
with the Google Cloud CLI — for instructions on downloading the tool and setting the policy, see the
firebase docs.
For example, the policy was set on the debug deployment by creating a cors.json:
[
{
"origin": ["*"],
"method": ["GET"],
"maxAgeSeconds": 3600
}
]
and running (the bucket here being that of the debug project, debug-e8934):
gsutil cors set cors.json gs://debug-e8934.firebasestorage.app
"origin": ["*"] is convenient for debugging; for a production deployment, restrict it to the origins
the app is served from.
Supabase¶
This page walks through the firebase setup only. For supabase, the equivalent requirements are:
config.supabaseenabled, with its url and public API keybucketNameset inconfig.remote_assets— unlike firebase, it is required and is the bucket the files are read from- the bucket configured to allow public read access
- the same
{folderName}/{packName}/…folder layout inside that bucket
Testing and debugging¶
download and ensure_downloaded accept debug_download_delay_ms, which pauses for that many
milliseconds before each asset file, however the pack is being fetched:
click | asset_pack: download: my_asset_pack | debug_download_delay_ms: 3000
On a first download the pause applies as each file is unpacked, not to the transfer itself, so the window it opens is during installation rather than during the download.
This exists to open a reliable window for interrupting a download — force-quitting the app mid-pack, toggling aeroplane mode — which is otherwise hard to hit on a fast connection. It should be omitted outside local testing. Note that the delay applies to skipped files too, so a resumed download won't look any faster with it enabled: check that status and counts reach completion rather than judging by speed.
The debug deployment content (the app-debug-content repo and its Debug Sheets drive folder) contains
reference templates — debug_remote_assets and debug_asset_packs — covering downloads, progress
display, overrides and reset.
Current limitations¶
- No background continuation. Downloads stop when the app is backgrounded or killed, and resume on next launch rather than continuing while away.
- No download queue. One pack and one file at a time, so large packs are slow.
- No per-pack delete. Storage is reclaimed for all packs at once via
asset_pack: reset, or not at all. - Updates can leave orphaned files. Removing or renaming a manifest entry leaves the old file on
the device, still resolving, until
reset. Storage is shared between packs, so nothing can prove another pack does not still need that file. Changed content is overwritten in place, so this only arises from removals and renames. - No integrity repair. There is no way to detect or fix an asset file that becomes corrupted after it has been downloaded.