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..3422926 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"
@@ -452,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 {
@@ -909,6 +912,16 @@ int parse_json_command(char *command_string, char *topic) {
}
}
+ /* Phone notification events (from DAWN phone_service via HUD topic) */
+ 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 (device_str != NULL && strcmp(device_str, "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..40f22c6
--- /dev/null
+++ b/src/ui/notification.c
@@ -0,0 +1,586 @@
+/*
+ * 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;
+ }
+
+ /* 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);
+ 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);
+ }
+ pthread_mutex_lock(&s_image.image_mutex);
+
+ 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);
+
+ pthread_mutex_unlock(&s_image.image_mutex);
+
+ /* 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 --- */
+ pthread_mutex_lock(&s_image.image_mutex);
+ 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;
+ }
+ pthread_mutex_unlock(&s_image.image_mutex);
+}
+
+/* =============================================================================
+ * 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:
+ 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;
+ }
+}
+
+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 0000000..a2996bd
Binary files /dev/null and b/ui_assets/mk2/contact-placeholder.png differ
diff --git a/ui_assets/mk2/notification-image-bg.png b/ui_assets/mk2/notification-image-bg.png
new file mode 100644
index 0000000..f306e29
Binary files /dev/null and b/ui_assets/mk2/notification-image-bg.png differ
diff --git a/ui_assets/mk2/notification-phone-bg.png b/ui_assets/mk2/notification-phone-bg.png
new file mode 100644
index 0000000..aa617da
Binary files /dev/null and b/ui_assets/mk2/notification-phone-bg.png differ
diff --git a/ui_assets/svg-src/contact_placeholder_v2.svg b/ui_assets/svg-src/contact_placeholder_v2.svg
new file mode 100644
index 0000000..9ed45d9
--- /dev/null
+++ b/ui_assets/svg-src/contact_placeholder_v2.svg
@@ -0,0 +1,58 @@
+
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 @@
+