Skip to content

Device-specific Settings

Gadgetbridge provides a Kotlin DSL for declaring device-specific settings programmatically. It replaces the older pattern of returning lists of XML resource IDs from getDeviceSpecificSettings() and pairing them with a DeviceSpecificSettingsCustomizer.

The DSL lets you:

  • Declare every preference in one place: in the coordinator.
  • Control visibility dynamically based on current preference values (visibleWhen).
  • Create reusable, typed components (equalizer, volume, language, password, etc.).
  • Enumerate settings for non-UI consumers (device controls, quick tiles, etc.) without parsing XML (not yet implemented, but in the future).

The settings are rendered into the standard PreferenceFragment. The device's protocol layer (onSendConfiguration) is unchanged.


Quick start

Override getDeviceSettings() in your coordinator and return a DeviceSettingsSpec built with the deviceSettings { } builder:

override fun getDeviceSettings(device: GBDevice): DeviceSettingsSpec = deviceSettings {
    switchSetting(
        key   = DeviceSettingsPreferenceConst.PREF_NOTIFICATIONS_ENABLE,
        title = R.string.pref_notifications_enable,
        icon  = R.drawable.ic_notifications,
    )
    list(
        key            = DeviceSettingsPreferenceConst.PREF_SCREEN_ORIENTATION,
        title          = R.string.pref_screen_orientation,
        entriesRes     = R.array.pref_screen_orientation_labels,
        entryValuesRes = R.array.pref_screen_orientation_values,
    )
}

The existing getDeviceSpecificSettings() and customizer are ignored when getDeviceSettings() returns a non-null value.


Primitive settings

All primitives are functions on DeviceSettingsScope, the implicit receiver inside the deviceSettings { } block.

Boolean switch

Renders to a SwitchPreferenceCompat.

switchSetting(
    key          = "my_pref_key",
    title        = R.string.my_pref_title,
    icon         = R.drawable.ic_something,    // optional
    defaultValue = false,                      // optional
    dependency   = "other_key",                // optional: disable when other switch is off
    connectedOnly = true,                      // default: grey out when device is disconnected
    visibleWhen  = { prefs -> /* ... */ },       // optional: hide/show based on current prefs
)

List

Renders to a ListPreference. Three overloads are available depending on the entry source.

Legacy XML arrays (no migration needed for existing arrays):

list(
    key            = "my_pref_key",
    title          = R.string.my_pref_title,
    entriesRes     = R.array.my_labels,
    entryValuesRes = R.array.my_values,
)

Static Kotlin list (preferred for new code):

list(
    key     = "my_pref_key",
    title   = R.string.my_pref_title,
    entries = listOf(
        ListEntry.Res("value_a", R.string.label_a),
        ListEntry.Res("value_b", R.string.label_b),
    ),
)

Dynamic entries (re-evaluated on every device state change - use for presets fetched from the device):

list(
    key            = "my_pref_key",
    title          = R.string.my_pref_title,
    entriesProvider = { prefs ->
        myDevice.availablePresets().map { p -> ListEntry.Text(p.id, p.name) }
    },
)

Seek bar (integer slider)

Renders to a SeekBarPreference.

seekbar(
    key          = DeviceSettingsPreferenceConst.PREF_VOLUME,
    title        = R.string.menuitem_volume,
    min          = 0,
    max          = 30,
    defaultValue = 15,
    showValue    = true,   // optional
)

Category header

Renders to a PreferenceCategory that groups the preferences that follow it (up to the next category or XML screen boundary). The category is automatically hidden when all of its member preferences are hidden.

category(
    key   = "pref_header_audio",
    title = R.string.pref_header_audio,
)

Text

Renders to an EditTextPreference.

text(
    key       = "my_text_key",
    title     = R.string.my_text_title,
    maxLength = 20,
    inputType = InputType.TYPE_CLASS_TEXT,
)

Set enabled = false to display a value read-only (the dialog cannot be opened):

text(
    key     = "pref_reported_codec",
    title   = R.string.reported_codec,
    enabled = false,   // read-only display
)

For advanced keyboard/filter control, supply onBindEditText. It is called after the standard inputType and maxLength are applied, so you can append additional InputFilters or attach a TextWatcher:

