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.
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¶
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").
deviceName - editable Bluetooth name¶
multipointPairing - opens the multipoint pairing screen¶
passwordScreen - enable/password sub-screen¶
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: