> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loci.ng/llms.txt
> Use this file to discover all available pages before exploring further.

# Frames

> Fill the map from the LOCI frame index. Initialise the SDK with an API key, preload frames and the data recorded on them, cache for offline use, and spend the key's budget on purpose.

Frames give the map its work areas and the data recorded on them. Load them
before the map appears and save them on the device to work without signal.
The map never touches the network itself: panning, drawing, showing and
hiding work areas never wait on a connection and never spend the key's
budget.

Everything on this page is the SDK making the same calls this site
documents. The request and response contract is the
[API reference](/api-reference/introduction); this page is which of those
calls the SDK makes, and when.

## Which API calls the SDK makes

The SDK makes exactly three kinds of request, all on the frames service. It
never looks frames up by state, LGA, ward or box.

| SDK method | API call | Needs |
| - | - | - |
| `frameAtLocation(lat, lng)` | [`GET /v1/frames/at`](/api-reference/introduction) | `frame.read` |
| `frameFromWorkArea(area)` | `GET /v1/frames/at` for a point inside your polygon | `frame.read` |
| `loadFrames(ids)`, `savePack(...)` | [`GET /v1/frames?ids=`](/api-reference/introduction) for outlines, then [`GET /v1/frames/data?ids=`](/api-reference/introduction) in batches of 100 | `frame.read`; `feature.read` for feature datasets |

## Get your key

Create an API key in your LOCI dashboard under **Developers → New key**: pick
a scope and an expiry, and copy the secret, which is shown once. See
[Authentication](/authentication) for the full story, and [Scopes](/scopes)
for what each preset reaches.

| Scope | For frames, this gives you |
| - | - |
| **Locality** | The frame under a point, and the baseline rows recorded on frames (administrative, population, buildings, physical, cultural). |
| **Analyst** | The same reads. The question call it adds is not used by the SDK. |
| **Full read** | Also the individual points, lines and polygons recorded on each frame — the `feature.read` a dataset of shapes needs. |