text(
    key            = "my_text_key",
    title          = R.string.my_text_title,
    maxLength      = 4,
    inputType      = InputType.TYPE_CLASS_NUMBER,
    onBindEditText = { editText ->
        editText.setSelection(editText.text.length)   // cursor to end
        editText.addTextChangedListener(/* ... */)
    },
)

Action

Renders to a simple Preference that triggers an action when clicked.

action(
    key   = "my_action_key",
    title = R.string.my_action_title,
) { handler ->
    // handler gives access to device, context, etc.
    handler.context.startActivity(/* ... */)
    true
}

External Settings

Directs the user to a dedicated device-specific settings activity. The device is passed as an extra.

externalSettings(
    key           = "my_action_key",
    title         = R.string.my_title,
    activityClass = MySettingsActivity::class.java,
)

Sub-screen

Directs the user to a sub-screen, akin to a nested PreferenceScreen.

screen(
    key   = "pref_screen_audio",
    title = R.string.audio_settings,
    icon  = R.drawable.ic_music_note,
) {
    // nested primitives or components go here
    seekbar(key = DeviceSettingsPreferenceConst.PREF_VOLUME, /* ... */)
    equalizerPreset<MyEq>(defaultValue = MyEq.STANDARD)
}

Components

Components are extension functions on DeviceSettingsScope that encapsulate common setting patterns. They live in dsl/components/ and are the preferred building block for new coordinators.

enumList<T>

The enumList<T> is a list backed by a LabeledEntry enum. This is the simplest way to expose a typed enum as a list preference. Each constant's stored value is name.lowercase().

Step 1 - implement LabeledEntry on your enum:

enum class MySource(override val label: Int) : LabeledEntry {
    BLUETOOTH(R.string.source_bluetooth),
    AUX(R.string.source_aux),
    USB(R.string.source_usb),
}

Step 2 - use it in the DSL:

enumList<MySource>(
    key          = DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
    title        = R.string.media_source,
    icon         = R.drawable.ic_music_note,
    defaultValue = MySource.BLUETOOTH,
)

To show only a subset of the enum's values, pass a filter lambda:

enumList<MySource>(
    key          = "...",
    title        = R.string.media_source,
    defaultValue = MySource.BLUETOOTH,
    filter       = { it != MySource.USB },
)

equalizerPreset<T> - EQ preset list

Wraps enumList with the equalizer icon and default key. Supports the same filter parameter for per-mode preset subsets.

// Define the enum
enum class MyEq(override val label: Int, val modes: Set<MySource>) : LabeledEntry {
    STANDARD(R.string.eq_standard, setOf(MySource.BLUETOOTH, MySource.AUX)),
    BASS_BOOST(R.string.eq_bass, setOf(MySource.BLUETOOTH)),
    VOCAL(R.string.eq_vocal, setOf(MySource.AUX)),
}

// Declare two presets, one per source mode
equalizerPreset<MyEq>(
    key          = "pref_eq_bt",
    defaultValue = MyEq.STANDARD,
    filter       = { MySource.BLUETOOTH in it.modes },
    visibleWhen  = { prefs -> prefs.getString("pref_source", "") == "bluetooth" },
)
equalizerPreset<MyEq>(
    key          = "pref_eq_aux",
    defaultValue = MyEq.STANDARD,
    filter       = { MySource.AUX in it.modes },
    visibleWhen  = { prefs -> prefs.getString("pref_source", "") == "aux" },
)

volume - standard volume slider

volume(
    max          = 30,
    defaultValue = 15,
)

languages - device language picker

Pass the Language constants the device supports. The preference key is "language" and the stored value is the locale code (e.g. "zh_CN").

languages(Language.EN, Language.ZH, Language.JA, Language.KO)

deviceName - editable Bluetooth name

deviceName(maxLength = 10)

multipointPairing - opens the multipoint pairing screen

multipointPairing()

passwordScreen - enable/password sub-screen

passwordScreen(PasswordMode.NUMBERS_4_DIGITS_0_TO_9)

