Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions docs/development/app_permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# App permissions and the Microphone API

Third-party apps can ask for capabilities the user has to grant. The first
one is live microphone access. This page describes the moving parts across
the firmware and the phone app so the two stay in sync.

Nothing that predates the permission system is gated by it: dictation
sessions keep working for every app, because the app only ever receives a
transcription. Permissions only guard APIs that hand raw data to the app.

## Declaring a permission

An app declares what it needs in its manifest:

```json
"capabilities": ["microphone"]
```

The SDK build turns that into `PROCESS_INFO_USES_MICROPHONE` (bit 11 of the
binary header flags, see `pebble_process_info.h`), so the firmware can tell
"not declared" from "denied" without talking to the phone. The phone reads
the same array from `appinfo.json` in the `.pbw` and from the web locker.

## Grant records

The phone is the source of truth. It pushes one record per app into the
`AppPermissions` BlobDB (id `0x0D`), keyed by the 16-byte app UUID:

| Offset | Size | Field | Notes |
|-------:|-----:|-----------------|------------------------------------|
| 0 | 1 | `version` | 1 |
| 1 | 3 | reserved | 0 |
| 4 | 4 | `granted_mask` | little-endian, bit 0 = microphone |
| 8 | 4 | `declared_mask` | informational, same bit layout |

The record is deleted when the app is uninstalled and kept across upgrades,
so grants persist across ordinary updates. The watch advertises
`app_permissions_support` (protocol capability bit 25) so the phone knows it
can push records; enforcement never depends on that bit.

## Resolving a permission on the watch

`app_permissions_get_state_for_current_app()` in
`src/fw/services/app_permissions/` combines the header flag and the record:

- not declared in the header → `AppPermissionStateNotDeclared`, whatever the
record says;
- system apps → `AppPermissionStateGranted`;
- declared, no record (old phone app, or nothing pushed yet) →
`AppPermissionStateDenied`. SDK shell builds (`CONFIG_SHELL_SDK`) return
`Granted` here so `pbl install` works without the phone;
- declared with a record → the bit in `granted_mask`.

When the running app's record changes the service emits
`PEBBLE_APP_PERMISSION_EVENT`, which apps observe through
`app_permission_service_subscribe()`, and tells the capture service to stop
if the microphone was revoked.

Console commands for development:

```
perm list
perm grant <install id> mic
perm revoke <install id> mic
```

## Microphone capture

Watchfaces can never record: the SDK build rejects `microphone` in a
watchface manifest, the phone never creates a grant for one, and the capture
service refuses them (`MicCaptureStartErrWatchface`) regardless of the record.

`mic_data_service_subscribe()` (applib) starts capture through
`mic_capture_service` (kernel). Capture is only served to the app task, only
while the app is in focus, and only with the permission granted. The kernel
owns a 320 ms ring buffer that the app drains through syscalls; when the
app falls behind the newest chunk is dropped and the next batch carries an
`overrun` flag.

The microphone itself is shared through `mic_manager`: dictation always
wins and preempts an app, and an app cannot start while dictation runs.

Capture stops, with a reason delivered to the app's `stopped` handler:

| Trigger | `MicDataStopReason` |
|-------------------------------------------|----------------------|
| any focusable modal (notification, call…) | `FocusLost` |
| dictation takes the microphone | `Interrupted` |
| the grant is revoked | `PermissionRevoked` |
| the app exits or is killed | (no callback) |

While capturing, the OS shows a "Listening" banner (`src/fw/popups/mic_banner.c`)
as a transparent, unfocusable modal at discreet priority. It reserves its strip
through the unobstructed area service, composing with Timeline Peek, so
`layer_get_unobstructed_bounds()` shrinks for the app. Any modal that could
hide the banner also takes focus, which is what stops capture.

## Streaming to the phone

`mic_stream_to_phone_start()` (applib) skips the app entirely: the capture
service opens the Speex encoder, sets up an audio endpoint transfer and asks
the phone for a `VoiceEndpointSessionTypeAudioStream` session over the voice
endpoint, tagged with the app UUID (untagged for built-in apps). Once the
phone accepts, the mic starts and every frame is encoded on KernelBG and sent
over the audio endpoint (10000), exactly like dictation but with no result
expected. The app is told through `started`, and through `stopped` with
`MicDataStopReasonPhone` if the phone refuses, stops, or never answers (8 s).
Dictation preempts a stream the same way it preempts capture.

