From 93d5e08f23169dd9164b08986235d52caf23d584 Mon Sep 17 00:00:00 2001 From: Kris Kersey Date: Fri, 17 Apr 2026 02:48:07 +0000 Subject: [PATCH 1/2] Add HUD notification system for phone calls, SMS, and image display Config-driven notification popups with fade in/out, auto-dismiss, and contact photo display from base64 MQTT payloads. - Notification state machine (HIDDEN/SHOWING/VISIBLE/COMPACT/HIDING) with two independent slots (phone + image) - 6 dynamic text sources: CALLER_NAME, CALLER_NUMBER, CALL_STATUS, SMS_PREVIEW, NOTIFICATION_TITLE, NOTIFICATION_SOURCE - notification_photo special element with lazy-loaded placeholder - notification_group integer enum resolved at parse time (O(1) render) - Mutex-protected phone state (MQTT writer, render thread reader) - Base64 photo decode via OpenSSL BIO, texture on render thread - Ring event resets incoming_call TTL (keeps notification alive) - 9 config.json elements (phone bg, photo, 4 labels, image bg, 2 labels) - Notification panel PNG assets (angular beveled, Iron Man aesthetic) - Added new original SVG directory. --- CMakeLists.txt | 1 + config.json | 105 ++++ include/config/config_parser.h | 10 + include/ui/notification.h | 216 +++++++ src/comm/command_processing.c | 11 + src/config/config_parser.c | 16 + src/core/mirage.c | 5 + src/rendering/element_renderer.c | 74 +++ src/ui/notification.c | 568 ++++++++++++++++++ ui_assets/mk2/contact-placeholder.png | Bin 0 -> 3319 bytes ui_assets/mk2/notification-image-bg.png | Bin 0 -> 4323 bytes ui_assets/mk2/notification-phone-bg.png | Bin 0 -> 3388 bytes ui_assets/svg-src/contact_placeholder_v2.svg | 58 ++ .../svg-src/notification_image_bg_v2.svg | 58 ++ .../svg-src/notification_phone_bg_v6.svg | 58 ++ 15 files changed, 1180 insertions(+) create mode 100644 include/ui/notification.h create mode 100644 src/ui/notification.c create mode 100644 ui_assets/mk2/contact-placeholder.png create mode 100644 ui_assets/mk2/notification-image-bg.png create mode 100644 ui_assets/mk2/notification-phone-bg.png create mode 100644 ui_assets/svg-src/contact_placeholder_v2.svg create mode 100644 ui_assets/svg-src/notification_image_bg_v2.svg create mode 100644 ui_assets/svg-src/notification_phone_bg_v6.svg diff --git a/CMakeLists.txt b/CMakeLists.txt index 9508723..b885675 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,6 +96,7 @@ set(SOURCE_FILES # ui src/ui/hud_manager.c + src/ui/notification.c # comm src/comm/command_processing.c diff --git a/config.json b/config.json index c3f2294..d69f0eb 100644 --- a/config.json +++ b/config.json @@ -408,6 +408,111 @@ "layer": 1, "huds": ["default", "environmental", "armor"] }, + { + "type": "static", + "name": "phone_notif_bg", + "file": "notification-phone-bg.png", + "dest_x": 880, + "dest_y": 80, + "layer": 10, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "special", + "special_name": "notification_photo", + "name": "phone_notif_photo", + "file": "contact-placeholder.png", + "dest_x": 900, + "dest_y": 138, + "width": 128, + "height": 128, + "layer": 11, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "text", + "string": "*CALL_STATUS*", + "font": "Aldrich-Regular.ttf", + "size": 22, + "color": "0x02, 0xDF, 0xF1, 0xFF", + "dest_x": 1048, + "dest_y": 130, + "layer": 11, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "text", + "string": "*CALLER_NAME*", + "font": "Aldrich-Regular.ttf", + "size": 34, + "color": "0xFF, 0xFF, 0xFF, 0xFF", + "dest_x": 1048, + "dest_y": 160, + "layer": 11, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "text", + "string": "*CALLER_NUMBER*", + "font": "Aldrich-Regular.ttf", + "size": 22, + "color": "0x5A, 0x8A, 0x8D, 0xFF", + "dest_x": 1048, + "dest_y": 200, + "layer": 11, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "text", + "string": "*SMS_PREVIEW*", + "font": "Aldrich-Regular.ttf", + "size": 20, + "color": "0xFF, 0xFF, 0xFF, 0xFF", + "dest_x": 1048, + "dest_y": 230, + "layer": 11, + "huds": ["default", "environmental", "armor"], + "notification_group": "phone" + }, + { + "type": "static", + "name": "image_notif_bg", + "file": "notification-image-bg.png", + "dest_x": 420, + "dest_y": 250, + "layer": 10, + "huds": ["default"], + "notification_group": "image" + }, + { + "type": "text", + "string": "*NOTIFICATION_TITLE*", + "font": "Aldrich-Regular.ttf", + "size": 22, + "color": "0xFF, 0xFF, 0xFF, 0xFF", + "dest_x": 440, + "dest_y": 680, + "layer": 11, + "huds": ["default"], + "notification_group": "image" + }, + { + "type": "text", + "string": "*NOTIFICATION_SOURCE*", + "font": "Aldrich-Regular.ttf", + "size": 16, + "color": "0x5A, 0x8A, 0x8D, 0xFF", + "dest_x": 440, + "dest_y": 710, + "layer": 11, + "huds": ["default"], + "notification_group": "image" + }, { "type": "special", "name": "armor_display", diff --git a/include/config/config_parser.h b/include/config/config_parser.h index 2453d8a..54da941 100644 --- a/include/config/config_parser.h +++ b/include/config/config_parser.h @@ -141,6 +141,13 @@ typedef enum { TEXT_SOURCE_COMPASS, TEXT_SOURCE_LOG, TEXT_SOURCE_ALERT, + /* Notification text sources */ + TEXT_SOURCE_CALLER_NAME, + TEXT_SOURCE_CALLER_NUMBER, + TEXT_SOURCE_CALL_STATUS, + TEXT_SOURCE_SMS_PREVIEW, + TEXT_SOURCE_NOTIFICATION_TITLE, + TEXT_SOURCE_NOTIFICATION_SOURCE, TEXT_SOURCE_COUNT /* Always last */ } text_source_t; @@ -305,6 +312,9 @@ typedef struct _element { int gauge_value_label_width; /* Cached label dimensions */ int gauge_value_label_height; + /* Notification group — links element to a notification slot (resolved at parse time) */ + int notification_group; /* notif_group_t enum from notification.h */ + /* Transition state - used for fade/zoom effects */ float transition_alpha; int in_transition; diff --git a/include/ui/notification.h b/include/ui/notification.h new file mode 100644 index 0000000..4800856 --- /dev/null +++ b/include/ui/notification.h @@ -0,0 +1,216 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * By contributing to this project, you agree to license your contributions + * under the GPLv3 (or any later version) or any future licenses chosen by + * the project author(s). Contributions include any modifications, + * enhancements, or additions to the project. These contributions become + * part of the project and are adopted by the project author(s). + * + * Notification system for HUD phone/SMS/image popups. + * Config-driven: elements link to slots via notification_group field. + */ + +#ifndef NOTIFICATION_H +#define NOTIFICATION_H + +#include +#include +#include +#include +#include + +/* ============================================================================= + * Constants + * ============================================================================= */ + +#define NOTIF_MAX_NAME 64 +#define NOTIF_MAX_NUMBER 24 +#define NOTIF_MAX_PREVIEW 128 +#define NOTIF_MAX_TITLE 128 +#define NOTIF_MAX_SOURCE 64 + +/* Notification group IDs — resolved once at config parse time for O(1) render dispatch */ +typedef enum { + NOTIF_GROUP_NONE = 0, + NOTIF_GROUP_PHONE, + NOTIF_GROUP_IMAGE, +} notif_group_t; + +/** + * @brief Resolve a notification_group string to an enum ID. + * @param group Group name from config.json ("phone", "image") + * @return Enum ID, or NOTIF_GROUP_NONE if not recognized + */ +notif_group_t notif_group_resolve(const char *group); + +/* Timing (milliseconds) */ +#define NOTIF_FADE_IN_PHONE_MS 150 +#define NOTIF_FADE_IN_IMAGE_MS 250 +#define NOTIF_FADE_OUT_MS 250 +#define NOTIF_COMPACT_AFTER_MS 5000 /* Collapse active call to single line */ +#define NOTIF_CALL_ENDED_TTL_MS 3000 +#define NOTIF_SMS_TTL_MS 15000 +#define NOTIF_IMAGE_TTL_MS 30000 + +/* ============================================================================= + * Types + * ============================================================================= */ + +typedef enum { + NOTIF_STATE_HIDDEN = 0, + NOTIF_STATE_SHOWING, /* Fading in */ + NOTIF_STATE_VISIBLE, /* Full display, timeout counting */ + NOTIF_STATE_COMPACT, /* Phone only — collapsed single-line timer */ + NOTIF_STATE_HIDING, /* Fading out */ +} notif_state_t; + +typedef enum { + NOTIF_EVENT_NONE = 0, + NOTIF_EVENT_INCOMING_CALL, + NOTIF_EVENT_CALL_ACTIVE, + NOTIF_EVENT_CALL_ENDED, + NOTIF_EVENT_SMS_RECEIVED, + NOTIF_EVENT_IMAGE_DISPLAY, +} notif_event_t; + +/* Phone notification slot */ +typedef struct { + notif_state_t state; + notif_event_t event; + char caller_name[NOTIF_MAX_NAME]; + char caller_number[NOTIF_MAX_NUMBER]; + char call_status[NOTIF_MAX_NAME]; /* "INCOMING CALL", "CALL ACTIVE 03:45", etc. */ + char sms_preview[NOTIF_MAX_PREVIEW]; + uint32_t start_time_ms; /* When notification was triggered */ + uint32_t ttl_ms; /* Time-to-live from start */ + uint32_t fade_start_ms; /* When current fade began */ + uint32_t fade_duration_ms; + int call_duration_sec; /* For active call timer */ + float alpha; /* 0.0 = hidden, 1.0 = fully visible */ + + /* Contact photo (decoded from base64 MQTT payload) */ + unsigned char *photo_data; /* Raw image bytes (malloc'd) */ + size_t photo_data_size; + bool photo_dirty; /* Set by MQTT thread, consumed by render thread */ + SDL_Texture *photo_texture; /* Created on render thread */ + pthread_mutex_t mutex; /* Protects all fields (MQTT writer, render reader) */ +} notif_phone_t; + +/* Image notification slot */ +typedef struct { + notif_state_t state; + char title[NOTIF_MAX_TITLE]; + char source[NOTIF_MAX_SOURCE]; + char image_url[256]; /* /api/images/img_xxxxxxxxxxxx */ + uint32_t start_time_ms; + uint32_t ttl_ms; + uint32_t fade_start_ms; + uint32_t fade_duration_ms; + float alpha; + + /* Image data (fetched via HTTP) */ + unsigned char *image_data; + size_t image_data_size; + bool image_dirty; + SDL_Texture *image_texture; + pthread_mutex_t image_mutex; +} notif_image_t; + +/* ============================================================================= + * Public API + * ============================================================================= */ + +/** + * @brief Initialize the notification system. + */ +void notification_init(void); + +/** + * @brief Shut down and free resources. + */ +void notification_shutdown(void); + +/** + * @brief Handle a phone event from MQTT (incoming_call, call_active, etc.) + * + * Called from MQTT callback thread. Thread-safe. + * + * @param root Parsed JSON object (not consumed — caller frees) + */ +void notification_handle_phone_event(struct json_object *root); + +/** + * @brief Handle an image display request from MQTT. + * + * Called from MQTT callback thread. Thread-safe. + * + * @param root Parsed JSON object (not consumed — caller frees) + */ +void notification_handle_image_request(struct json_object *root); + +/** + * @brief Update notification timers and state transitions. + * + * Called every frame from the render thread. + */ +void notification_update(void); + +/** + * @brief Check if elements in a notification group should be visible. + * + * @param group Notification group name ("phone" or "image") + * @return Current alpha (0.0 = hidden, 1.0 = visible) + */ +float notification_get_alpha(int group); + +/** + * @brief Check if a notification group is active (any state except HIDDEN). + * + * @param group Notification group name + * @return true if active + */ +bool notification_is_active(int group); + +/** + * @brief Get current dynamic text for notification text sources. + * + * @param source_id Text source enum value + * @param out Output buffer + * @param out_size Size of output buffer + */ +void notification_get_text(int source_id, char *out, size_t out_size); + +/** + * @brief Get the contact photo texture (or NULL if none). + * + * Must be called from the render thread. Creates SDL texture from + * decoded photo data if dirty flag is set. + * + * @param renderer SDL renderer for texture creation + * @return SDL_Texture pointer or NULL + */ +SDL_Texture *notification_get_photo_texture(SDL_Renderer *renderer); + +/** + * @brief Get the phone notification slot (read-only access for renderer). + */ +const notif_phone_t *notification_get_phone(void); + +/** + * @brief Get the image notification slot (read-only access for renderer). + */ +const notif_image_t *notification_get_image(void); + +#endif /* NOTIFICATION_H */ diff --git a/src/comm/command_processing.c b/src/comm/command_processing.c index 0748a1f..25489da 100644 --- a/src/comm/command_processing.c +++ b/src/comm/command_processing.c @@ -44,6 +44,7 @@ #include "hardware/system_metrics.h" #include "media/screenshot.h" #include "ui/hud_manager.h" +#include "ui/notification.h" #include "util/logging.h" #include "util/string_utils.h" @@ -909,6 +910,16 @@ int parse_json_command(char *command_string, char *topic) { } } + /* Phone notification events (from DAWN phone_service via HUD topic) */ + if (tmpstr != NULL && strcmp(tmpstr, "phone") == 0) { + notification_handle_phone_event(parsed_json); + } + + /* Image display requests (from DAWN image_search_tool via HUD topic) */ + if (tmpstr != NULL && strcmp(tmpstr, "image") == 0) { + notification_handle_image_request(parsed_json); + } + /* Check for armor device match in topic */ if (topic != NULL) { element *current_armor_element = armor_element; diff --git a/src/config/config_parser.c b/src/config/config_parser.c index 41aac11..8884c69 100644 --- a/src/config/config_parser.c +++ b/src/config/config_parser.c @@ -30,6 +30,7 @@ #include "config/config_manager.h" #include "core/mirage.h" #include "ui/hud_manager.h" +#include "ui/notification.h" #include "util/logging.h" /* Map type string representations */ @@ -90,6 +91,13 @@ static const struct { { "*COMPASS*", TEXT_SOURCE_COMPASS }, { "*LOG*", TEXT_SOURCE_LOG }, { "*ALERT*", TEXT_SOURCE_ALERT }, + /* Notification text sources */ + { "*CALLER_NAME*", TEXT_SOURCE_CALLER_NAME }, + { "*CALLER_NUMBER*", TEXT_SOURCE_CALLER_NUMBER }, + { "*CALL_STATUS*", TEXT_SOURCE_CALL_STATUS }, + { "*SMS_PREVIEW*", TEXT_SOURCE_SMS_PREVIEW }, + { "*NOTIFICATION_TITLE*", TEXT_SOURCE_NOTIFICATION_TITLE }, + { "*NOTIFICATION_SOURCE*", TEXT_SOURCE_NOTIFICATION_SOURCE }, }; text_source_t resolve_text_source(const char *text) { @@ -560,6 +568,14 @@ static int parse_common_element_properties(struct json_object *element_obj, elem } } + /* Parse notification group (resolved to enum for O(1) render dispatch) */ + if (json_object_object_get_ex(element_obj, "notification_group", &tmpobj)) { + tmpstr_ptr = json_object_get_string(tmpobj); + if (tmpstr_ptr != NULL) { + curr_element->notification_group = (int)notif_group_resolve(tmpstr_ptr); + } + } + /* Parse HUD associations */ json_object_object_get_ex(element_obj, "huds", &tmpobj); if (tmpobj != NULL && json_object_get_type(tmpobj) == json_type_array) { diff --git a/src/core/mirage.c b/src/core/mirage.c index 2002e96..ae0c8d8 100644 --- a/src/core/mirage.c +++ b/src/core/mirage.c @@ -115,6 +115,7 @@ #include "media/screenshot.h" #include "rendering/element_renderer.h" #include "ui/hud_manager.h" +#include "ui/notification.h" #include "util/curl_download.h" #include "util/image_utils.h" #include "util/logging.h" @@ -1793,6 +1794,9 @@ int main(int argc, char **argv) { /* Load secrets (API keys) from secrets.json -- non-fatal if missing */ secrets_load("secrets.json"); + /* Initialize notification system (phone/SMS/image HUD popups) */ + notification_init(); + last_file_check = currTime; #ifndef ORIGINAL_RATIO @@ -2454,6 +2458,7 @@ int main(int argc, char **argv) { #endif cleanup_hud_manager(); + notification_shutdown(); /* MQTT already stopped before free_elements above */ diff --git a/src/rendering/element_renderer.c b/src/rendering/element_renderer.c index dcd964c..cd8f999 100644 --- a/src/rendering/element_renderer.c +++ b/src/rendering/element_renderer.c @@ -47,6 +47,7 @@ #include "rendering/element_renderer.h" #include "rendering/gauge_renderer.h" #include "ui/hud_manager.h" +#include "ui/notification.h" #include "util/curl_download.h" #include "util/logging.h" @@ -94,6 +95,15 @@ static void calculate_zoom_rect(SDL_Rect *dst_rect_l, SDL_Rect *dst_rect_r, floa */ /* Render a static element */ +/* Apply notification group fade alpha to a base alpha value */ +static Uint8 apply_notification_alpha(element *curr_element, Uint8 base_alpha) { + if (curr_element->notification_group) { + float notif_alpha = notification_get_alpha(curr_element->notification_group); + return (Uint8)((float)base_alpha * notif_alpha); + } + return base_alpha; +} + void render_static_element(element *curr_element) { SDL_Rect dst_rect_l, dst_rect_r; SDL_Texture *this_texture = NULL; @@ -145,6 +155,7 @@ void render_static_element(element *curr_element) { if (curr_element->in_transition && curr_element->transition_alpha > 0.0f) { render_alpha = (Uint8)(curr_element->transition_alpha * 255); } + render_alpha = apply_notification_alpha(curr_element, render_alpha); /* Set alpha on renderer (affects next render call only) */ SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -251,6 +262,7 @@ void render_animated_element(element *curr_element) { if (curr_element->in_transition && curr_element->transition_alpha > 0.0f) { render_alpha = (Uint8)(curr_element->transition_alpha * 255); } + render_alpha = apply_notification_alpha(curr_element, render_alpha); /* Set alpha on renderer (affects next render call only) */ SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -732,6 +744,14 @@ void render_text_element(element *curr_element) { snprintf(render_text, MAX_TEXT_LENGTH, "%s", alert_text); break; } + case TEXT_SOURCE_CALLER_NAME: + case TEXT_SOURCE_CALLER_NUMBER: + case TEXT_SOURCE_CALL_STATUS: + case TEXT_SOURCE_SMS_PREVIEW: + case TEXT_SOURCE_NOTIFICATION_TITLE: + case TEXT_SOURCE_NOTIFICATION_SOURCE: + notification_get_text(curr_element->text_source_id, render_text, MAX_TEXT_LENGTH); + break; default: /* TEXT_SOURCE_STATIC or unknown -- render text as-is */ strncpy(render_text, curr_element->text, MAX_TEXT_LENGTH - 1); @@ -835,6 +855,7 @@ void render_text_element(element *curr_element) { if (curr_element->in_transition && curr_element->transition_alpha > 0.0f) { render_alpha = (Uint8)(curr_element->transition_alpha * 255); } + render_alpha = apply_notification_alpha(curr_element, render_alpha); /* Set alpha on renderer (affects next render call only) */ SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -856,6 +877,47 @@ void render_text_element(element *curr_element) { } } +/* Render a notification contact photo (special element type "notification_photo") */ +void render_notification_photo_element(element *curr_element) { + SDL_Renderer *renderer = get_sdl_renderer(); + SDL_Texture *photo_tex = notification_get_photo_texture(renderer); + + /* Lazy-load placeholder texture from filename if not yet created */ + if (!curr_element->texture && curr_element->filename[0]) { + curr_element->texture = get_cached_texture(curr_element->filename); + } + + /* Use placeholder texture if no photo available */ + SDL_Texture *tex = photo_tex ? photo_tex : curr_element->texture; + if (!tex) { + return; + } + + hud_display_settings *this_hds = get_hud_display_settings(); + SDL_Rect dst_rect_l, dst_rect_r; + dst_rect_l.x = dst_rect_r.x = curr_element->dest_x; + dst_rect_l.y = dst_rect_r.y = curr_element->dest_y; + dst_rect_l.w = dst_rect_r.w = curr_element->width > 0 ? curr_element->width : 128; + dst_rect_l.h = dst_rect_r.h = curr_element->height > 0 ? curr_element->height : 128; + + if (!curr_element->fixed) { + dst_rect_l.x -= this_hds->stereo_offset; + dst_rect_r.x += this_hds->stereo_offset; + } + + /* Apply notification alpha */ + float notif_alpha = notification_get_alpha(curr_element->notification_group); + Uint8 alpha = (Uint8)(notif_alpha * 255); + if (alpha == 0) { + return; + } + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetTextureAlphaMod(tex, alpha); + renderStereo(tex, NULL, &dst_rect_l, &dst_rect_r, 0.0); + SDL_SetTextureAlphaMod(tex, 255); +} + /* Forward declarations for special element types */ void render_map_element(element *curr_element); void render_pitch_element(element *curr_element); @@ -886,6 +948,8 @@ void render_special_element(element *curr_element) { render_gauge_element(curr_element); } else if (strcmp("armor_display", curr_element->name) == 0) { render_armor_display_element(curr_element); + } else if (strcmp("notification_photo", curr_element->special_name) == 0) { + render_notification_photo_element(curr_element); } else { LOG_ERROR("Unknown special element type: %s", curr_element->special_name); } @@ -1370,6 +1434,7 @@ void render_battery_element(element *curr_element) { if (curr_element->in_transition && curr_element->transition_alpha > 0.0f) { render_alpha = (Uint8)(curr_element->transition_alpha * 255); } + render_alpha = apply_notification_alpha(curr_element, render_alpha); /* Set alpha on renderer (affects next render call only) */ SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); @@ -1921,6 +1986,12 @@ void render_element(element *curr_element) { return; } + /* Skip notification elements when their group is not active */ + if (curr_element->notification_group && + !notification_is_active(curr_element->notification_group)) { + return; + } + switch (curr_element->type) { case STATIC: render_static_element(curr_element); @@ -2045,6 +2116,9 @@ void render_hud_elements(void) { hud_display_settings *this_hds = get_hud_display_settings(); element *first_element = get_first_element(); + /* Update notification timers and state transitions */ + notification_update(); + if (hud_mgr->transition_from != NULL) { /* We need to reset text elements on the beginning of the transition. */ curr_element = first_element; diff --git a/src/ui/notification.c b/src/ui/notification.c new file mode 100644 index 0000000..710eb93 --- /dev/null +++ b/src/ui/notification.c @@ -0,0 +1,568 @@ +/* + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * By contributing to this project, you agree to license your contributions + * under the GPLv3 (or any later version) or any future licenses chosen by + * the project author(s). Contributions include any modifications, + * enhancements, or additions to the project. These contributions become + * part of the project and are adopted by the project author(s). + * + * Notification system — phone call/SMS popups and image display on HUD. + * State machine manages fade in/out, compact mode, and TTL expiry. + * Photo textures decoded from base64 on MQTT thread, created on render thread. + */ + +#include "ui/notification.h" + +#include +#include +#include +#include +#include +#include + +#include "config/config_parser.h" +#include "util/logging.h" + +/* ============================================================================= + * State + * ============================================================================= */ + +static notif_phone_t s_phone = { 0 }; +static notif_image_t s_image = { 0 }; + +notif_group_t notif_group_resolve(const char *group) { + if (!group || !group[0]) { + return NOTIF_GROUP_NONE; + } + if (strcmp(group, "phone") == 0) { + return NOTIF_GROUP_PHONE; + } + if (strcmp(group, "image") == 0) { + return NOTIF_GROUP_IMAGE; + } + return NOTIF_GROUP_NONE; +} + +/* ============================================================================= + * Base64 Decode Helper + * ============================================================================= */ + +/** + * @brief Decode a base64 string to raw bytes. + * @param input Base64 string + * @param out_size Output: decoded size + * @return malloc'd buffer (caller frees), or NULL on failure + */ +static unsigned char *base64_decode(const char *input, size_t *out_size) { + if (!input || !out_size) { + return NULL; + } + + size_t input_len = strlen(input); + if (input_len == 0) { + return NULL; + } + + /* Output is at most 3/4 of input length */ + size_t max_out = (input_len * 3) / 4 + 4; + unsigned char *output = malloc(max_out); + if (!output) { + return NULL; + } + + BIO *b64 = BIO_new(BIO_f_base64()); + BIO *mem = BIO_new_mem_buf(input, (int)input_len); + if (!b64 || !mem) { + free(output); + if (b64) + BIO_free(b64); + if (mem) + BIO_free(mem); + return NULL; + } + + BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL); + BIO_push(b64, mem); + + int decoded_len = BIO_read(b64, output, (int)max_out); + BIO_free_all(b64); + + if (decoded_len <= 0) { + free(output); + return NULL; + } + + *out_size = (size_t)decoded_len; + return output; +} + +/* ============================================================================= + * State Machine Helpers + * ============================================================================= */ + +static uint32_t now_ms(void) { + return SDL_GetTicks(); +} + +static void phone_set_state(notif_state_t new_state, uint32_t fade_ms) { + s_phone.state = new_state; + if (new_state == NOTIF_STATE_SHOWING || new_state == NOTIF_STATE_HIDING) { + s_phone.fade_start_ms = now_ms(); + s_phone.fade_duration_ms = fade_ms; + } +} + +static void image_set_state(notif_state_t new_state, uint32_t fade_ms) { + s_image.state = new_state; + if (new_state == NOTIF_STATE_SHOWING || new_state == NOTIF_STATE_HIDING) { + s_image.fade_start_ms = now_ms(); + s_image.fade_duration_ms = fade_ms; + } +} + +static void phone_clear(void) { + s_phone.state = NOTIF_STATE_HIDDEN; + s_phone.event = NOTIF_EVENT_NONE; + s_phone.caller_name[0] = '\0'; + s_phone.caller_number[0] = '\0'; + s_phone.call_status[0] = '\0'; + s_phone.sms_preview[0] = '\0'; + s_phone.alpha = 0.0f; + s_phone.call_duration_sec = 0; + + /* Don't free photo here — it persists for reuse across events from same contact */ +} + +static void phone_free_photo(void) { + pthread_mutex_lock(&s_phone.mutex); + if (s_phone.photo_data) { + free(s_phone.photo_data); + s_phone.photo_data = NULL; + s_phone.photo_data_size = 0; + } + s_phone.photo_dirty = false; + pthread_mutex_unlock(&s_phone.mutex); + + if (s_phone.photo_texture) { + SDL_DestroyTexture(s_phone.photo_texture); + s_phone.photo_texture = NULL; + } +} + +/* ============================================================================= + * Public API — Lifecycle + * ============================================================================= */ + +void notification_init(void) { + memset(&s_phone, 0, sizeof(s_phone)); + memset(&s_image, 0, sizeof(s_image)); + pthread_mutex_init(&s_phone.mutex, NULL); + pthread_mutex_init(&s_image.image_mutex, NULL); + LOG_INFO("Notification system initialized"); +} + +void notification_shutdown(void) { + phone_free_photo(); + + pthread_mutex_lock(&s_image.image_mutex); + if (s_image.image_data) { + free(s_image.image_data); + s_image.image_data = NULL; + } + pthread_mutex_unlock(&s_image.image_mutex); + + if (s_image.image_texture) { + SDL_DestroyTexture(s_image.image_texture); + s_image.image_texture = NULL; + } + + pthread_mutex_destroy(&s_phone.mutex); + pthread_mutex_destroy(&s_image.image_mutex); + LOG_INFO("Notification system shut down"); +} + +/* ============================================================================= + * MQTT Event Handlers (called from MQTT thread) + * ============================================================================= */ + +void notification_handle_phone_event(struct json_object *root) { + if (!root) { + return; + } + + struct json_object *j_event; + if (!json_object_object_get_ex(root, "event", &j_event)) { + return; + } + const char *event = json_object_get_string(j_event); + if (!event) { + return; + } + + /* Extract common fields */ + const char *name = ""; + const char *number = ""; + struct json_object *j_tmp; + if (json_object_object_get_ex(root, "name", &j_tmp)) { + name = json_object_get_string(j_tmp); + } + if (json_object_object_get_ex(root, "number", &j_tmp)) { + number = json_object_get_string(j_tmp); + } + + /* Decode photo if present */ + struct json_object *j_photo; + if (json_object_object_get_ex(root, "photo", &j_photo)) { + struct json_object *j_data; + if (json_object_object_get_ex(j_photo, "data", &j_data)) { + const char *b64 = json_object_get_string(j_data); + if (b64 && b64[0]) { + size_t decoded_size = 0; + unsigned char *decoded = base64_decode(b64, &decoded_size); + if (decoded && decoded_size > 0) { + pthread_mutex_lock(&s_phone.mutex); + free(s_phone.photo_data); + s_phone.photo_data = decoded; + s_phone.photo_data_size = decoded_size; + s_phone.photo_dirty = true; + pthread_mutex_unlock(&s_phone.mutex); + } + } + } + } + + /* Lock for all field writes (render thread reads under same lock) */ + pthread_mutex_lock(&s_phone.mutex); + + if (strcmp(event, "incoming_call") == 0) { + snprintf(s_phone.caller_name, sizeof(s_phone.caller_name), "%s", + (name && name[0]) ? name : "Unknown"); + snprintf(s_phone.caller_number, sizeof(s_phone.caller_number), "%s", number); + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "INCOMING CALL"); + s_phone.sms_preview[0] = '\0'; + s_phone.event = NOTIF_EVENT_INCOMING_CALL; + s_phone.start_time_ms = now_ms(); + s_phone.ttl_ms = 8000; /* Short TTL — extended by each ring event */ + + s_phone.call_duration_sec = 0; + phone_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_PHONE_MS); + + } else if (strcmp(event, "ring") == 0) { + /* Subsequent ring — reset TTL to keep notification visible while ringing */ + if (s_phone.event == NOTIF_EVENT_INCOMING_CALL && s_phone.state != NOTIF_STATE_HIDDEN) { + s_phone.start_time_ms = now_ms(); + } + + } else if (strcmp(event, "call_active") == 0) { + snprintf(s_phone.caller_name, sizeof(s_phone.caller_name), "%s", + (name && name[0]) ? name : "Unknown"); + snprintf(s_phone.caller_number, sizeof(s_phone.caller_number), "%s", number); + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "CALL ACTIVE"); + s_phone.sms_preview[0] = '\0'; + s_phone.event = NOTIF_EVENT_CALL_ACTIVE; + s_phone.start_time_ms = now_ms(); + s_phone.ttl_ms = 0; /* No auto-dismiss — stays until call_ended */ + s_phone.call_duration_sec = 0; + phone_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_PHONE_MS); + + } else if (strcmp(event, "call_ended") == 0) { + int duration = 0; + if (json_object_object_get_ex(root, "duration", &j_tmp)) { + duration = json_object_get_int(j_tmp); + } + s_phone.call_duration_sec = duration; + + if (duration > 0) { + int mins = duration / 60; + int secs = duration % 60; + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "CALL ENDED %d:%02d", mins, + secs); + } else { + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "CALL ENDED"); + } + + s_phone.event = NOTIF_EVENT_CALL_ENDED; + s_phone.start_time_ms = now_ms(); + s_phone.ttl_ms = NOTIF_CALL_ENDED_TTL_MS; + phone_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_PHONE_MS); + + } else if (strcmp(event, "sms_received") == 0) { + snprintf(s_phone.caller_name, sizeof(s_phone.caller_name), "%s", + (name && name[0]) ? name : "Unknown"); + snprintf(s_phone.caller_number, sizeof(s_phone.caller_number), "%s", number); + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "SMS RECEIVED"); + + const char *preview = ""; + if (json_object_object_get_ex(root, "preview", &j_tmp)) { + preview = json_object_get_string(j_tmp); + } + snprintf(s_phone.sms_preview, sizeof(s_phone.sms_preview), "%s", preview ? preview : ""); + + s_phone.event = NOTIF_EVENT_SMS_RECEIVED; + s_phone.start_time_ms = now_ms(); + s_phone.ttl_ms = NOTIF_SMS_TTL_MS; + + struct json_object *j_ttl; + if (json_object_object_get_ex(root, "ttl", &j_ttl)) { + s_phone.ttl_ms = (uint32_t)(json_object_get_int(j_ttl) * 1000); + } + + phone_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_PHONE_MS); + } + + pthread_mutex_unlock(&s_phone.mutex); +} + +void notification_handle_image_request(struct json_object *root) { + if (!root) { + return; + } + + struct json_object *j_tmp; + + const char *title = ""; + if (json_object_object_get_ex(root, "title", &j_tmp)) { + title = json_object_get_string(j_tmp); + } + snprintf(s_image.title, sizeof(s_image.title), "%s", title ? title : ""); + + const char *source = ""; + if (json_object_object_get_ex(root, "source", &j_tmp)) { + source = json_object_get_string(j_tmp); + } + snprintf(s_image.source, sizeof(s_image.source), "%s", source ? source : ""); + + const char *url = ""; + if (json_object_object_get_ex(root, "image_url", &j_tmp)) { + url = json_object_get_string(j_tmp); + } + snprintf(s_image.image_url, sizeof(s_image.image_url), "%s", url ? url : ""); + + s_image.start_time_ms = now_ms(); + s_image.ttl_ms = NOTIF_IMAGE_TTL_MS; + + struct json_object *j_ttl; + if (json_object_object_get_ex(root, "ttl", &j_ttl)) { + s_image.ttl_ms = (uint32_t)(json_object_get_int(j_ttl) * 1000); + } + + image_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_IMAGE_MS); + + /* TODO: Kick off async HTTP fetch for image_url using service token */ +} + +/* ============================================================================= + * Frame Update (called from render thread) + * ============================================================================= */ + +void notification_update(void) { + uint32_t t = now_ms(); + + pthread_mutex_lock(&s_phone.mutex); + + /* --- Phone slot state machine --- */ + switch (s_phone.state) { + case NOTIF_STATE_SHOWING: { + uint32_t elapsed = t - s_phone.fade_start_ms; + if (elapsed >= s_phone.fade_duration_ms) { + s_phone.alpha = 1.0f; + s_phone.state = NOTIF_STATE_VISIBLE; + } else { + s_phone.alpha = (float)elapsed / (float)s_phone.fade_duration_ms; + } + break; + } + case NOTIF_STATE_VISIBLE: { + /* Update call timer for active calls */ + if (s_phone.event == NOTIF_EVENT_CALL_ACTIVE) { + int secs = (int)(t - s_phone.start_time_ms) / 1000; + int mins = secs / 60; + secs = secs % 60; + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "CALL ACTIVE %d:%02d", mins, + secs); + + /* Transition to compact after NOTIF_COMPACT_AFTER_MS */ + if ((t - s_phone.start_time_ms) >= NOTIF_COMPACT_AFTER_MS) { + s_phone.state = NOTIF_STATE_COMPACT; + } + } + + /* TTL expiry (0 = no auto-dismiss) */ + if (s_phone.ttl_ms > 0 && (t - s_phone.start_time_ms) >= s_phone.ttl_ms) { + phone_set_state(NOTIF_STATE_HIDING, NOTIF_FADE_OUT_MS); + } + break; + } + case NOTIF_STATE_COMPACT: { + /* Still update timer */ + if (s_phone.event == NOTIF_EVENT_CALL_ACTIVE) { + int secs = (int)(t - s_phone.start_time_ms) / 1000; + int mins = secs / 60; + secs = secs % 60; + snprintf(s_phone.call_status, sizeof(s_phone.call_status), "CALL ACTIVE %d:%02d", mins, + secs); + } + /* Compact mode stays until call_ended or dismiss */ + break; + } + case NOTIF_STATE_HIDING: { + uint32_t elapsed = t - s_phone.fade_start_ms; + if (elapsed >= s_phone.fade_duration_ms) { + phone_clear(); + } else { + s_phone.alpha = 1.0f - ((float)elapsed / (float)s_phone.fade_duration_ms); + } + break; + } + default: + break; + } + + pthread_mutex_unlock(&s_phone.mutex); + + /* --- Image slot state machine --- */ + switch (s_image.state) { + case NOTIF_STATE_SHOWING: { + uint32_t elapsed = t - s_image.fade_start_ms; + if (elapsed >= s_image.fade_duration_ms) { + s_image.alpha = 1.0f; + s_image.state = NOTIF_STATE_VISIBLE; + } else { + s_image.alpha = (float)elapsed / (float)s_image.fade_duration_ms; + } + break; + } + case NOTIF_STATE_VISIBLE: { + if (s_image.ttl_ms > 0 && (t - s_image.start_time_ms) >= s_image.ttl_ms) { + image_set_state(NOTIF_STATE_HIDING, NOTIF_FADE_OUT_MS); + } + break; + } + case NOTIF_STATE_HIDING: { + uint32_t elapsed = t - s_image.fade_start_ms; + if (elapsed >= s_image.fade_duration_ms) { + s_image.state = NOTIF_STATE_HIDDEN; + s_image.alpha = 0.0f; + s_image.title[0] = '\0'; + s_image.source[0] = '\0'; + } else { + s_image.alpha = 1.0f - ((float)elapsed / (float)s_image.fade_duration_ms); + } + break; + } + default: + break; + } +} + +/* ============================================================================= + * Query API (called from render thread) + * ============================================================================= */ + +float notification_get_alpha(int group) { + if (group == NOTIF_GROUP_PHONE) { + return s_phone.alpha; + } + if (group == NOTIF_GROUP_IMAGE) { + return s_image.alpha; + } + return 0.0f; +} + +bool notification_is_active(int group) { + if (group == NOTIF_GROUP_PHONE) { + return s_phone.state != NOTIF_STATE_HIDDEN; + } + if (group == NOTIF_GROUP_IMAGE) { + return s_image.state != NOTIF_STATE_HIDDEN; + } + return false; +} + +void notification_get_text(int source_id, char *out, size_t out_size) { + if (!out || out_size == 0) { + return; + } + out[0] = '\0'; + + switch (source_id) { + case TEXT_SOURCE_CALLER_NAME: + pthread_mutex_lock(&s_phone.mutex); + snprintf(out, out_size, "%s", s_phone.caller_name); + pthread_mutex_unlock(&s_phone.mutex); + break; + case TEXT_SOURCE_CALLER_NUMBER: + pthread_mutex_lock(&s_phone.mutex); + snprintf(out, out_size, "%s", s_phone.caller_number); + pthread_mutex_unlock(&s_phone.mutex); + break; + case TEXT_SOURCE_CALL_STATUS: + pthread_mutex_lock(&s_phone.mutex); + snprintf(out, out_size, "%s", s_phone.call_status); + pthread_mutex_unlock(&s_phone.mutex); + break; + case TEXT_SOURCE_SMS_PREVIEW: + pthread_mutex_lock(&s_phone.mutex); + snprintf(out, out_size, "%s", s_phone.sms_preview); + pthread_mutex_unlock(&s_phone.mutex); + break; + case TEXT_SOURCE_NOTIFICATION_TITLE: + snprintf(out, out_size, "%s", s_image.title); + break; + case TEXT_SOURCE_NOTIFICATION_SOURCE: + snprintf(out, out_size, "%s", s_image.source); + break; + default: + break; + } +} + +SDL_Texture *notification_get_photo_texture(SDL_Renderer *renderer) { + if (!renderer) { + return s_phone.photo_texture; + } + + /* Check if MQTT thread delivered new photo data */ + pthread_mutex_lock(&s_phone.mutex); + if (s_phone.photo_dirty && s_phone.photo_data && s_phone.photo_data_size > 0) { + /* Destroy old texture */ + if (s_phone.photo_texture) { + SDL_DestroyTexture(s_phone.photo_texture); + s_phone.photo_texture = NULL; + } + + /* Create texture from raw bytes */ + SDL_RWops *rw = SDL_RWFromMem(s_phone.photo_data, (int)s_phone.photo_data_size); + if (rw) { + SDL_Surface *surface = IMG_Load_RW(rw, 1); /* 1 = auto-close RW */ + if (surface) { + s_phone.photo_texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + } + } + s_phone.photo_dirty = false; + } + pthread_mutex_unlock(&s_phone.mutex); + + return s_phone.photo_texture; +} + +const notif_phone_t *notification_get_phone(void) { + return &s_phone; +} + +const notif_image_t *notification_get_image(void) { + return &s_image; +} diff --git a/ui_assets/mk2/contact-placeholder.png b/ui_assets/mk2/contact-placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..a2996bd80cafcb1223844451c2e3dc7665bf612c GIT binary patch literal 3319 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 KF34`;kN*MQrY$W1 literal 0 HcmV?d00001 diff --git a/ui_assets/mk2/notification-phone-bg.png b/ui_assets/mk2/notification-phone-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..aa617da72189ea773467dd86d35bc3822a9c1625 GIT binary patch literal 3388 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 jNZYQ~YEqv@{b*mxxpUdE$u=1J9Rf!W + + diff --git a/ui_assets/svg-src/notification_image_bg_v2.svg b/ui_assets/svg-src/notification_image_bg_v2.svg new file mode 100644 index 0000000..c0924c7 --- /dev/null +++ b/ui_assets/svg-src/notification_image_bg_v2.svg @@ -0,0 +1,58 @@ + + + diff --git a/ui_assets/svg-src/notification_phone_bg_v6.svg b/ui_assets/svg-src/notification_phone_bg_v6.svg new file mode 100644 index 0000000..e354895 --- /dev/null +++ b/ui_assets/svg-src/notification_phone_bg_v6.svg @@ -0,0 +1,58 @@ + + + From 8865a061186e764e838f62feba3e6ea872ec37f0 Mon Sep 17 00:00:00 2001 From: Kris Kersey Date: Fri, 17 Apr 2026 03:23:53 +0000 Subject: [PATCH 2/2] Fix device routing, mutex coverage, base64 size cap, and brace style - Save device_str before tmpstr reuse to prevent wrong notification routing when device field is overwritten by format/other parsing - Add image_mutex protection to s_image state machine update and text source reads (matches s_phone mutex pattern) - Cap base64 decode at 512KB input to prevent OOM from rogue payloads - Add braces to single-statement if bodies in BIO error path Co-Authored-By: Claude Opus 4.6 (1M context) --- src/comm/command_processing.c | 8 +++++--- src/ui/notification.c | 22 ++++++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/comm/command_processing.c b/src/comm/command_processing.c index 25489da..3422926 100644 --- a/src/comm/command_processing.c +++ b/src/comm/command_processing.c @@ -453,12 +453,14 @@ int parse_json_command(char *command_string, char *topic) { return FAILURE; } - /* Check for "device" key */ + /* Check for "device" key — save for later dispatch (tmpstr gets reused) */ + const char *device_str = NULL; if (!json_object_object_get_ex(parsed_json, "device", &tmpobj)) { /* No "device" field found, this might be valid for some commands */ LOG_INFO("No device field found in JSON command"); } else if (tmpobj != NULL) { tmpstr = json_object_get_string(tmpobj); + device_str = tmpstr; /* Preserved — tmpstr may be reassigned below */ if (tmpstr == NULL) { LOG_WARNING("Device field exists but is not a string"); } else { @@ -911,12 +913,12 @@ int parse_json_command(char *command_string, char *topic) { } /* Phone notification events (from DAWN phone_service via HUD topic) */ - if (tmpstr != NULL && strcmp(tmpstr, "phone") == 0) { + if (device_str != NULL && strcmp(device_str, "phone") == 0) { notification_handle_phone_event(parsed_json); } /* Image display requests (from DAWN image_search_tool via HUD topic) */ - if (tmpstr != NULL && strcmp(tmpstr, "image") == 0) { + if (device_str != NULL && strcmp(device_str, "image") == 0) { notification_handle_image_request(parsed_json); } diff --git a/src/ui/notification.c b/src/ui/notification.c index 710eb93..40f22c6 100644 --- a/src/ui/notification.c +++ b/src/ui/notification.c @@ -75,6 +75,12 @@ static unsigned char *base64_decode(const char *input, size_t *out_size) { return NULL; } + /* Cap at 512KB base64 (~384KB decoded) to prevent OOM from rogue MQTT payloads */ + if (input_len > 512 * 1024) { + LOG_WARNING("notification: base64 input too large (%zu bytes), rejecting", input_len); + return NULL; + } + /* Output is at most 3/4 of input length */ size_t max_out = (input_len * 3) / 4 + 4; unsigned char *output = malloc(max_out); @@ -86,10 +92,12 @@ static unsigned char *base64_decode(const char *input, size_t *out_size) { BIO *mem = BIO_new_mem_buf(input, (int)input_len); if (!b64 || !mem) { free(output); - if (b64) + if (b64) { BIO_free(b64); - if (mem) + } + if (mem) { BIO_free(mem); + } return NULL; } @@ -336,6 +344,8 @@ void notification_handle_image_request(struct json_object *root) { if (json_object_object_get_ex(root, "title", &j_tmp)) { title = json_object_get_string(j_tmp); } + pthread_mutex_lock(&s_image.image_mutex); + snprintf(s_image.title, sizeof(s_image.title), "%s", title ? title : ""); const char *source = ""; @@ -360,6 +370,8 @@ void notification_handle_image_request(struct json_object *root) { image_set_state(NOTIF_STATE_SHOWING, NOTIF_FADE_IN_IMAGE_MS); + pthread_mutex_unlock(&s_image.image_mutex); + /* TODO: Kick off async HTTP fetch for image_url using service token */ } @@ -433,6 +445,7 @@ void notification_update(void) { pthread_mutex_unlock(&s_phone.mutex); /* --- Image slot state machine --- */ + pthread_mutex_lock(&s_image.image_mutex); switch (s_image.state) { case NOTIF_STATE_SHOWING: { uint32_t elapsed = t - s_image.fade_start_ms; @@ -465,6 +478,7 @@ void notification_update(void) { default: break; } + pthread_mutex_unlock(&s_image.image_mutex); } /* ============================================================================= @@ -519,10 +533,14 @@ void notification_get_text(int source_id, char *out, size_t out_size) { pthread_mutex_unlock(&s_phone.mutex); break; case TEXT_SOURCE_NOTIFICATION_TITLE: + pthread_mutex_lock(&s_image.image_mutex); snprintf(out, out_size, "%s", s_image.title); + pthread_mutex_unlock(&s_image.image_mutex); break; case TEXT_SOURCE_NOTIFICATION_SOURCE: + pthread_mutex_lock(&s_image.image_mutex); snprintf(out, out_size, "%s", s_image.source); + pthread_mutex_unlock(&s_image.image_mutex); break; default: break;