Available modes: NUMBERS_6, NUMBERS_4_DIGITS_0_TO_9, NUMBERS_4_DIGITS_1_TO_4, VISIBLE_NUMBERS_4_DIGITS_0_TO_9.

The component wires up input type, length enforcement, cursor positioning, the OK-button text watcher, and the dependency between the enable toggle and the text field automatically.


Visibility and connected-only

Every primitive and component accepts two parameters.

visibleWhen controls whether the preference row is shown at all. The predicate receives the current Prefs snapshot and is re-evaluated after any preference in the screen changes:

seekbar(
    key         = "pref_mic_gain",
    /* ... */
    visibleWhen = { prefs ->
        prefs.getString("pref_mic_mode", "") == "manual"
    },
)

connectedOnly (default true) disables the preference when the device is disconnected:

switchSetting(
    key           = "pref_startup_logo",
    /* ... */
    connectedOnly = false,   // allow the preference to be changed while disconnected
)

XML screens

A coordinator can mix DSL nodes with legacy XML screens. Use xmlScreen for screens not yet migrated:

override fun getDeviceSettings(device: GBDevice): DeviceSettingsSpec = deviceSettings {
    // Fully programmatic
    equalizerPreset<MyEq>(defaultValue = MyEq.STANDARD)

    // Legacy XML screen - rendered as before
    xmlScreen(
        DeviceSpecificSettingsScreen.TOUCH_OPTIONS,
        R.xml.devicesettings_my_device_controls,
        childConnectedKeys = listOf("pref_long_press_action"),
    )
    xmlScreen(
        DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS,
        R.xml.devicesettings_headphones,
        connectedOnly = false,
    )

    category(key = "pref_header_system", title = R.string.pref_header_system)
    // ...
}

childConnectedKeys lists preference keys inside the XML that should also be disabled when the device is disconnected.


Complete example

The ShokzCoordinator and SinilinkCoordinator serve as the reference implementations. A condensed example:

override fun getDeviceSettings(device: GBDevice): DeviceSettingsSpec = deviceSettings {
    multipointPairing()
    languages(Language.EN, Language.ZH, Language.JA, Language.KO)

    enumList<MyMediaSource>(
        key          = DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
        title        = R.string.media_source,
        icon         = R.drawable.ic_music_note,
        defaultValue = MyMediaSource.BLUETOOTH,
    )

    equalizerPreset<MyEq>(
        key          = "pref_eq_bt",
        defaultValue = MyEq.STANDARD,
        filter       = { MyMediaSource.BLUETOOTH in it.sources },
        visibleWhen  = { prefs ->
            MyMediaSource.fromPreference(
                prefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "")
            ) == MyMediaSource.BLUETOOTH
        },
    )

    volume(max = 30, defaultValue = 15)
    deviceName(maxLength = 16)
    passwordScreen(PasswordMode.NUMBERS_4_DIGITS_0_TO_9)

    xmlScreen(
        DeviceSpecificSettingsScreen.TOUCH_OPTIONS,
        R.xml.devicesettings_my_device_controls,
    )
}

Adding a new component

Components are plain extension functions on DeviceSettingsScope. A component is the right abstraction when:

  • The same combination of primitives appears in more than one coordinator, or
  • A setting has non-trivial wiring (dependencies, visibility, bind-text logic) that would clutter the coordinator.
// In dsl/components/MyComponents.kt
fun DeviceSettingsScope.myFeature(
    connectedOnly: Boolean = true,
    visibleWhen: ((Prefs) -> Boolean)? = null,
) {
    switchSetting(
        key           = "pref_my_feature_enabled",
        title         = R.string.my_feature,
        connectedOnly = connectedOnly,
        visibleWhen   = visibleWhen,
    )
    seekbar(
        key          = "pref_my_feature_intensity",
        title        = R.string.my_feature_intensity,
        min          = 1,
        max          = 10,
        defaultValue = 5,
        dependency   = "pref_my_feature_enabled",
        connectedOnly = connectedOnly,
        visibleWhen   = visibleWhen,
    )
}

Use it like any built-in component:

override fun getDeviceSettings(device: GBDevice): DeviceSettingsSpec = deviceSettings {
    myFeature()
}