On the phone, `VoiceSessionManager` answers the session, decodes the frames
and publishes them as PCM to the app's PebbleKit JS runtime as
`audiostream` events (see `WatchAudioStreams`).

## Encoding

Raw PCM is too much for the Bluetooth link. `audio_encoder_open()` (applib)
gives the app the firmware's speech encoder, one frame at a time; the
returned `AudioEncoderInfo` tells the phone how to decode. Speex wideband is
the only codec today; the service is codec-agnostic so another backend can
be added later. Dictation uses the same service as the system owner and
takes precedence over an app.

## Phone side (CoreApp)

- `LockerAppPermission` rows track each declared permission per app, with
`decided = false` until the user answers the prompt shown on the home
screen. Grants can be changed later from the app's detail screen.
- `LockerPermissions` reconciles the rows whenever an app is installed,
updated or removed, and mirrors them into the `AppPermissionsEntry`
BlobDB entity that syncs to the watch.
- An update that adds a permission installs with the permission denied and
prompts right away; the install itself is never blocked.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ development/qemu.md
development/debugging.md
development/moddable.md
development/sdk_export.md
development/app_permissions.md
development/contributing.md
```

Expand Down
41 changes: 41 additions & 0 deletions include/pbl/services/app_permissions/app_permissions.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* SPDX-FileCopyrightText: 2026 Core Devices LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#pragma once

#include "pbl/services/app_permissions/app_permissions_types.h"
#include "pbl/services/blob_db/app_permissions_db.h"
#include "pbl/util/uuid.h"
#include "system/status_codes.h"

#include <stdbool.h>

//! Kernel-side view of per-app permission grants.
//!
//! The phone is the source of truth and pushes grant records into BlobDBIdAppPermissions. This
//! service turns those records plus the app's manifest declaration into an AppPermissionState,
//! and notifies the running app (PEBBLE_APP_PERMISSION_EVENT) when its grants change.
//!
//! Rules:
//! - Not declared in the app's manifest -> NotDeclared, whatever the record says.
//! - System apps -> Granted.
//! - Declared but no record (e.g. old phone app) -> Denied. SDK shell builds default to Granted
//! so sideloaded apps work without the phone.

//! Subscribes to BlobDB events. Call once at boot after blob_db_init_dbs().
void app_permissions_init(void);

//! @param uuid The app's UUID
//! @param declared Whether the app declares the permission in its manifest
//! @param permission The permission to look up
AppPermissionState app_permissions_get_state_for_app(const Uuid *uuid, bool declared,
AppPermission permission);

//! State of a permission for the app currently running in the app task (NotDeclared if none).
AppPermissionState app_permissions_get_state_for_current_app(AppPermission permission);

bool app_permissions_is_granted_for_current_app(AppPermission permission);

//! Grants or revokes a permission locally (console / test tooling). Writes through the BlobDB so
//! the same events fire as for a phone-originated change.
status_t app_permissions_set_granted(const Uuid *uuid, AppPermission permission, bool granted);
29 changes: 29 additions & 0 deletions include/pbl/services/app_permissions/app_permissions_types.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* SPDX-FileCopyrightText: 2026 Core Devices LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#pragma once

//! @addtogroup Foundation
//! @{
//! @addtogroup Permissions
//! @{

//! Permissions an app can declare in its manifest and the user can grant or deny.
typedef enum AppPermission {
//! Live microphone capture (`capabilities: ["microphone"]`)
AppPermission_Microphone = 0,
AppPermissionCount,
} AppPermission;

//! State of a permission for one app.
typedef enum AppPermissionState {
//! The app does not declare the permission in its manifest.
AppPermissionStateNotDeclared = 0,
//! The permission is declared but has not been granted by the user.
AppPermissionStateDenied = 1,
//! The permission is granted.
AppPermissionStateGranted = 2,
} AppPermissionState;

//! @} // end addtogroup Permissions
//! @} // end addtogroup Foundation
50 changes: 50 additions & 0 deletions include/pbl/services/audio_encoder/audio_encoder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* SPDX-FileCopyrightText: 2026 Core Devices LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#pragma once

#include "kernel/pebble_tasks.h"
#include "pbl/services/audio_encoder/audio_encoder_types.h"

#include <stdbool.h>
#include <stdint.h>

//! Codec-agnostic speech encoder shared by dictation and the app Microphone API.
//!
//! One encoder instance exists at a time, owned by a task. Dictation opens it as the system
//! owner and takes it away from an app if needed (the app's capture has already been preempted
//! by then). Backends are selected at build time; see audio_encoder_backend.h.

_Static_assert(sizeof(AudioEncoderInfo) == 16, "AudioEncoderInfo is part of the SDK ABI");

//! Largest input frame any backend accepts, in samples (all channels).
#define AUDIO_ENCODER_MAX_FRAME_SAMPLES (1600)
//! Largest encoded packet any backend produces.
#define AUDIO_ENCODER_MAX_PACKET_BYTES (512)

//! Owner used by kernel clients (dictation).
#define AUDIO_ENCODER_SYSTEM_OWNER (PebbleTask_KernelMain)

void audio_encoder_service_init(void);

bool audio_encoder_service_is_codec_available(AudioCodec codec);

//! Opens the encoder for `owner`. Fails if another task holds it, unless the caller is the
//! system owner, which closes an app's encoder first.
//! @param codec Codec to encode with
//! @param owner Task that will own the encoder
//! @param[out] info_out Filled on success
bool audio_encoder_service_open(AudioCodec codec, PebbleTask owner, AudioEncoderInfo *info_out);

//! Encodes exactly frame_samples * channels samples.
//! @return number of bytes written to out, or a negative value on error
int audio_encoder_service_encode(PebbleTask owner, const int16_t *pcm, uint32_t num_samples,
uint8_t *out, uint32_t out_len);

//! Closes the encoder if `owner` holds it.
void audio_encoder_service_close(PebbleTask owner);

//! Closes the encoder if `task` holds it. For process cleanup.
void audio_encoder_service_close_for_task(PebbleTask task);

bool audio_encoder_service_is_open(void);
27 changes: 27 additions & 0 deletions include/pbl/services/audio_encoder/audio_encoder_backend.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* SPDX-FileCopyrightText: 2026 Core Devices LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#pragma once

#include "pbl/services/audio_encoder/audio_encoder.h"

#include <stddef.h>

//! One codec implementation. A backend is a singleton: open() allocates its state, close()
//! frees it, and encode() is only called in between.
typedef struct AudioEncoderBackend {
AudioCodec codec;
//! @param[out] info_out Filled on success
bool (*open)(AudioEncoderInfo *info_out);
//! @param pcm frame_samples * channels samples
//! @return bytes written or negative on error
int (*encode)(const int16_t *pcm, uint8_t *out, uint32_t out_len);
void (*close)(void);
} AudioEncoderBackend;

//! Backends compiled into this firmware. Tests provide their own.
const AudioEncoderBackend *const *audio_encoder_get_backends(size_t *num_backends_out);

#ifdef CONFIG_SPEEX
extern const AudioEncoderBackend g_audio_encoder_backend_speex;
#endif
35 changes: 35 additions & 0 deletions include/pbl/services/audio_encoder/audio_encoder_types.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* SPDX-FileCopyrightText: 2026 Core Devices LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#pragma once

#include <stdint.h>

//! @addtogroup Foundation
//! @{
//! @addtogroup AudioEncoder
//! @{

//! Speech codecs. Availability depends on the firmware build; check with
//! \ref audio_encoder_codec_available.
typedef enum AudioCodec {
AudioCodecInvalid = 0,
//! Speex wideband, 16 kHz, ~9.8 kbps. Frames of 320 samples, ~30 byte packets.
AudioCodecSpeexWB = 1,
AudioCodecCount,
} AudioCodec;

//! Describes an open encoder. Everything an app needs to tell the phone how to decode.
typedef struct AudioEncoderInfo {
uint8_t codec; //!< AudioCodec
uint8_t channels; //!< Interleaved input channels (1 for the watch mic)
uint16_t frame_samples; //!< Input samples per channel per encode call
uint32_t sample_rate; //!< Hz
uint32_t bitrate; //!< bits per second (nominal)
uint16_t max_packet_bytes; //!< Upper bound on one encoded frame
uint8_t bitstream_version; //!< Codec-specific bitstream version
uint8_t reserved;
} AudioEncoderInfo;

//! @} // end addtogroup AudioEncoder
//! @} // end addtogroup Foundation
1 change: 1 addition & 0 deletions include/pbl/services/blob_db/api.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ typedef enum PACKED {
BlobDBIdHealth = 0x0A,
BlobDBIdAppGlance = 0x0B,
BlobDBIdSettings = 0x0C,
BlobDBIdAppPermissions = 0x0D,
NumBlobDBs,
} BlobDBId;
_Static_assert(sizeof(BlobDBId) == 1, "BlobDBId is larger than 1 byte");
Expand Down
Loading