/* * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #define FILE_LOG_COLOR LOG_COLOR_BLUE #include "settings_bluetooth.h" #include "settings_menu.h" #include "settings_remote.h" #include "settings_window.h" #include "applib/app.h" #include "applib/app_focus_service.h" #include "applib/event_service_client.h" #include "applib/fonts/fonts.h" #include "applib/graphics/graphics.h" #include "applib/graphics/gtypes.h" #include "applib/ui/ui.h" #include "comm/bt_lock.h" #include "comm/ble/gap_le_connection.h" #include "comm/ble/gap_le_device_name.h" #include "drivers/rtc.h" #include "kernel/pbl_malloc.h" #include "kernel/ui/system_icons.h" #include "resource/resource_ids.auto.h" #include "services/common/analytics/analytics.h" #include "services/common/bluetooth/bluetooth_persistent_storage.h" #include "services/common/bluetooth/local_id.h" #include "services/common/bluetooth/pairability.h" #include "services/common/i18n/i18n.h" #include "services/common/system_task.h" #include "services/normal/bluetooth/ble_hrm.h" #include "system/logging.h" #include "system/passert.h" #include "util/string.h" #include #include #include #include #include #include #include #define HEADER_BUFFER_SIZE 22 #define SHARING_HEART_RATE_EXTRA_HEIGHT_PX (18) typedef enum SettingsBluetooth { SettingsBluetoothAirplaneMode, SettingsBluetoothTotal, } SettingsBluetooth; enum { BluetoothIconIdx, BluetoothAltIconIdx, AirplaneIconIdx, NumIcons, }; static const uint32_t ICON_RESOURCE_ID[NumIcons] = { RESOURCE_ID_SETTINGS_ICON_BLUETOOTH, RESOURCE_ID_SETTINGS_ICON_BLUETOOTH_ALT, RESOURCE_ID_SETTINGS_ICON_AIRPLANE, }; typedef enum { ToggleStateIdle, ToggleStateEnablingBluetooth, ToggleStateDisablingBluetooth, } ToggleState; typedef struct SettingsBluetoothData { SettingsCallbacks callbacks; GBitmap icon_heap_bitmap[NumIcons]; ListNode* remote_list_head; char header_buffer[HEADER_BUFFER_SIZE]; ToggleState toggle_state; bool did_enable_pairability; EventServiceInfo bt_airplane_event_info; EventServiceInfo bt_connection_event_info; EventServiceInfo bt_pairing_event_info; EventServiceInfo ble_device_name_updated_event_info; #if CAPABILITY_HAS_BUILTIN_HRM EventServiceInfo ble_hrm_sharing_event_info; #endif } SettingsBluetoothData; // BT stack interaction stuff /////////////////////////// static void settings_bluetooth_reconnect_once(void) { // After the user toggles BT back on, immediately attempt to reconnect once: if (bt_ctl_is_airplane_mode_on() == false) { bt_driver_reconnect_try_now(true /*ignore_paused*/); } } static void settings_bluetooth_toggle_airplane_mode(SettingsBluetoothData* data) { const bool airplane_mode = bt_ctl_is_airplane_mode_on(); bt_ctl_set_airplane_mode_async(!airplane_mode); data->toggle_state = airplane_mode ? ToggleStateEnablingBluetooth : ToggleStateDisablingBluetooth; settings_menu_mark_dirty(SettingsMenuItemBluetooth); } bool is_remote_connected(StoredRemote* remote) { switch (remote->type) { case StoredRemoteTypeBTClassic: return remote->classic.connected; case StoredRemoteTypeBLE: return (remote->ble.connection != NULL); case StoredRemoteTypeBTDual: return remote->dual.classic.connected || (remote->dual.ble.connection != NULL); default: WTF; } return false; } static int remote_comparator(StoredRemote* remote, StoredRemote* other) { if (is_remote_connected(remote) != is_remote_connected(other)) { return is_remote_connected(remote) ? -1 : 1; } else { return strncmp(remote->name, other->name, sizeof(remote->name)); } } static void add_remote(SettingsBluetoothData* data, StoredRemote* remote) { const bool ascending = false; data->remote_list_head = list_sorted_add(data->remote_list_head, &remote->list_node, (Comparator) remote_comparator, ascending); } static StoredRemote* stored_remote_create(void) { StoredRemote* remote = task_malloc_check(sizeof(*remote)); *remote = (StoredRemote){}; return remote; } static void prv_copy_device_name_with_fallback(StoredRemote *remote, const char *name) { if (!name || strlen(name) == 0) { i18n_get_with_buffer("", remote->name, sizeof(remote->name)); } else { strncpy(remote->name, name, sizeof(remote->name)); } } static void prv_add_bt_classic_remote(BTDeviceAddress *addr, SM128BitKey *link_key, const char *name, uint8_t *platform_bits, void *context) { SettingsBluetoothData *data = (SettingsBluetoothData*) context; if (!data) { return; } // Determine the address of our active remote, if we have one. BTDeviceAddress active_addr = {}; const bool is_connected = bt_driver_classic_copy_connected_address(&active_addr); // Create the new remote StoredRemote *remote = stored_remote_create(); remote->classic.bd_addr = *addr; prv_copy_device_name_with_fallback(remote, name); if (is_connected && (0 == memcmp(addr, &active_addr, sizeof(*addr)))) { remote->classic.connected = true; } else { remote->classic.connected = false; } add_remote(data, remote); } static void prv_add_bt_classic_remotes(SettingsBluetoothData *data) { bt_persistent_storage_for_each_bt_classic_pairing(prv_add_bt_classic_remote, data); } static bool dual_remote_filter(ListNode *node, void *data) { StoredRemote *classic_remote = (StoredRemote *) node; BTDeviceInternal *device = (BTDeviceInternal *) data; BTDeviceInternal le_device_with_classic_address = (const BTDeviceInternal) { .address = classic_remote->classic.bd_addr, .is_random_address = false, }; return bt_device_equal(&le_device_with_classic_address.opaque, &device->opaque); } static void prv_add_and_merge_ble_remote(BTDeviceInternal *device, SMIdentityResolvingKey *irk, const char *name, BTBondingID *id, void *context) { SettingsBluetoothData *data = (SettingsBluetoothData*) context; if (!data) { return; } StoredRemote* remote = (StoredRemote*) list_find_next(data->remote_list_head, dual_remote_filter, true, device); if (remote) { // The remote is also a ble device, promote to a dual remote const bool classic_connected = remote->classic.connected; remote->type = StoredRemoteTypeBTDual; remote->dual.classic.connected = classic_connected; // Note: We update remote->dual.ble.connected outside this cb remote->dual.ble.bonding = *id; } else { // Remote for which we only have a BLE key, add it in the menu as well, so it is accessible // and can be removed by the user: StoredRemote* remote = stored_remote_create(); remote->type = StoredRemoteTypeBLE; // Note: We update remote->ble.connection outside this cb remote->ble.bonding = *id; prv_copy_device_name_with_fallback(remote, name); add_remote(data, remote); } } //! This must be called after updating classic remotes for remote consolidation static void prv_add_and_merge_ble_remotes(SettingsBluetoothData *data) { bt_persistent_storage_for_each_ble_pairing(prv_add_and_merge_ble_remote, data); StoredRemote *remote = (StoredRemote *)data->remote_list_head; while (remote) { StoredRemoteBLE *ble_rem = NULL; if (remote->type == StoredRemoteTypeBLE) { ble_rem = &remote->ble; } else if (remote->type == StoredRemoteTypeBTDual) { ble_rem = &remote->dual.ble; } if (ble_rem) { SMIdentityResolvingKey irk; BTDeviceInternal device; if (bt_persistent_storage_get_ble_pairing_by_id(ble_rem->bonding, &irk, &device, NULL)) { bt_lock(); GAPLEConnection *connection = gap_le_connection_find_by_irk(&irk); if (!connection) { connection = gap_le_connection_by_device(&device); } ble_rem->connection = connection; #if CAPABILITY_HAS_BUILTIN_HRM ble_rem->is_sharing_heart_rate = ble_hrm_is_sharing_to_connection(connection); #endif bt_unlock(); } } remote = (StoredRemote *)remote->list_node.next; } } static void prv_clear_remote_list(SettingsBluetoothData* data) { while (data->remote_list_head) { StoredRemote* remote = (StoredRemote*) data->remote_list_head; data->remote_list_head = list_pop_head(&remote->list_node); task_free(remote); } } static void prv_reload_remote_list(SettingsBluetoothData* data) { prv_clear_remote_list(data); prv_add_bt_classic_remotes(data); prv_add_and_merge_ble_remotes(data); } static void settings_bluetooth_update_remotes_private(SettingsBluetoothData* data) { prv_reload_remote_list(data); if (!data->remote_list_head) { strncpy(data->header_buffer, i18n_get("Pairing Instructions", data), HEADER_BUFFER_SIZE); } else { const unsigned int num_remotes = list_count(data->remote_list_head); sniprintf(data->header_buffer, HEADER_BUFFER_SIZE, (num_remotes != 1) ? i18n_get("%u Paired Phones", data) : i18n_get("%u Paired Phone", data), num_remotes); } } void settings_bluetooth_update_remotes(SettingsBluetoothData *data) { settings_bluetooth_update_remotes_private(data); settings_menu_reload_data(SettingsMenuItemBluetooth); } ////////// static void prv_settings_bluetooth_event_handler(PebbleEvent *event, void *context) { SettingsBluetoothData* settings_data = (SettingsBluetoothData *) context; PBL_LOG_COLOR(LOG_LEVEL_DEBUG, LOG_COLOR_BLUE, "BT EVENT"); switch (event->type) { case PEBBLE_BT_CONNECTION_EVENT: // If BT Settings is open, update BLE device name upon connecting device: if (event->bluetooth.connection.is_ble && event->bluetooth.connection.state == PebbleBluetoothConnectionEventStateConnected) { // https://pebbletechnology.atlassian.net/browse/PBL-22176 // iOS seems to respond with 0x0E (Unlikely Error) when performing this request while // the encryption set up is going on. For non-bonded devices it will work fine though. gap_le_device_name_request(&event->bluetooth.connection.device); } // fall-through! case PEBBLE_BT_PAIRING_EVENT: #if CAPABILITY_HAS_BUILTIN_HRM case PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT: #endif case PEBBLE_BLE_DEVICE_NAME_UPDATED_EVENT: { bool had_remotes = (settings_data->remote_list_head != NULL); settings_bluetooth_update_remotes_private(settings_data); bool has_remotes = (settings_data->remote_list_head != NULL); // Handle single phone pairing policy: enable/disable advertising based on pairing state if (had_remotes && !has_remotes) { // Device was removed, enable advertising if (!settings_data->did_enable_pairability) { bt_pairability_use(); settings_data->did_enable_pairability = true; PBL_LOG(LOG_LEVEL_INFO, "Enabled advertising - no paired devices"); } } else if (!had_remotes && has_remotes) { // Device was added, disable advertising if (settings_data->did_enable_pairability) { bt_pairability_release(); settings_data->did_enable_pairability = false; PBL_LOG(LOG_LEVEL_INFO, "Disabled advertising - device paired"); } } settings_menu_mark_dirty(SettingsMenuItemBluetooth); break; } case PEBBLE_BT_STATE_EVENT: { settings_bluetooth_reconnect_once(); settings_data->toggle_state = ToggleStateIdle; settings_menu_mark_dirty(SettingsMenuItemBluetooth); break; } default: break; } } // UI Stuff ///////////////////////////// // Menu Layer Callbacks ///////////////////////////// //-- Address // ... //| Airplane Mode: Off //-- Paired Devices //| Device Name // Connected //| Device Name // static void prv_draw_stored_remote_item_rect(GContext *ctx, const Layer *cell_layer, const char *remote_name, const char *connected_string, const char *le_string, const char *is_sharing_heart_rate_string) { const GFont font = ((le_string || is_sharing_heart_rate_string) ? fonts_get_system_font(FONT_KEY_GOTHIC_18) : NULL); if (le_string) { GRect box = cell_layer->bounds; box.size.w -= 5; box.origin.y += 20; box.size.h = 24; graphics_draw_text(ctx, le_string, font, box, GTextOverflowModeFill, GTextAlignmentRight, NULL); } if (is_sharing_heart_rate_string) { const int horizontal_margin = menu_cell_basic_horizontal_inset(); GRect box = grect_inset(cell_layer->bounds, GEdgeInsets(0, horizontal_margin)); box.origin.y += 38; box.size.h = 24; graphics_draw_text(ctx, is_sharing_heart_rate_string, font, box, GTextOverflowModeFill, GTextAlignmentLeft, NULL); // Gross hack to avoid centering the title / subtitle labels in the entire cell: ((Layer *)cell_layer)->bounds.size.h -= SHARING_HEART_RATE_EXTRA_HEIGHT_PX; } menu_cell_basic_draw(ctx, cell_layer, remote_name, connected_string, NULL); if (is_sharing_heart_rate_string) { // Restore original height: ((Layer *)cell_layer)->bounds.size.h += SHARING_HEART_RATE_EXTRA_HEIGHT_PX; } } bool settings_bluetooth_is_sharing_heart_rate_for_stored_remote(StoredRemote* remote) { #if CAPABILITY_HAS_BUILTIN_HRM switch (remote->type) { case StoredRemoteTypeBLE: return remote->ble.is_sharing_heart_rate; case StoredRemoteTypeBTDual: return remote->dual.ble.is_sharing_heart_rate; default: return false; } #else return false; #endif // CAPABILITY_HAS_BUILTIN_HRM } #if PBL_ROUND static void prv_draw_stored_remote_item_round(GContext *ctx, const Layer *cell_layer, const char *remote_name, const char *connected_string, const char *le_string, const char *is_sharing_heart_rate_string) { # if CAPABILITY_HAS_BUILTIN_HRM _Static_assert(false, "FIXME: Implement round drawing code to show heart rate sharing status!"); # endif // CAPABILITY_HAS_BUILTIN_HRM menu_cell_basic_draw(ctx, cell_layer, remote_name, connected_string, NULL); } #endif // PBL_ROUND static void draw_stored_remote_item(GContext *ctx, const Layer *cell_layer, uint16_t device_index, SettingsBluetoothData *data) { const uint32_t num_remotes = list_count(data->remote_list_head); PBL_ASSERT(device_index < num_remotes, "Got index %" PRId16 " only have %" PRId32, device_index, num_remotes); StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, device_index); bool connected = is_remote_connected(remote); const char *le_string = NULL; if (remote->type == StoredRemoteTypeBTDual && remote->dual.classic.connected != (remote->dual.ble.connection != NULL)) { le_string = remote->dual.classic.connected ? i18n_get("No LE", data) : i18n_get("LE Only", data); } const char *connected_string = connected ? i18n_get("Connected", data) : PBL_IF_RECT_ELSE("", NULL); // Add ellipsis if the name might have been cut off by the mobile const char ellipsis[] = UTF8_ELLIPSIS_STRING; const size_t max_name_size = BT_DEVICE_NAME_BUFFER_SIZE - 2; const size_t name_size = strnlen(remote->name, BT_DEVICE_NAME_BUFFER_SIZE); char *remote_name = task_zalloc_check(max_name_size + sizeof(ellipsis)); strncpy(remote_name, remote->name, name_size); if (name_size > max_name_size) { const size_t ellipsis_start_offset = utf8_get_size_truncate(remote_name, name_size); strncpy(&remote_name[ellipsis_start_offset], ellipsis, sizeof(ellipsis)); } const char *is_sharing_heart_rate = (settings_bluetooth_is_sharing_heart_rate_for_stored_remote(remote) ? i18n_get("Sharing Heart Rate ❤", data) : NULL); PBL_IF_RECT_ELSE(prv_draw_stored_remote_item_rect, prv_draw_stored_remote_item_round)(ctx, cell_layer, remote_name, connected_string, le_string, is_sharing_heart_rate); task_free(remote_name); } static uint16_t prv_num_rows_cb(SettingsCallbacks *context) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; return list_count(data->remote_list_head) + 1; } static int16_t prv_row_height_cb(SettingsCallbacks *context, uint16_t row, bool is_selected) { #if PBL_RECT # if CAPABILITY_HAS_BUILTIN_HRM int heart_rate_sharing_text_height = 0; if (row > 0) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; const uint16_t device_index = row - 1; StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, device_index); if (settings_bluetooth_is_sharing_heart_rate_for_stored_remote(remote)) { heart_rate_sharing_text_height = SHARING_HEART_RATE_EXTRA_HEIGHT_PX; } } # else const int heart_rate_sharing_text_height = 0; # endif // CAPABILITY_HAS_BUILTIN_HRM return menu_cell_basic_cell_height() + heart_rate_sharing_text_height; #elif PBL_ROUND return (is_selected ? MENU_CELL_ROUND_FOCUSED_TALL_CELL_HEIGHT : MENU_CELL_ROUND_UNFOCUSED_SHORT_CELL_HEIGHT); #else #endif } static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx, const Layer *cell_layer, uint16_t row, bool selected) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; if (row == 0) { char device_name_buffer[BT_DEVICE_NAME_BUFFER_SIZE]; const char *subtitle = NULL; const char *title = i18n_get("Connection", data); GBitmap *icon = NULL; if (data->toggle_state == ToggleStateIdle) { if (bt_ctl_is_airplane_mode_on()) { subtitle = i18n_get("Airplane Mode", data); icon = &data->icon_heap_bitmap[AirplaneIconIdx]; } else { // Always show device name as subtitle bt_local_id_copy_device_name(device_name_buffer, false); subtitle = device_name_buffer; icon = &data->icon_heap_bitmap[BluetoothIconIdx]; } } else { subtitle = (data->toggle_state == ToggleStateDisablingBluetooth) ? i18n_get("Disabling...", data) : i18n_get("Enabling...", data); icon = &data->icon_heap_bitmap[BluetoothAltIconIdx]; } menu_cell_basic_draw(ctx, cell_layer, title, subtitle, icon); // TODO PBL-23111: Decide how we should show these strings on round displays #if PBL_RECT // Hack: the pairing instruction is drawn in the cell callback, but outside of the cell... const GDrawState draw_state = ctx->draw_state; // Enable drawing outside of the cell: ctx->draw_state.clip_box = ctx->dest_bitmap.bounds; graphics_context_set_text_color(ctx, GColorBlack); GFont font = fonts_get_system_font(FONT_KEY_GOTHIC_18); GRect box = cell_layer->bounds; box.origin.x = 15; box.origin.y = menu_cell_basic_cell_height() + (int16_t)9; box.size.w -= 30; box.size.h = 83; if (!data->remote_list_head) { if (bt_ctl_is_airplane_mode_on()) { graphics_draw_text(ctx, i18n_get("Disable Airplane Mode to connect.", data), font, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); } else { graphics_draw_text(ctx, i18n_get("Open the Pebble app on your phone to connect.", data), font, box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); } } else { // Show message when any phone is paired (even if disconnected) // Position the message lower to appear below the paired phone row GRect msg_box = box; msg_box.origin.y += menu_cell_basic_cell_height() - 10; graphics_draw_text(ctx, i18n_get("Forget this device to pair a new device.", data), font, msg_box, GTextOverflowModeTrailingEllipsis, GTextAlignmentCenter, NULL); } ctx->draw_state = draw_state; #endif } else { const uint16_t device_index = row - 1; draw_stored_remote_item(ctx, cell_layer, device_index, data); } } static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; if (row == 0) { settings_bluetooth_toggle_airplane_mode(data); return; } if (!data->remote_list_head) { return; } prv_reload_remote_list(data); StoredRemote* remote = (StoredRemote*) list_get_at(data->remote_list_head, row - 1); settings_remote_menu_push(data, remote); } static void prv_focus_handler(bool in_focus) { if (!in_focus) { return; } settings_menu_reload_data(SettingsMenuItemBluetooth); } static void prv_expand_cb(SettingsCallbacks *context) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; settings_bluetooth_update_remotes_private(data); // When entering the BT Settings, update device names of all connected devices: if (!bt_ctl_is_airplane_mode_on()) { gap_le_device_name_request_all(); } data->bt_airplane_event_info = (EventServiceInfo) { .type = PEBBLE_BT_STATE_EVENT, .handler = prv_settings_bluetooth_event_handler, .context = data, }; data->bt_connection_event_info = (EventServiceInfo) { .type = PEBBLE_BT_CONNECTION_EVENT, .handler = prv_settings_bluetooth_event_handler, .context = data, }; data->bt_pairing_event_info = (EventServiceInfo) { .type = PEBBLE_BT_PAIRING_EVENT, .handler = prv_settings_bluetooth_event_handler, .context = data, }; data->ble_device_name_updated_event_info = (EventServiceInfo) { .type = PEBBLE_BLE_DEVICE_NAME_UPDATED_EVENT, .handler = prv_settings_bluetooth_event_handler, .context = data, }; #if CAPABILITY_HAS_BUILTIN_HRM data->ble_hrm_sharing_event_info = (EventServiceInfo) { .type = PEBBLE_BLE_HRM_SHARING_STATE_UPDATED_EVENT, .handler = prv_settings_bluetooth_event_handler, .context = data, }; event_service_client_subscribe(&data->ble_hrm_sharing_event_info); #endif event_service_client_subscribe(&data->bt_airplane_event_info); event_service_client_subscribe(&data->bt_connection_event_info); event_service_client_subscribe(&data->bt_pairing_event_info); event_service_client_subscribe(&data->ble_device_name_updated_event_info); // Only enable pairing/advertising if there are no paired devices (single phone policy) data->did_enable_pairability = false; if (!data->remote_list_head) { bt_pairability_use(); data->did_enable_pairability = true; } bt_driver_reconnect_pause(); // Reload & redraw after pairing popup app_focus_service_subscribe_handlers((AppFocusHandlers) { .did_focus = prv_focus_handler }); } // Turns off services that are part of the bluetooth settings menu such as enabling // discovery. We don't want to keep these services running longer than necessary because // they consume a fair amount of power static void prv_hide_cb(SettingsCallbacks *context) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; // Only release pairability if we enabled it if (data->did_enable_pairability) { bt_pairability_release(); } bt_driver_reconnect_resume(); bt_driver_reconnect_reset_interval(); bt_driver_reconnect_try_now(false /*ignore_paused*/); #if CAPABILITY_HAS_BUILTIN_HRM event_service_client_unsubscribe(&data->ble_hrm_sharing_event_info); #endif event_service_client_unsubscribe(&data->bt_airplane_event_info); event_service_client_unsubscribe(&data->bt_connection_event_info); event_service_client_unsubscribe(&data->bt_pairing_event_info); event_service_client_unsubscribe(&data->ble_device_name_updated_event_info); app_focus_service_unsubscribe(); } static void prv_deinit_cb(SettingsCallbacks *context) { SettingsBluetoothData *data = (SettingsBluetoothData *) context; i18n_free_all(data); prv_clear_remote_list(data); for (unsigned int idx = 0; idx < NumIcons; ++idx) { gbitmap_deinit(&data->icon_heap_bitmap[idx]); } app_free(data); } static Window *prv_init(void) { SettingsBluetoothData *data = app_malloc_check(sizeof(SettingsBluetoothData)); *data = (SettingsBluetoothData){}; for (unsigned int idx = 0; idx < NumIcons; ++idx) { gbitmap_init_with_resource(&data->icon_heap_bitmap[idx], ICON_RESOURCE_ID[idx]); } data->callbacks = (SettingsCallbacks) { .deinit = prv_deinit_cb, .draw_row = prv_draw_row_cb, .select_click = prv_select_click_cb, .num_rows = prv_num_rows_cb, .row_height = prv_row_height_cb, .expand = prv_expand_cb, .hide = prv_hide_cb, }; return settings_window_create(SettingsMenuItemBluetooth, &data->callbacks); } const SettingsModuleMetadata *settings_bluetooth_get_info(void) { static const SettingsModuleMetadata s_module_info = { .name = i18n_noop("Bluetooth"), .init = prv_init, }; return &s_module_info; } #undef HEADER_BUFFER_SIZE