> [!NOTE]
>
> A key is read-only, acts for one organisation, and expires. Every device
> that uses the same key shares one rate-limit budget — see
> [cost](#rate-limits-and-cost).

## Initialise the SDK

Call `Loci.initialize` once, for example in `Application.onCreate`. Read the
key from your own secure storage. After that, `Loci.frames` is the object
every example here uses. Calling it again replaces the key; `Loci.dispose()`
releases the SDK, and `Loci.isInitialized` says whether it is ready.

```kotlin initialise theme={null}
Loci.initialize(applicationContext, readKey())
```

Every call returns at once and reports to its callback on the main thread
with a `LociFramesOutcome`: `Success` with the value, or `Failure` with a
`LociFramesError`. See [Errors and events](#errors-and-events).

## Two ways to give the map its work areas

Work areas come from boundaries you hold ([Work areas](/sdks/android-maps#work-areas))
or from frames you load.

| Question | Your own boundaries | Frames |
| - | - | - |
| Where the outlines come from | Your data, passed to `attach` or `setWorkAreas`. | The frames service, by id. |
| Works without signal | Always. | From a saved pack ([Packs](#packs)). |
| Needs a key | No. | Yes. |

Load frames before the map appears, for example when the user opens a
project — not from a tap or a camera callback.

### Find a frame

If you do not hold frame ids, ask for the frame under a coordinate or under
one of your work areas. Both return the frame's id, outline and box.

```kotlin frameAtLocation and frameFromWorkArea theme={null}
Loci.frames.frameAtLocation(9.0579, 7.4951) { outcome ->
    outcome.getOrNull()?.let { frame -> println("${frame.id} ${frame.bbox}") }
}

Loci.frames.frameFromWorkArea(area) { outcome ->
    outcome.getOrNull()?.let { frame -> println(frame.id) }
}
```

A point outside the grid fails with `LociFramesErrorCode.NOT_FOUND`.
`LociWorkArea.fromBbox(bbox, id)` and `LociWorkArea.fromFrame(frame)` build a
work area from a box or a frame.

## Preload your frames

`loadFrames` returns the frames as work areas and the recorded shapes as
layers. Pass both to the map.

```kotlin load, then show theme={null}
Loci.frames.loadFrames(listOf("2163538", "2163551"), hiddenLayerIds = setOf("internal-notes")) { outcome ->
    val result = outcome.getOrNull() ?: return@loadFrames
    controller.setWorkAreas(result.workAreas)
    controller.setLayers(result.mapLayers)
}
```

On a `LociMapView` set the same two values as properties:

```kotlin show them on the view theme={null}
view.workAreas = result.workAreas
view.layers = result.mapLayers
```

Each frame is a work area with the frame id as its id; its data is under
`properties["frames"]`, by layer id. Frames start hidden — the user turns
them on in the work-area dialog, or you call `controller.showWorkArea(id)`. A
frame follows every work-area rule, including
[geofencing](/sdks/android-maps#geofencing).

### What you get back

A `LociFramesResult` has these properties:

| Property | Meaning |
| - | - |
| `workAreas` | One per frame found. Hidden layers are removed. |
| `frameIds` | The ids you asked for, in your order. |
| `layers` | The layers the frames' datasets make. The frame itself (the administrative baseline) is not one. A pack keeps these. |
| `datasets` | The shapes users recorded on the frames, by layer. Only a cloud load returns them. See [Packs](#packs). |
| `mapLayers` | One view-only `MapLayer` for each of `layers`, holding the recorded shapes. Pass it to the map. |
| `missingIds` | Ids the service has no frame or no data for. **Not an error** — show it to the user. |
| `charged` | Datasets this call was billed for. See [cost](#rate-limits-and-cost). |
| `leftOut` | Datasets your key is not allowed to read, with the reason. |
| `truncatedLayerIds` | Layers that hit the 200-feature cap per request, so may be incomplete. |
| `fromPack`, `packId`, `createdAtSeconds` | Whether the data came from a saved pack, which one, and when it was sealed. |
| `requests` | HTTP requests this call made. Zero when it came from a pack. |

> [!WARNING]
>
> Recorded shapes in `datasets` are returned once and are not stored. After a
> pack load the view-only layers are empty: load from the cloud again or pass
> the features you keep.

## Packs

A pack saves frames on the device under an id you choose, for example
`ward-7`, so they load without signal. Ids use 1 to 64 letters, digits, `_`
or `-`. This is the only place LOCI stores data: a pack holds frame outlines,
layer definitions and baseline values, encrypted and readable only on this
device. It never holds features — the shapes users recorded on a frame, and
the entries your app passes in, are not stored. Pass your features each time.

```kotlin save, load, refresh, list, delete theme={null}
val frames = Loci.frames
val ids = listOf("2163538", "2163551")

frames.savePack("ward-7", ids, hiddenLayerIds = setOf("internal-notes"), maxAgeSeconds = 1_209_600) { outcome -> println(outcome) }
frames.loadPack("ward-7") { outcome -> println(outcome) }           // no network
frames.refreshPack("ward-7") { outcome -> println(outcome) }        // only when you call it
frames.listPacks { outcome -> println(outcome.getOrNull()) }        // [ward-7]
frames.deletePack("ward-7") { outcome -> println(outcome.getOrNull()) }
```

| Method | What it does |
| - | - |
| `savePack` | Fetches the ids and seals them as the pack. Replaces any pack with that id. If anything fails, the previous pack stays exactly as it was. |
| `loadPack` | Reads the pack. No network. Fails when the pack is missing, belongs to another organisation, or is expired. Never deletes the pack. |
| `refreshPack` | Fetches the pack's frames again and replaces the pack. Runs only when you call it. An expired pack can be refreshed. Two concurrent refreshes share one request. |
| `deletePack` | Removes the pack file. Gives true when a pack was removed. |
| `listPacks` | Pack ids on this device, sorted. |

**What a pack stores:** frame id, outline and bounding box; layer definitions
and baseline values, except layers you hid ([Hidden layers](#hidden-layers));
and the creation time, the organisation and the optional maximum age. Packs
are kept out of Android backups and need **Android 6.0 (API 23)**; below
that, pack methods fail with `LociFramesPackError.STORAGE_FAILED`. A pack is
never refreshed automatically — call `refreshPack`.

**Expiry.** Pass `maxAgeSeconds` to `savePack` or `refreshPack`. After that
long the pack is expired, not deleted, and `loadPack` fails with
`LociFramesPackError.EXPIRED`. `LociFramesSource.PACK_FIRST` treats an expired
pack like a missing one and goes to the service; `refreshPack` works on it.
With no `maxAgeSeconds` a pack never expires, and a refresh without it keeps
the pack's own.

### Hidden layers

Keep a layer off the device, such as internal notes, by passing its id as
`hiddenLayerIds` to `loadFrames`, `savePack`, `loadPack` or `refreshPack`. A
hidden layer is not returned and is not stored in packs.

```kotlin hide a layer theme={null}
Loci.frames.loadFrames(listOf("2163538"), hiddenLayerIds = setOf("internal-notes")) { outcome -> println(outcome) }
```

### Source: cloud or pack

`loadFrames` reads from the service by default. Set `source` to read a pack.

| Source | Behaviour | Needs `packId` |
| - | - | :-: |
| `LociFramesSource.CLOUD` (default) | Reads from the service. Does not read or write a pack. | No |
| `LociFramesSource.PACK_ONLY` | Reads the pack named by `packId`. No network. Fails when the pack is missing, foreign or expired. | Yes |
| `LociFramesSource.PACK_FIRST` | Reads the pack when it exists and is not expired; otherwise reads from the service. Does not save what it fetched. | Yes |

```kotlin pack first theme={null}
Loci.frames.loadFrames(
    ids = listOf("2163538", "2163551"),
    source = LociFramesSource.PACK_FIRST,
    packId = "ward-7",
) { outcome -> println(outcome) }
```

To save frames, call `savePack`.

## Errors and events

A failed call reports `LociFramesOutcome.Failure` with one of four errors.
Branch on the case and its code, not on the message. None of them carries
your key.

| Error | Meaning |
| - | - |
| `LociFramesError.Api` | A service or connection error. `code` is a `LociFramesErrorCode`: `UNAUTHORIZED`, `INVALID_KEY`, `INSUFFICIENT_SCOPE`, `INVALID_POINT`, `INVALID_BBOX`, `INVALID`, `NOT_FOUND`, `RATE_LIMITED`, `INTERNAL`, `NETWORK`, `UNTRUSTED_CONNECTION` or `UNKNOWN`. It also has `status`, `retryAfterSeconds` and `isFinal`. Final codes are never retried. |
| `LociFramesError.Format` | The service answered in a shape the SDK cannot read. |
| `LociFramesError.Pack` | A pack could not be used. `error` is a `LociFramesPackError`: `INVALID_KEY`, `TRUNCATED`, `BAD_MAGIC`, `UNSUPPORTED_VERSION`, `AUTHENTICATION_FAILED`, `CORRUPT_PAYLOAD`, `NOT_FOUND`, `ORG_MISMATCH`, `EXPIRED`, `INVALID_PACK_ID` or `STORAGE_FAILED`. |
| `LociFramesError.InvalidArgument` | An argument was not usable, such as an empty id or a latitude out of range. Nothing was sent. |

```kotlin handle a failure theme={null}
Loci.frames.loadFrames(listOf("2163538")) { outcome ->
    when (val error = outcome.errorOrNull()) {
        null -> Unit
        is LociFramesError.Api -> if (error.isFinal) show("Check your key") else show("Try again later")
        is LociFramesError.Pack -> show("The saved pack could not be opened")
        is LociFramesError.Format, is LociFramesError.InvalidArgument -> show(error.message)
    }
}
```

### Events

Listen for things worth showing the user. `addListener` returns a function
that stops listening.

| Event | Meaning |
| - | - |
| `LociFramesEvent.TokenRejected` | The key was refused (`UNAUTHORIZED`, `INVALID_KEY`, `INSUFFICIENT_SCOPE`). No retry will help; supply a different key. |
| `LociFramesEvent.Charged` | A request was billed for per-query datasets. |
| `LociFramesEvent.MayBeTruncated` | A cloud fetch found a dataset at the 200-feature cap. |

```kotlin listen theme={null}
val stop = Loci.frames.addListener { event ->
    when (event) {
        is LociFramesEvent.TokenRejected -> show("Your key was refused")
        is LociFramesEvent.Charged -> show("Billed for ${event.datasetIds.size} datasets")
        is LociFramesEvent.MayBeTruncated -> show("Some layers may be incomplete")
    }
}
// call stop() to stop listening
```

**Retries.** A 429 waits its `Retry-After` and is retried up to three times.
A server error or dropped connection is retried once. Final codes are not
retried. A failed fetch returns no partial result and does not change a pack.

## Rate limits and cost

Rate limits apply per key and per organisation, so devices sharing a key
share one limit. Load frames in your prepare step, not in a loop. Read
`retryAfterSeconds` on a `RATE_LIMITED` error rather than any number here,
because the numbers can change — the full detail is in [Limits](/limits).

| Topic | Rule |
| - | - |
| Commands that cost requests | `loadFrames` from the service, `savePack`, `refreshPack` and the two frame lookups. Loading a pack, listing packs and opening the map cost none. |
| Requests per load | `2 × ceil(N / 100)` for N ids: shapes, then data, 100 ids per call. `result.requests` counts them, retries included. |
| Billed datasets | Listed in `charged`. Some datasets are [licensed per query](/limits#per-query-datasets); each refresh bills them again. |
| Feature cap | 200 features per dataset per request. `truncatedLayerIds` lists the layers that reached it. |
| Expired key | A saved pack still opens offline. |

> [!WARNING]
>
> Keys expire, and a key stops working when the person who created it leaves
> the organisation. A phone offline for weeks may find its key dead — but
> pack data already on the device stays readable.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.