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 @@ + + +