From 15beb27f99c67ec3106251e8baed14acb9c30477 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 18:01:52 +0200 Subject: [PATCH 1/9] notifications: add age-aware storage iteration Avoid deserializing notification payloads older than the grouping range. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- .../notifications/notification_storage.h | 9 +++ .../notifications/notification_storage.c | 49 ++++++++++++++++ .../notifications/test_notification_storage.c | 58 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/include/pbl/services/notifications/notification_storage.h b/include/pbl/services/notifications/notification_storage.h index d85922ecd4..6b3eba5ca5 100644 --- a/include/pbl/services/notifications/notification_storage.h +++ b/include/pbl/services/notifications/notification_storage.h @@ -58,6 +58,15 @@ bool notification_storage_find_ancs_notification_by_timestamp( void notification_storage_iterate(bool (*iter_callback)(void *data, SerializedTimelineItemHeader *header_id), void *data); +//! Iterates over all notifications and deserializes payloads at or after item_cutoff. +//! item is NULL for older notifications. Callback data is only valid during the callback. +//! NOTE: Do NOT call into other notification storage functions from the iterator callback. +void notification_storage_iterate_items_after( + time_t item_cutoff, + bool (*iter_callback)(void *data, const CommonTimelineItemHeader *header, + const TimelineItem *item), + void *data); + //! Iterates over all the notifications calling the callback with the passed data. //! Overwrites the notifications and rewrites them to disk. //! This is essentially a noop if the callback doesn't alter the data. diff --git a/src/fw/services/notifications/notification_storage.c b/src/fw/services/notifications/notification_storage.c index 29831d72da..8055bdbfb3 100644 --- a/src/fw/services/notifications/notification_storage.c +++ b/src/fw/services/notifications/notification_storage.c @@ -736,6 +736,55 @@ void notification_storage_iterate(bool (*iter_callback)(void *data, prv_file_close(fd); } +void notification_storage_iterate_items_after( + time_t item_cutoff, + bool (*iter_callback)(void *data, const CommonTimelineItemHeader *header, + const TimelineItem *item), + void *data) { + PBL_ASSERTN(s_notif_storage_mutex != NULL); + + if (iter_callback == NULL) { + return; + } + + int fd = prv_file_open(OP_FLAG_READ); + if (fd < 0) { + return; + } + + Iterator iter; + NotificationIterState iter_state = {.fd = fd}; + iter_init(&iter, (IteratorCallback)prv_iter_next, NULL, &iter_state); + + while (iter_next(&iter)) { + if (iter_state.header.common.status & TimelineItemStatusDeleted) { + if (pfs_seek(fd, iter_state.header.payload_length, FSeekCur) < 0) { + break; + } + continue; + } + + const bool deserialize = iter_state.header.common.timestamp >= item_cutoff; + TimelineItem item; + if (deserialize && !prv_get_notification(&item, &iter_state.header, fd)) { + break; + } + + const bool should_continue = + iter_callback(data, &iter_state.header.common, deserialize ? &item : NULL); + if (deserialize) { + timeline_item_free_allocated_buffer(&item); + } else if (pfs_seek(fd, iter_state.header.payload_length, FSeekCur) < 0) { + break; + } + if (!should_continue) { + break; + } + } + + prv_file_close(fd); +} + void notification_storage_reset_and_init(void) { notification_storage_lock(); pfs_remove(FILENAME); diff --git a/tests/fw/services/notifications/test_notification_storage.c b/tests/fw/services/notifications/test_notification_storage.c index cb1748da6e..0b913aa164 100644 --- a/tests/fw/services/notifications/test_notification_storage.c +++ b/tests/fw/services/notifications/test_notification_storage.c @@ -162,6 +162,27 @@ static void compare_notifications(TimelineItem *a, TimelineItem *b) { } } +typedef struct { + Uuid older_id; + Uuid recent_id; + uint8_t header_count; + uint8_t item_count; +} NotificationItemsIteratorContext; + +static bool prv_items_iterator_callback(void *data, const CommonTimelineItemHeader *header, + const TimelineItem *item) { + NotificationItemsIteratorContext *context = data; + if (item) { + context->item_count++; + cl_assert(uuid_equal(&item->header.id, &context->recent_id)); + cl_assert_equal_s(attribute_get_string(&item->attr_list, AttributeIdTitle, NULL), "Sender"); + } else { + context->header_count++; + cl_assert(uuid_equal(&header->id, &context->older_id)); + } + return true; +} + // Tests //////////////////////////////////// void test_notification_storage__basic(void) { @@ -200,6 +221,43 @@ void test_notification_storage__basic(void) { cl_assert_equal_b(notification_storage_get(&invalid_uuid, &r), false); } +void test_notification_storage__iterate_items_after_skips_old_and_deleted_payloads(void) { + TimelineItem older = { + .header = + { + .id = UuidMake(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + .timestamp = 100, + .type = TimelineItemTypeNotification, + .layout = LayoutIdGeneric, + }, + .attr_list = + { + .num_attributes = ARRAY_LENGTH(attributes), + .attributes = attributes, + }, + }; + TimelineItem recent = older; + recent.header.id = UuidMake(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + recent.header.timestamp = 200; + TimelineItem deleted = older; + deleted.header.id = UuidMake(3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + deleted.header.timestamp = 300; + + notification_storage_store(&older); + notification_storage_store(&recent); + notification_storage_store(&deleted); + notification_storage_remove(&deleted.header.id); + + NotificationItemsIteratorContext context = { + .older_id = older.header.id, + .recent_id = recent.header.id, + }; + notification_storage_iterate_items_after(200, prv_items_iterator_callback, &context); + + cl_assert_equal_i(context.header_count, 1); + cl_assert_equal_i(context.item_count, 1); +} + void test_notification_storage__multiple(void) { Uuid i1 ; uuid_generate(&i1); From b1ce86b2a5e7f85ee480b16b4d1d0f409fa5a0db Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 18:02:05 +0200 Subject: [PATCH 2/9] settings: add notification grouping range Persist Never, one-day, one-week, and all-history grouping options. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- .../alerts_preferences_private.h | 12 ++++++ src/fw/apps/system/settings/notifications.c | 40 +++++++++++++++++++ .../notifications/alerts_preferences.c | 20 ++++++++++ 3 files changed, 72 insertions(+) diff --git a/include/pbl/services/notifications/alerts_preferences_private.h b/include/pbl/services/notifications/alerts_preferences_private.h index 6072cea648..79ef819f33 100644 --- a/include/pbl/services/notifications/alerts_preferences_private.h +++ b/include/pbl/services/notifications/alerts_preferences_private.h @@ -41,6 +41,18 @@ bool alerts_preferences_get_notification_backlight(void); void alerts_preferences_set_notification_backlight(bool enable); +typedef enum { + NotificationGroupingRange_Never = 0, + NotificationGroupingRange_OneDay, + NotificationGroupingRange_OneWeek, + NotificationGroupingRange_All, + NotificationGroupingRangeCount, +} NotificationGroupingRange; + +NotificationGroupingRange alerts_preferences_get_notification_grouping_range(void); + +void alerts_preferences_set_notification_grouping_range(NotificationGroupingRange range); + typedef enum { NotificationStatusBarStyle_Default = 0, NotificationStatusBarStyle_Bold = 1, diff --git a/src/fw/apps/system/settings/notifications.c b/src/fw/apps/system/settings/notifications.c index b65cd0948a..0bd0b802d3 100644 --- a/src/fw/apps/system/settings/notifications.c +++ b/src/fw/apps/system/settings/notifications.c @@ -44,6 +44,7 @@ enum NotificationsItem { #endif NotificationsItemVibeDelay, NotificationsItemBacklight, + NotificationsItemGroupBySender, NotificationsItemStatusBarStyle, NotificationsItem_Count, }; @@ -269,6 +270,36 @@ static void prv_status_bar_style_menu_push(SettingsNotificationsData *data) { s_status_bar_style_labels, data); } +// Group by Sender +//////////////////////// + +static const char *s_notification_grouping_range_labels[] = { + [NotificationGroupingRange_Never] = i18n_noop("Never"), + [NotificationGroupingRange_OneDay] = i18n_noop("1 day"), + [NotificationGroupingRange_OneWeek] = i18n_noop("1 week"), + [NotificationGroupingRange_All] = i18n_noop("All"), +}; + +_Static_assert(ARRAY_LENGTH(s_notification_grouping_range_labels) == NotificationGroupingRangeCount, + ""); + +static void prv_notification_grouping_range_menu_select(OptionMenu *option_menu, int selection, + void *context) { + alerts_preferences_set_notification_grouping_range((NotificationGroupingRange)selection); + app_window_stack_remove(&option_menu->window, true /* animated */); +} + +static void prv_notification_grouping_range_menu_push(SettingsNotificationsData *data) { + const OptionMenuCallbacks callbacks = { + .select = prv_notification_grouping_range_menu_select, + }; + const char *title = i18n_noop("Group by sender"); + settings_option_menu_push(title, OptionMenuContentType_SingleLine, + alerts_preferences_get_notification_grouping_range(), &callbacks, + ARRAY_LENGTH(s_notification_grouping_range_labels), + true /* icons_enabled */, s_notification_grouping_range_labels, data); +} + // Menu Layer Callbacks //////////////////////// @@ -314,6 +345,12 @@ static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx, i18n_noop("On") : i18n_noop("Off"); break; } + case NotificationsItemGroupBySender: { + title = i18n_noop("Group by sender"); + subtitle = s_notification_grouping_range_labels + [alerts_preferences_get_notification_grouping_range()]; + break; + } case NotificationsItemStatusBarStyle: { /// String within Settings->Notifications that selects the notification status bar style title = i18n_noop("Status Bar"); @@ -356,6 +393,9 @@ static void prv_select_click_cb(SettingsCallbacks *context, uint16_t row) { alerts_preferences_set_notification_backlight( !alerts_preferences_get_notification_backlight()); break; + case NotificationsItemGroupBySender: + prv_notification_grouping_range_menu_push(data); + break; case NotificationsItemStatusBarStyle: prv_status_bar_style_menu_push(data); break; diff --git a/src/fw/services/notifications/alerts_preferences.c b/src/fw/services/notifications/alerts_preferences.c index e020b93d9b..561939d696 100644 --- a/src/fw/services/notifications/alerts_preferences.c +++ b/src/fw/services/notifications/alerts_preferences.c @@ -94,6 +94,9 @@ static bool s_notification_vibe_delay = true; // true = vibe at end of animatio #define PREF_KEY_NOTIF_BACKLIGHT "notifBacklight" static bool s_notification_backlight = true; // true = enable backlight (default), false = disable backlight +#define PREF_KEY_NOTIF_GROUPING_RANGE "notifGroupingRange" +static NotificationGroupingRange s_notification_grouping_range = NotificationGroupingRange_Never; + #define PREF_KEY_NOTIF_STATUS_BAR_STYLE "notifStatusBarStyle" static NotificationStatusBarStyle s_notification_status_bar_style = NotificationStatusBarStyle_Default; @@ -344,6 +347,7 @@ void alerts_preferences_init(void) { RESTORE_PREF(PREF_KEY_NOTIF_DESIGN_STYLE, s_notification_alternative_design); RESTORE_PREF(PREF_KEY_NOTIF_VIBE_DELAY, s_notification_vibe_delay); RESTORE_PREF(PREF_KEY_NOTIF_BACKLIGHT, s_notification_backlight); + RESTORE_PREF(PREF_KEY_NOTIF_GROUPING_RANGE, s_notification_grouping_range); RESTORE_PREF(PREF_KEY_NOTIF_STATUS_BAR_STYLE, s_notification_status_bar_style); RESTORE_PREF(PREF_KEY_DND_AUTO_DISMISS, s_dnd_auto_dismiss); #undef RESTORE_PREF @@ -364,6 +368,9 @@ void alerts_preferences_init(void) { if (s_speaker_volume > 100) { s_speaker_volume = 100; } + if (s_notification_grouping_range >= NotificationGroupingRangeCount) { + s_notification_grouping_range = NotificationGroupingRange_Never; + } prv_save_changed_vibe_scores_to_file(&file, orig_vibe_score_notifications, orig_vibe_score_incoming_calls, orig_vibe_score_alarms, @@ -439,6 +446,18 @@ void alerts_preferences_set_notification_backlight(bool enable) { SET_PREF(PREF_KEY_NOTIF_BACKLIGHT, s_notification_backlight); } +NotificationGroupingRange alerts_preferences_get_notification_grouping_range(void) { + return (s_notification_grouping_range < NotificationGroupingRangeCount) + ? s_notification_grouping_range + : NotificationGroupingRange_Never; +} + +void alerts_preferences_set_notification_grouping_range(NotificationGroupingRange range) { + s_notification_grouping_range = + (range < NotificationGroupingRangeCount) ? range : NotificationGroupingRange_Never; + SET_PREF(PREF_KEY_NOTIF_GROUPING_RANGE, s_notification_grouping_range); +} + NotificationStatusBarStyle alerts_preferences_get_notification_status_bar_style(void) { return s_notification_status_bar_style; } @@ -726,6 +745,7 @@ void alerts_preferences_handle_blob_db_event(PebbleBlobDBEvent *event) { RELOAD_IF_MATCH(PREF_KEY_NOTIF_DESIGN_STYLE, s_notification_alternative_design); RELOAD_IF_MATCH(PREF_KEY_NOTIF_VIBE_DELAY, s_notification_vibe_delay); RELOAD_IF_MATCH(PREF_KEY_NOTIF_BACKLIGHT, s_notification_backlight); + RELOAD_IF_MATCH(PREF_KEY_NOTIF_GROUPING_RANGE, s_notification_grouping_range); RELOAD_IF_MATCH(PREF_KEY_NOTIF_STATUS_BAR_STYLE, s_notification_status_bar_style); RELOAD_IF_MATCH(PREF_KEY_DND_MOTION_BACKLIGHT, s_dnd_motion_backlight); RELOAD_IF_MATCH(PREF_KEY_DND_TOUCH_BACKLIGHT, s_dnd_touch_backlight); From 130bf838c5d78ed80dba06eda1bf305dd7dc4276 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 18:02:10 +0200 Subject: [PATCH 3/9] notifications: add sender grouping model Track notification IDs by sender while preserving chronological ordering and removal behavior. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications_history.c | 274 ++++++++++++++++++ src/fw/apps/system/notifications_history.h | 55 ++++ .../test_notifications_history.c | 268 +++++++++++++++++ .../system_apps/notifications/wscript_build | 8 + 4 files changed, 605 insertions(+) create mode 100644 src/fw/apps/system/notifications_history.c create mode 100644 src/fw/apps/system/notifications_history.h create mode 100644 tests/fw/apps/system_apps/notifications/test_notifications_history.c create mode 100644 tests/fw/apps/system_apps/notifications/wscript_build diff --git a/src/fw/apps/system/notifications_history.c b/src/fw/apps/system/notifications_history.c new file mode 100644 index 0000000000..83b473be51 --- /dev/null +++ b/src/fw/apps/system/notifications_history.c @@ -0,0 +1,274 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "notifications_history.h" + +#include "kernel/pbl_malloc.h" +#include "pbl/services/timeline/attribute.h" +#include "pbl/services/timeline/timeline.h" + +#include +#include + +typedef struct StringRange { + const char *start; + size_t length; +} StringRange; + +static int prv_compare_position(time_t timestamp_a, uint32_t sequence_a, time_t timestamp_b, + uint32_t sequence_b) { + if (timestamp_a > timestamp_b) { + return -1; + } + if (timestamp_a < timestamp_b) { + return 1; + } + if (sequence_a > sequence_b) { + return -1; + } + if (sequence_a < sequence_b) { + return 1; + } + return 0; +} + +static int prv_row_comparator(void *a, void *b) { + NotificationHistoryRow *row_a = a; + NotificationHistoryRow *row_b = b; + return prv_compare_position(row_a->timestamp, row_a->sequence, row_b->timestamp, row_b->sequence); +} + +static int prv_member_comparator(void *a, void *b) { + NotificationHistoryMember *member_a = a; + NotificationHistoryMember *member_b = b; + return prv_compare_position(member_a->timestamp, member_a->sequence, member_b->timestamp, + member_b->sequence); +} + +static StringRange prv_trimmed_string_range(const char *string) { + if (!string) { + return (StringRange){}; + } + + const char *start = string; + while (*start && isspace((unsigned char)*start)) { + start++; + } + + const char *end = start + strlen(start); + while (end > start && isspace((unsigned char)*(end - 1))) { + end--; + } + + return (StringRange){ + .start = start, + .length = (size_t)(end - start), + }; +} + +static bool prv_group_sender_for_item(const TimelineItem *item, StringRange *sender_out) { + static const Uuid s_android_notifications_source = UUID_NOTIFICATIONS_DATA_SOURCE; + + if (item->header.ancs_notif || + !uuid_equal(&item->header.parent_id, &s_android_notifications_source)) { + return false; + } + + const char *sender = attribute_get_string(&item->attr_list, AttributeIdSender, NULL); + if (!sender) { + sender = attribute_get_string(&item->attr_list, AttributeIdTitle, NULL); + } + + *sender_out = prv_trimmed_string_range(sender); + return sender_out->length > 0; +} + +static NotificationHistoryRow *prv_find_group(NotificationHistory *history, + const StringRange *sender) { + NotificationHistoryRow *row = history->rows; + while (row) { + if (row->is_group && strlen(row->group.sender) == sender->length && + memcmp(row->group.sender, sender->start, sender->length) == 0) { + return row; + } + row = (NotificationHistoryRow *)list_get_next(&row->node); + } + return NULL; +} + +static void prv_insert_row_sorted(NotificationHistory *history, NotificationHistoryRow *row) { + history->rows = (NotificationHistoryRow *)list_sorted_add((ListNode *)history->rows, &row->node, + prv_row_comparator, false); +} + +static NotificationHistoryRow *prv_create_individual_row(NotificationHistory *history, + const CommonTimelineItemHeader *header) { + NotificationHistoryRow *row = app_malloc_check(sizeof(*row)); + *row = (NotificationHistoryRow){ + .is_group = false, + .timestamp = header->timestamp, + .sequence = history->next_sequence++, + .notification_id = header->id, + }; + list_init(&row->node); + return row; +} + +static NotificationHistoryMember *prv_create_member(NotificationHistory *history, + const CommonTimelineItemHeader *header) { + NotificationHistoryMember *member = app_malloc_check(sizeof(*member)); + *member = (NotificationHistoryMember){ + .id = header->id, + .timestamp = header->timestamp, + .sequence = history->next_sequence++, + }; + list_init(&member->node); + return member; +} + +static NotificationHistoryRow *prv_create_group(NotificationHistory *history, + const StringRange *sender) { + NotificationHistoryRow *row = app_malloc_check(sizeof(*row)); + *row = (NotificationHistoryRow){ + .is_group = true, + }; + list_init(&row->node); + + row->group.sender = app_malloc_check(sender->length + 1); + memcpy(row->group.sender, sender->start, sender->length); + row->group.sender[sender->length] = '\0'; + return row; +} + +static void prv_free_group_members(NotificationHistoryMember *member) { + while (member) { + NotificationHistoryMember *next = (NotificationHistoryMember *)list_get_next(&member->node); + app_free(member); + member = next; + } +} + +static void prv_free_row(NotificationHistoryRow *row) { + if (row->is_group) { + prv_free_group_members(row->group.members); + app_free(row->group.sender); + } + app_free(row); +} + +void notifications_history_init(NotificationHistory *history, bool group_by_sender, + time_t grouping_cutoff) { + *history = (NotificationHistory){ + .group_by_sender = group_by_sender, + .grouping_cutoff = grouping_cutoff, + }; +} + +void notifications_history_deinit(NotificationHistory *history) { + NotificationHistoryRow *row = history->rows; + while (row) { + NotificationHistoryRow *next = (NotificationHistoryRow *)list_get_next(&row->node); + prv_free_row(row); + row = next; + } + history->rows = NULL; +} + +void notifications_history_add_header(NotificationHistory *history, + const CommonTimelineItemHeader *header) { + NotificationHistoryRow *row = prv_create_individual_row(history, header); + if (history->group_by_sender) { + prv_insert_row_sorted(history, row); + } else { + history->rows = (NotificationHistoryRow *)list_prepend((ListNode *)history->rows, &row->node); + } +} + +void notifications_history_add_item(NotificationHistory *history, const TimelineItem *item) { + if (!history->group_by_sender || item->header.timestamp < history->grouping_cutoff) { + notifications_history_add_header(history, &item->header); + return; + } + + StringRange sender; + if (!prv_group_sender_for_item(item, &sender)) { + notifications_history_add_header(history, &item->header); + return; + } + + NotificationHistoryRow *row = prv_find_group(history, &sender); + if (!row) { + row = prv_create_group(history, &sender); + } else { + list_remove(&row->node, (ListNode **)&history->rows, NULL); + } + + NotificationHistoryMember *member = prv_create_member(history, &item->header); + row->group.members = (NotificationHistoryMember *)list_sorted_add( + (ListNode *)row->group.members, &member->node, prv_member_comparator, false); + row->group.count++; + row->timestamp = row->group.members->timestamp; + row->sequence = row->group.members->sequence; + prv_insert_row_sorted(history, row); +} + +bool notifications_history_remove(NotificationHistory *history, const Uuid *id) { + NotificationHistoryRow *row = history->rows; + while (row) { + if (!row->is_group) { + if (uuid_equal(&row->notification_id, id)) { + list_remove(&row->node, (ListNode **)&history->rows, NULL); + prv_free_row(row); + return true; + } + } else { + NotificationHistoryMember *member = row->group.members; + while (member && !uuid_equal(&member->id, id)) { + member = (NotificationHistoryMember *)list_get_next(&member->node); + } + if (member) { + const bool removed_latest = (member == row->group.members); + list_remove(&member->node, (ListNode **)&row->group.members, NULL); + app_free(member); + row->group.count--; + + if (row->group.count == 0) { + list_remove(&row->node, (ListNode **)&history->rows, NULL); + prv_free_row(row); + } else if (removed_latest) { + list_remove(&row->node, (ListNode **)&history->rows, NULL); + row->timestamp = row->group.members->timestamp; + row->sequence = row->group.members->sequence; + prv_insert_row_sorted(history, row); + } + return true; + } + } + row = (NotificationHistoryRow *)list_get_next(&row->node); + } + return false; +} + +uint16_t notifications_history_get_row_count(const NotificationHistory *history) { + return (uint16_t)list_count((ListNode *)history->rows); +} + +NotificationHistoryRow *notifications_history_get_row(const NotificationHistory *history, + uint16_t index) { + return (NotificationHistoryRow *)list_get_at((ListNode *)history->rows, index); +} + +bool notifications_history_row_is_collapsed_group(const NotificationHistoryRow *row) { + return row->is_group && row->group.count > 1; +} + +const Uuid *notifications_history_row_get_latest_id(const NotificationHistoryRow *row) { + if (row->is_group) { + return row->group.members ? &row->group.members->id : NULL; + } + return &row->notification_id; +} + +uint16_t notifications_history_row_get_count(const NotificationHistoryRow *row) { + return row->is_group ? row->group.count : 1; +} diff --git a/src/fw/apps/system/notifications_history.h b/src/fw/apps/system/notifications_history.h new file mode 100644 index 0000000000..d27cdef249 --- /dev/null +++ b/src/fw/apps/system/notifications_history.h @@ -0,0 +1,55 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#pragma once + +#include "pbl/services/timeline/item.h" +#include "pbl/util/list.h" + +#include +#include + +typedef struct NotificationHistoryMember { + ListNode node; + Uuid id; + time_t timestamp; + uint32_t sequence; +} NotificationHistoryMember; + +typedef struct NotificationHistoryRow { + ListNode node; + bool is_group; + time_t timestamp; + uint32_t sequence; + union { + Uuid notification_id; + struct { + char *sender; + NotificationHistoryMember *members; + uint16_t count; + } group; + }; +} NotificationHistoryRow; + +typedef struct NotificationHistory { + NotificationHistoryRow *rows; + bool group_by_sender; + time_t grouping_cutoff; + uint32_t next_sequence; +} NotificationHistory; + +void notifications_history_init(NotificationHistory *history, bool group_by_sender, + time_t grouping_cutoff); +void notifications_history_deinit(NotificationHistory *history); + +void notifications_history_add_header(NotificationHistory *history, + const CommonTimelineItemHeader *header); +void notifications_history_add_item(NotificationHistory *history, const TimelineItem *item); +bool notifications_history_remove(NotificationHistory *history, const Uuid *id); + +uint16_t notifications_history_get_row_count(const NotificationHistory *history); +NotificationHistoryRow *notifications_history_get_row(const NotificationHistory *history, + uint16_t index); +bool notifications_history_row_is_collapsed_group(const NotificationHistoryRow *row); +const Uuid *notifications_history_row_get_latest_id(const NotificationHistoryRow *row); +uint16_t notifications_history_row_get_count(const NotificationHistoryRow *row); diff --git a/tests/fw/apps/system_apps/notifications/test_notifications_history.c b/tests/fw/apps/system_apps/notifications/test_notifications_history.c new file mode 100644 index 0000000000..24ffc86c23 --- /dev/null +++ b/tests/fw/apps/system_apps/notifications/test_notifications_history.c @@ -0,0 +1,268 @@ +/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-License-Identifier: Apache-2.0 */ + +#include "apps/system/notifications_history.h" + +#include "clar.h" +#include "fake_pbl_malloc.h" +#include "pbl/services/timeline/attribute.h" +#include "pbl/services/timeline/timeline.h" + +static NotificationHistory s_history; + +const char *attribute_get_string(const AttributeList *attr_list, AttributeId id, + char *default_value) { + for (uint8_t i = 0; i < attr_list->num_attributes; i++) { + if (attr_list->attributes[i].id == id) { + return attr_list->attributes[i].cstring; + } + } + return default_value; +} + +static Uuid prv_id(uint8_t value) { + return UuidMake(value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); +} + +static void prv_add_attribute(uint8_t id, time_t timestamp, AttributeId attribute_id, + const char *value) { + static const Uuid s_android_notifications_source = UUID_NOTIFICATIONS_DATA_SOURCE; + Attribute attribute = { + .id = attribute_id, + .cstring = (char *)value, + }; + TimelineItem item = { + .header = + { + .id = prv_id(id), + .parent_id = s_android_notifications_source, + .timestamp = timestamp, + .type = TimelineItemTypeNotification, + }, + .attr_list = + { + .num_attributes = value ? 1 : 0, + .attributes = value ? &attribute : NULL, + }, + }; + notifications_history_add_item(&s_history, &item); +} + +static void prv_add(uint8_t id, time_t timestamp, const char *sender) { + prv_add_attribute(id, timestamp, AttributeIdSender, sender); +} + +static void prv_add_ios(uint8_t id, time_t timestamp, const char *sender) { + Attribute sender_attribute = { + .id = AttributeIdSender, + .cstring = (char *)sender, + }; + TimelineItem item = { + .header = + { + .id = prv_id(id), + .timestamp = timestamp, + .type = TimelineItemTypeNotification, + .ancs_notif = true, + }, + .attr_list = + { + .num_attributes = 1, + .attributes = &sender_attribute, + }, + }; + notifications_history_add_item(&s_history, &item); +} + +static NotificationHistoryRow *prv_row(uint16_t index) { + return notifications_history_get_row(&s_history, index); +} + +static void prv_assert_id(const Uuid *id, uint8_t expected) { + Uuid expected_id = prv_id(expected); + cl_assert(uuid_equal(id, &expected_id)); +} + +void test_notifications_history__initialize(void) { + fake_pbl_malloc_clear_tracking(); + notifications_history_init(&s_history, true, 0); +} + +void test_notifications_history__cleanup(void) { + notifications_history_deinit(&s_history); + fake_pbl_malloc_check_net_allocs(); + fake_pbl_malloc_clear_tracking(); +} + +void test_notifications_history__same_sender_forms_one_group(void) { + prv_add(1, 100, "Anna"); + prv_add(2, 200, "Anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); + cl_assert(prv_row(0)->is_group); + cl_assert_equal_i(notifications_history_row_get_count(prv_row(0)), 2); + cl_assert(notifications_history_row_is_collapsed_group(prv_row(0))); + cl_assert_equal_s(prv_row(0)->group.sender, "Anna"); + prv_assert_id(notifications_history_row_get_latest_id(prv_row(0)), 2); +} + +void test_notifications_history__single_message_is_not_collapsed_group(void) { + prv_add(1, 100, "Anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); + cl_assert(!notifications_history_row_is_collapsed_group(prv_row(0))); + prv_assert_id(notifications_history_row_get_latest_id(prv_row(0)), 1); +} + +void test_notifications_history__different_senders_form_separate_groups(void) { + prv_add(1, 100, "Anna"); + prv_add(2, 200, "Bob"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 2); + cl_assert_equal_s(prv_row(0)->group.sender, "Bob"); + cl_assert_equal_s(prv_row(1)->group.sender, "Anna"); + cl_assert(!notifications_history_row_is_collapsed_group(prv_row(0))); + cl_assert(!notifications_history_row_is_collapsed_group(prv_row(1))); +} + +void test_notifications_history__title_is_used_when_sender_is_missing(void) { + prv_add_attribute(1, 100, AttributeIdTitle, "Anna"); + prv_add_attribute(2, 200, AttributeIdTitle, "Anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); + cl_assert_equal_s(prv_row(0)->group.sender, "Anna"); + cl_assert_equal_i(prv_row(0)->group.count, 2); +} + +void test_notifications_history__body_is_not_used_as_group_key(void) { + prv_add_attribute(1, 100, AttributeIdBody, "Same body"); + prv_add_attribute(2, 200, AttributeIdBody, "Same body"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 2); + cl_assert(!prv_row(0)->is_group); + cl_assert(!prv_row(1)->is_group); +} + +void test_notifications_history__groups_and_members_are_newest_first(void) { + prv_add(1, 300, "Anna"); + prv_add(2, 100, "Anna"); + prv_add(3, 200, "Anna"); + prv_add(4, 250, "Bob"); + + cl_assert_equal_s(prv_row(0)->group.sender, "Anna"); + NotificationHistoryMember *member = prv_row(0)->group.members; + prv_assert_id(&member->id, 1); + member = (NotificationHistoryMember *)list_get_next(&member->node); + prv_assert_id(&member->id, 3); + member = (NotificationHistoryMember *)list_get_next(&member->node); + prv_assert_id(&member->id, 2); + cl_assert_equal_s(prv_row(1)->group.sender, "Bob"); +} + +void test_notifications_history__equal_timestamps_use_insertion_order(void) { + prv_add(1, 100, "Anna"); + prv_add(2, 100, "Anna"); + + NotificationHistoryMember *member = prv_row(0)->group.members; + prv_assert_id(&member->id, 2); + member = (NotificationHistoryMember *)list_get_next(&member->node); + prv_assert_id(&member->id, 1); +} + +void test_notifications_history__missing_sender_and_ios_remain_individual(void) { + prv_add(1, 100, NULL); + prv_add(2, 200, " "); + prv_add_ios(3, 300, "Anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 3); + cl_assert(!prv_row(0)->is_group); + cl_assert(!prv_row(1)->is_group); + cl_assert(!prv_row(2)->is_group); + prv_assert_id(&prv_row(0)->notification_id, 3); + prv_assert_id(&prv_row(1)->notification_id, 2); + prv_assert_id(&prv_row(2)->notification_id, 1); +} + +void test_notifications_history__sender_whitespace_is_trimmed_without_case_folding(void) { + prv_add(1, 100, " Anna "); + prv_add(2, 200, "Anna"); + prv_add(3, 300, "anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 2); + cl_assert_equal_s(prv_row(0)->group.sender, "anna"); + cl_assert_equal_s(prv_row(1)->group.sender, "Anna"); + cl_assert_equal_i(prv_row(1)->group.count, 2); +} + +void test_notifications_history__removing_notifications_updates_and_removes_group(void) { + prv_add(1, 100, "Anna"); + prv_add(2, 300, "Anna"); + prv_add(3, 200, "Bob"); + + Uuid id = prv_id(2); + cl_assert(notifications_history_remove(&s_history, &id)); + cl_assert_equal_s(prv_row(0)->group.sender, "Bob"); + cl_assert_equal_i(prv_row(1)->group.count, 1); + cl_assert(!notifications_history_row_is_collapsed_group(prv_row(1))); + prv_assert_id(notifications_history_row_get_latest_id(prv_row(1)), 1); + + id = prv_id(1); + cl_assert(notifications_history_remove(&s_history, &id)); + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); + cl_assert_equal_s(prv_row(0)->group.sender, "Bob"); +} + +void test_notifications_history__mixed_grouped_and_individual_notifications(void) { + prv_add(1, 100, "Anna"); + prv_add(2, 400, NULL); + prv_add(3, 300, "Anna"); + prv_add(4, 200, "Bob"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 3); + prv_assert_id(&prv_row(0)->notification_id, 2); + cl_assert_equal_s(prv_row(1)->group.sender, "Anna"); + cl_assert_equal_i(prv_row(1)->group.count, 2); + cl_assert_equal_s(prv_row(2)->group.sender, "Bob"); +} + +void test_notifications_history__disabled_preserves_storage_iteration_order(void) { + notifications_history_deinit(&s_history); + notifications_history_init(&s_history, false, 0); + + CommonTimelineItemHeader first = { + .id = prv_id(1), + .timestamp = 300, + }; + CommonTimelineItemHeader second = { + .id = prv_id(2), + .timestamp = 100, + }; + CommonTimelineItemHeader third = { + .id = prv_id(3), + .timestamp = 200, + }; + notifications_history_add_header(&s_history, &first); + notifications_history_add_header(&s_history, &second); + notifications_history_add_header(&s_history, &third); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 3); + prv_assert_id(&prv_row(0)->notification_id, 3); + prv_assert_id(&prv_row(1)->notification_id, 2); + prv_assert_id(&prv_row(2)->notification_id, 1); +} + +void test_notifications_history__only_notifications_within_range_are_grouped(void) { + notifications_history_deinit(&s_history); + notifications_history_init(&s_history, true, 1000); + + prv_add(1, 900, "Anna"); + prv_add(2, 1000, "Anna"); + prv_add(3, 1100, "Anna"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 2); + cl_assert(prv_row(0)->is_group); + cl_assert_equal_i(prv_row(0)->group.count, 2); + prv_assert_id(notifications_history_row_get_latest_id(prv_row(0)), 3); + cl_assert(!prv_row(1)->is_group); + prv_assert_id(&prv_row(1)->notification_id, 1); +} diff --git a/tests/fw/apps/system_apps/notifications/wscript_build b/tests/fw/apps/system_apps/notifications/wscript_build new file mode 100644 index 0000000000..2dc4740fbe --- /dev/null +++ b/tests/fw/apps/system_apps/notifications/wscript_build @@ -0,0 +1,8 @@ +from tools.waf.pebble_test import clar + +clar(ctx, + sources_ant_glob="src/fw/apps/system/notifications_history.c", + test_sources_ant_glob="test_notifications_history.c", + override_includes=['dummy_board']) + +# vim:filetype=python From dba58ddfce86beb7ae274abc7d95ce71ab345348 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 18:02:14 +0200 Subject: [PATCH 4/9] notifications: group history by sender Display collapsed sender rows and open grouped notifications in a newest-first transcript. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications.c | 418 ++++++++++++++---- .../notifications/notification_window.c | 18 +- .../notifications/notification_window.h | 2 + .../notification_window_private.h | 1 + 4 files changed, 339 insertions(+), 100 deletions(-) diff --git a/src/fw/apps/system/notifications.c b/src/fw/apps/system/notifications.c index f211b014d4..9569730415 100644 --- a/src/fw/apps/system/notifications.c +++ b/src/fw/apps/system/notifications.c @@ -2,6 +2,7 @@ /* SPDX-License-Identifier: Apache-2.0 */ #include "notifications.h" +#include "notifications_history.h" #include #include @@ -24,8 +25,11 @@ #include "popups/notifications/notification_window.h" #include "process_state/app_state/app_state.h" #include "resource/resource_ids.auto.h" +#include "pbl/drivers/rtc.h" #include "pbl/services/i18n/i18n.h" #include "pbl/services/blob_db/pin_db.h" +#include "pbl/services/clock.h" +#include "pbl/services/notifications/alerts_preferences_private.h" #include "pbl/services/notifications/notification_storage.h" #include "pbl/services/timeline/notification_layout.h" #include "shell/prefs.h" @@ -34,6 +38,7 @@ #include "util/date.h" #include "pbl/util/list.h" #include "pbl/util/string.h" +#include "util/time/time.h" typedef struct LoadedNotificationNode { ListNode node; @@ -42,24 +47,34 @@ typedef struct LoadedNotificationNode { bool icon_is_default; } LoadedNotificationNode; -typedef struct NotificationNode { - ListNode node; - Uuid id; -} NotificationNode; +typedef struct NotificationGroupWindow NotificationGroupWindow; typedef struct NotificationsData { Window window; MenuLayer menu_layer; TextLayer text_layer; - NotificationNode *notification_list; + NotificationHistory history; LoadedNotificationNode *loaded_notification_list; EventServiceInfo notification_event_info; ActionableDialog *actionable_dialog; + char *group_title; + size_t group_title_size; + NotificationGroupWindow *group_window; #if PBL_ROUND StatusBarLayer status_bar_layer; #endif } NotificationsData; +struct NotificationGroupWindow { + Window window; + MenuLayer menu_layer; + NotificationsData *notifications_data; + Uuid *notification_ids; + uint16_t count; + char *sender; + char time_buffer[16]; +}; + static NotificationsData *s_data = NULL; static const unsigned int MAX_ACTIVE_NOTIFICATIONS = 6; @@ -70,18 +85,6 @@ static bool prv_loaded_notification_list_filter_cb(ListNode *node, void *data) { return uuid_equal(&loaded_notification->notification.header.id, id); } -static bool prv_notification_list_filter_cb(ListNode *node, void *data) { - NotificationNode *notification = (NotificationNode *)node; - Uuid *id = data; - return uuid_equal(¬ification->id, id); -} - -static NotificationNode *prv_find_notification(NotificationNode *list, Uuid *id) { - return (NotificationNode *)list_find((ListNode *)list, - prv_notification_list_filter_cb, - id); -} - static LoadedNotificationNode *prv_find_loaded_notification(LoadedNotificationNode *list, Uuid *id) { return (LoadedNotificationNode *)list_find((ListNode *)list, @@ -89,49 +92,29 @@ static LoadedNotificationNode *prv_find_loaded_notification(LoadedNotificationNo id); } -static NotificationNode *prv_notification_list_add_notification_by_id( - NotificationNode **notification_list, Uuid *id) { - NotificationNode *new_node = app_malloc_check(sizeof(NotificationNode)); - - list_init((ListNode*) new_node); - new_node->id = *id; - - *notification_list = (NotificationNode*) list_prepend((ListNode*) *notification_list, - (ListNode*) new_node); - - return new_node; -} - -static void prv_notification_list_remove_notification_by_id( - NotificationNode **notification_list, Uuid *id) { - - NotificationNode *node = prv_find_notification(*notification_list, id); - list_remove((ListNode *)node, (ListNode **)notification_list, NULL); -} - -static NotificationNode *prv_add_notification(NotificationsData *data, Uuid *id) { - NotificationNode *node = prv_notification_list_add_notification_by_id(&data->notification_list, - id); - return node; -} - -static void prv_remove_notification(NotificationsData *data, Uuid *id) { - prv_notification_list_remove_notification_by_id(&data->notification_list, id); -} - static bool prv_notif_iterator_callback(void *data, SerializedTimelineItemHeader *header) { - return (prv_add_notification(data, &header->common.id) != NULL); + NotificationsData *notifications_data = data; + notifications_history_add_header(¬ifications_data->history, &header->common); + return true; } -static void prv_load_notification_storage(NotificationsData *data) { - notification_storage_iterate(&prv_notif_iterator_callback, data); +static bool prv_notif_item_iterator_callback(void *data, const CommonTimelineItemHeader *header, + const TimelineItem *item) { + NotificationsData *notifications_data = data; + if (item) { + notifications_history_add_item(¬ifications_data->history, item); + } else { + notifications_history_add_header(¬ifications_data->history, header); + } + return true; } -static void prv_notification_list_deinit(NotificationNode *notification_list) { - while (notification_list) { - NotificationNode *node = notification_list; - notification_list = (NotificationNode*) list_pop_head((ListNode*) notification_list); - app_free(node); +static void prv_load_notification_storage(NotificationsData *data) { + if (data->history.group_by_sender) { + notification_storage_iterate_items_after(data->history.grouping_cutoff, + prv_notif_item_iterator_callback, data); + } else { + notification_storage_iterate(&prv_notif_iterator_callback, data); } } @@ -142,18 +125,18 @@ static void prv_unload_loaded_notification(LoadedNotificationNode *loaded_notif) } static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( - LoadedNotificationNode **loaded_list, NotificationNode *node) { - if (node == NULL) { + LoadedNotificationNode **loaded_list, const Uuid *id) { + if (id == NULL) { return NULL; } - LoadedNotificationNode *loaded_node = prv_find_loaded_notification(*loaded_list, &node->id); + LoadedNotificationNode *loaded_node = prv_find_loaded_notification(*loaded_list, (Uuid *)id); if (loaded_node) { return loaded_node; } // unload old notifications - if (list_count((ListNode*) *loaded_list) > MAX_ACTIVE_NOTIFICATIONS) { + if (list_count((ListNode *)*loaded_list) >= MAX_ACTIVE_NOTIFICATIONS) { LoadedNotificationNode *old_node = (LoadedNotificationNode*) list_get_tail( (ListNode*) *loaded_list); list_remove((ListNode*) old_node, (ListNode**) loaded_list, NULL); @@ -162,7 +145,7 @@ static NOINLINE LoadedNotificationNode *prv_loaded_notification_list_load_item( // load the notification TimelineItem notification; - if (!notification_storage_get(&node->id, ¬ification)) { + if (!notification_storage_get((Uuid *)id, ¬ification)) { return NULL; } @@ -209,9 +192,152 @@ static void prv_loaded_notification_list_deinit(LoadedNotificationNode *loaded_l } } +static void prv_notifications_history_init(NotificationsData *data) { + const NotificationGroupingRange range = alerts_preferences_get_notification_grouping_range(); + time_t cutoff = 0; + time_t window = 0; + + if (range == NotificationGroupingRange_OneDay) { + window = SECONDS_PER_DAY; + } else if (range == NotificationGroupingRange_OneWeek) { + window = 7 * SECONDS_PER_DAY; + } + + if (window > 0) { + const time_t now = rtc_get_time(); + cutoff = (now > window) ? now - window : 0; + } + + notifications_history_init(&data->history, range != NotificationGroupingRange_Never, cutoff); +} + +static bool prv_push_single_notification_window(const Uuid *id) { + notification_window_init_history(false); + if (notification_window_is_modal()) { + return false; + } + + notification_window_add_notification_by_id((Uuid *)id); + notification_window_show(); + notification_window_focus_notification((Uuid *)id, false); + return true; +} + +static uint16_t prv_group_window_get_num_rows(MenuLayer *menu_layer, uint16_t section_index, + void *context) { + NotificationGroupWindow *group_window = context; + return group_window->count; +} + +static int16_t prv_group_window_get_header_height(MenuLayer *menu_layer, uint16_t section_index, + void *context) { + return MENU_CELL_BASIC_HEADER_HEIGHT; +} + +static int16_t prv_group_window_get_cell_height(MenuLayer *menu_layer, MenuIndex *cell_index, + void *context) { + return menu_cell_basic_cell_height(); +} + +static void prv_group_window_draw_header(GContext *ctx, const Layer *cell_layer, + uint16_t section_index, void *context) { + NotificationGroupWindow *group_window = context; + menu_cell_basic_header_draw(ctx, cell_layer, group_window->sender); +} + +static void prv_group_window_draw_row(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, + void *context) { + NotificationGroupWindow *group_window = context; + if (cell_index->row >= group_window->count) { + return; + } + + LoadedNotificationNode *loaded_node = prv_loaded_notification_list_load_item( + &group_window->notifications_data->loaded_notification_list, + &group_window->notification_ids[cell_index->row]); + if (!loaded_node) { + return; + } + + TimelineItem *notification = &loaded_node->notification; + const char *message = attribute_get_string(¬ification->attr_list, AttributeIdBody, ""); + if (IS_EMPTY_STRING(message)) { + message = attribute_get_string(¬ification->attr_list, AttributeIdSubtitle, ""); + } + if (IS_EMPTY_STRING(message)) { + message = attribute_get_string(¬ification->attr_list, AttributeIdTitle, "[Empty]"); + } + + clock_copy_time_string_timestamp(group_window->time_buffer, sizeof(group_window->time_buffer), + notification->header.timestamp); + menu_cell_basic_draw(ctx, cell_layer, message, group_window->time_buffer, NULL); +} + +static void prv_group_window_select(MenuLayer *menu_layer, MenuIndex *cell_index, void *context) { + NotificationGroupWindow *group_window = context; + if (cell_index->row < group_window->count) { + prv_push_single_notification_window(&group_window->notification_ids[cell_index->row]); + } +} + +static void prv_group_window_load(Window *window) { + NotificationGroupWindow *group_window = window_get_user_data(window); + MenuLayer *menu_layer = &group_window->menu_layer; + menu_layer_init(menu_layer, &window->layer.bounds); + menu_layer_set_callbacks(menu_layer, group_window, + &(MenuLayerCallbacks){ + .get_num_rows = prv_group_window_get_num_rows, + .get_header_height = prv_group_window_get_header_height, + .get_cell_height = prv_group_window_get_cell_height, + .draw_header = prv_group_window_draw_header, + .draw_row = prv_group_window_draw_row, + .select_click = prv_group_window_select, + }); + menu_layer_set_normal_colors(menu_layer, GColorWhite, GColorBlack); + menu_layer_set_highlight_colors( + menu_layer, PBL_IF_COLOR_ELSE(DEFAULT_NOTIFICATION_COLOR, GColorBlack), GColorWhite); + menu_layer_set_click_config_onto_window(menu_layer, window); + menu_layer_set_scroll_wrap_around(menu_layer, false); + layer_add_child(&window->layer, menu_layer_get_layer(menu_layer)); + menu_layer_set_selected_index(menu_layer, MenuIndex(0, 0), MenuRowAlignTop, false); +} + +static void prv_group_window_unload(Window *window) { + NotificationGroupWindow *group_window = window_get_user_data(window); + menu_layer_deinit(&group_window->menu_layer); + group_window->notifications_data->group_window = NULL; + app_free(group_window->notification_ids); + app_free(group_window->sender); + app_free(group_window); +} + +static void prv_push_group_window(NotificationsData *data, const NotificationHistoryRow *row) { + NotificationGroupWindow *group_window = app_zalloc_check(sizeof(*group_window)); + group_window->notifications_data = data; + group_window->count = row->group.count; + group_window->sender = app_strdup(row->group.sender); + group_window->notification_ids = app_malloc_check(sizeof(Uuid) * group_window->count); + + NotificationHistoryMember *member = row->group.members; + for (uint16_t i = 0; i < group_window->count; i++) { + group_window->notification_ids[i] = member->id; + member = (NotificationHistoryMember *)list_get_next(&member->node); + } + + window_init(&group_window->window, WINDOW_NAME("Notification Group")); + window_set_user_data(&group_window->window, group_window); + window_set_window_handlers(&group_window->window, &(WindowHandlers){ + .load = prv_group_window_load, + .unload = prv_group_window_unload, + }); + data->group_window = group_window; + app_window_stack_push(&group_window->window, true); +} + // Return true if successful -static bool prv_push_notification_window(NotificationsData *data) { - notification_window_init(false /*is_modal*/); +static bool prv_push_notification_window(NotificationsData *data, + NotificationHistoryRow *selected_row) { + notification_window_init_history(!data->history.group_by_sender); // Bail if a notification came in ahead of us and created a modal window // before we had a chance to react to the select button event. @@ -219,12 +345,16 @@ static bool prv_push_notification_window(NotificationsData *data) { return false; } - // iterate over visible items as visible (including the groups) in reverse order - // since notification_window shows each newly added notification first - NotificationNode *node = (NotificationNode*)list_get_tail(&data->notification_list->node); - while (node) { - notification_window_add_notification_by_id(&node->id); - node = (NotificationNode*)list_get_prev(&node->node); + if (data->history.group_by_sender) { + notification_window_add_notification_by_id( + (Uuid *)notifications_history_row_get_latest_id(selected_row)); + } else { + NotificationHistoryRow *row = + (NotificationHistoryRow *)list_get_tail(&data->history.rows->node); + while (row) { + notification_window_add_notification_by_id(&row->notification_id); + row = (NotificationHistoryRow *)list_get_prev(&row->node); + } } notification_window_show(); @@ -244,8 +374,8 @@ static void prv_confirmed_handler(ClickRecognizerRef recognizer, void *context) notification_storage_reset_and_init(); prv_loaded_notification_list_deinit(data->loaded_notification_list); data->loaded_notification_list = NULL; - prv_notification_list_deinit(data->notification_list); - data->notification_list = NULL; + notifications_history_deinit(&data->history); + prv_notifications_history_init(data); prv_load_notification_storage(data); actionable_dialog_pop(data->actionable_dialog); @@ -468,7 +598,7 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) { NotificationsData *notifications_data = data; - if ((notifications_data->notification_list) && (cell_index->row == 0)) { + if (notifications_data->history.rows && (cell_index->row == 0)) { // Clear All button selected prv_settings_clear_history_window_push(notifications_data); return; @@ -477,33 +607,39 @@ static void prv_select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, // shift index since the first one is hard coded to Clear int16_t notif_idx = cell_index->row - 1; - NotificationNode *node = (NotificationNode*) list_get_at( - (ListNode*) notifications_data->notification_list, notif_idx); - if (!node) { + NotificationHistoryRow *row = + notifications_history_get_row(¬ifications_data->history, notif_idx); + if (!row) { + return; + } + + if (notifications_history_row_is_collapsed_group(row)) { + prv_push_group_window(notifications_data, row); return; } - bool success = prv_push_notification_window(notifications_data); + bool success = prv_push_notification_window(notifications_data, row); if (!success) { // Bail if a notification came in ahead of us and created a modal window // before we had a chance to react to the select button event. return; } const bool animated = false; - notification_window_focus_notification(&node->id, animated); + notification_window_focus_notification((Uuid *)notifications_history_row_get_latest_id(row), + animated); } static uint16_t prv_get_num_rows_callback(struct MenuLayer *menu_layer, uint16_t section_index, void *data) { NotificationsData *notifications_data = data; - NotificationNode *node = notifications_data->notification_list; + NotificationHistoryRow *row = notifications_data->history.rows; // There's no notifications, don't draw anything - if (!node) { + if (!row) { return 0; } // add one for the CLEAR ALL at the top - return list_count((ListNode *)notifications_data->notification_list) + 1; + return notifications_history_get_row_count(¬ifications_data->history) + 1; } static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell_index, @@ -532,6 +668,23 @@ static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell })[runtime_platform_content_size]; } +static const char *prv_get_group_title(NotificationsData *data, const NotificationHistoryRow *row) { + const char *sender = row->group.sender; + const size_t required_size = strlen(sender) + 10; + if (required_size > data->group_title_size) { + char *group_title = app_realloc(data->group_title, required_size); + if (!group_title) { + return sender; + } + data->group_title = group_title; + data->group_title_size = required_size; + } + + snprintf(data->group_title, data->group_title_size, "%s (%u)", sender, + (unsigned int)row->group.count); + return data->group_title; +} + static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) { NotificationsData *notifications_data = data; @@ -565,14 +718,14 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn // shift index since the first one is hard coded to Clear const int16_t notif_idx = cell_index->row - 1; - NotificationNode *node = (NotificationNode*) list_get_at( - (ListNode*) notifications_data->notification_list, notif_idx); - if (!node) { + NotificationHistoryRow *row = + notifications_history_get_row(¬ifications_data->history, notif_idx); + if (!row) { return; } LoadedNotificationNode *loaded_node = prv_loaded_notification_list_load_item( - ¬ifications_data->loaded_notification_list, node); + ¬ifications_data->loaded_notification_list, notifications_history_row_get_latest_id(row)); if (!loaded_node) { return; } @@ -611,15 +764,20 @@ static void prv_draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIn WTF; } + if (notifications_history_row_is_collapsed_group(row)) { + title = prv_get_group_title(notifications_data, row); + subtitle = !IS_EMPTY_STRING(body) ? body : subtitle; + } + draw_cell(ctx, cell_layer, title, subtitle, loaded_node->icon); } // Display the appropriate layer static void prv_update_text_layer_visibility(NotificationsData *data) { - NotificationNode *node = data->notification_list; + NotificationHistoryRow *row = data->history.rows; // Toggle which layer is visible - if (node == NULL) { + if (row == NULL) { layer_set_hidden((Layer *) &data->menu_layer, true); layer_set_hidden((Layer *) &data->text_layer, false); } else { @@ -628,13 +786,80 @@ static void prv_update_text_layer_visibility(NotificationsData *data) { } } +static void prv_group_window_remove_notification(NotificationsData *data, const Uuid *id) { + NotificationGroupWindow *group_window = data->group_window; + if (!group_window) { + return; + } + + for (uint16_t i = 0; i < group_window->count; i++) { + if (!uuid_equal(&group_window->notification_ids[i], id)) { + continue; + } + + group_window->count--; + memmove(&group_window->notification_ids[i], &group_window->notification_ids[i + 1], + sizeof(Uuid) * (group_window->count - i)); + if (group_window->count <= 1) { + app_window_stack_remove(&group_window->window, false); + return; + } + + menu_layer_reload_data(&group_window->menu_layer); + const uint16_t selected_row = (i < group_window->count) ? i : group_window->count - 1; + menu_layer_set_selected_index(&group_window->menu_layer, MenuIndex(0, selected_row), + MenuRowAlignCenter, false); + return; + } +} + +static void prv_group_window_add_notification(NotificationsData *data, const Uuid *id) { + NotificationGroupWindow *group_window = data->group_window; + if (!group_window) { + return; + } + + NotificationHistoryRow *row = data->history.rows; + while (row) { + if (row->is_group && strcmp(row->group.sender, group_window->sender) == 0) { + NotificationHistoryMember *member = row->group.members; + uint16_t member_index = 0; + while (member) { + if (uuid_equal(&member->id, id)) { + Uuid *notification_ids = + app_realloc(group_window->notification_ids, sizeof(Uuid) * (group_window->count + 1)); + if (!notification_ids) { + return; + } + + group_window->notification_ids = notification_ids; + memmove(&group_window->notification_ids[member_index + 1], + &group_window->notification_ids[member_index], + sizeof(Uuid) * (group_window->count - member_index)); + group_window->notification_ids[member_index] = *id; + group_window->count++; + menu_layer_reload_data(&group_window->menu_layer); + menu_layer_set_selected_index(&group_window->menu_layer, MenuIndex(0, member_index), + MenuRowAlignCenter, false); + return; + } + member = (NotificationHistoryMember *)list_get_next(&member->node); + member_index++; + } + } + row = (NotificationHistoryRow *)list_get_next(&row->node); + } +} + static void prv_handle_notification_removed(Uuid *id) { - prv_remove_notification(s_data, id); + prv_group_window_remove_notification(s_data, id); + notifications_history_remove(&s_data->history, id); app_notification_window_remove_notification_by_id(id); } static void prv_handle_notification_acted_upon(Uuid *id) { - prv_remove_notification(s_data, id); + prv_group_window_remove_notification(s_data, id); + notifications_history_remove(&s_data->history, id); app_notification_window_remove_notification_by_id(id); } @@ -644,13 +869,13 @@ static void prv_handle_notification_added(Uuid *id) { return; } - prv_add_notification(s_data, id); + notifications_history_add_item(&s_data->history, ¬ification); + prv_group_window_add_notification(s_data, id); + timeline_item_free_allocated_buffer(¬ification); - // NOTE: To avoid having two flash reads, we only read and validate the notification once. - // We do it here, instead of in the function call below. If the above - // notification_storage validation above is removed, then we should at least validate - // it in the function call below. - app_notification_window_add_new_notification_by_id(id); + if (!s_data->history.group_by_sender) { + app_notification_window_add_new_notification_by_id(id); + } } static void prv_handle_notification(PebbleEvent *e, void *context) { @@ -671,7 +896,8 @@ static void prv_handle_notification(PebbleEvent *e, void *context) { if (action_result && (action_result->type == ActionResultTypeSuccess || action_result->type == ActionResultTypeSuccessANCSDismiss)) { - prv_remove_notification(s_data, &action_result->id); + prv_group_window_remove_notification(s_data, &action_result->id); + notifications_history_remove(&s_data->history, &action_result->id); app_notification_window_remove_notification_by_id(&action_result->id); } break; @@ -779,6 +1005,7 @@ static void prv_handle_init(void) { .handler = prv_handle_notification, }; event_service_client_subscribe(&data->notification_event_info); + prv_notifications_history_init(data); prv_load_notification_storage(data); prv_push_window(data); @@ -792,7 +1019,8 @@ static void prv_handle_deinit(void) { menu_layer_deinit(&data->menu_layer); event_service_client_unsubscribe(&data->notification_event_info); prv_loaded_notification_list_deinit(data->loaded_notification_list); - prv_notification_list_deinit(data->notification_list); + notifications_history_deinit(&data->history); + app_free(data->group_title); i18n_free_all(data); app_free(data); diff --git a/src/fw/popups/notifications/notification_window.c b/src/fw/popups/notifications/notification_window.c index 8d3540fc2e..0168751d95 100644 --- a/src/fw/popups/notifications/notification_window.c +++ b/src/fw/popups/notifications/notification_window.c @@ -895,7 +895,7 @@ static ActionMenuLevel *prv_create_action_menu_for_item(TimelineItem *item, !uuid_equal(&(Uuid)UUID_REMINDERS_DATA_SOURCE, &items_originator_id) && reminders_can_snooze(item)); - const bool has_dismiss_all_action = ((dismiss_action) && + const bool has_dismiss_all_action = (window_data->allow_dismiss_all && (dismiss_action) && (notifications_presented_list_count() > 1)); const bool has_quiet_time_action = true; // Always true const bool has_ancs_mute_action = prv_has_mute_action(item); @@ -1065,8 +1065,11 @@ static void prv_back_button_single_click_handler(ClickRecognizerRef recognizer, } static void prv_click_config_provider(void *data) { + NotificationWindowData *window_data = data; window_single_click_subscribe(BUTTON_ID_SELECT, prv_select_single_click_handler); - window_long_click_subscribe(BUTTON_ID_SELECT, 1000, prv_select_long_click_handler, NULL); + if (window_data->allow_dismiss_all) { + window_long_click_subscribe(BUTTON_ID_SELECT, 1000, prv_select_long_click_handler, NULL); + } window_set_click_context(BUTTON_ID_SELECT, data); window_single_click_subscribe(BUTTON_ID_BACK, prv_back_button_single_click_handler); @@ -1303,7 +1306,7 @@ static bool prv_action_button_touch_transparent(const Layer *layer, const GPoint } #endif -static void prv_init_notification_window(bool is_modal) { +static void prv_init_notification_window(bool is_modal, bool allow_dismiss_all) { NotificationWindowData *data = &s_notification_window_data; // init_notification_window() can be called from KernelMain when displaying an incoming @@ -1317,6 +1320,7 @@ static void prv_init_notification_window(bool is_modal) { s_in_use = true; data->pop_timer_is_final = false; data->is_modal = is_modal; + data->allow_dismiss_all = allow_dismiss_all; data->notification_app_id = UUID_INVALID; data->peek_layer_timer = EVENTED_TIMER_INVALID_ID; data->peek_animation = NULL; @@ -1412,7 +1416,7 @@ static void prv_init_notification_window(bool is_modal) { } void notification_window_init(bool is_modal) { - prv_init_notification_window(is_modal); + prv_init_notification_window(is_modal, true); if (is_modal && notification_window_is_modal()) { // If we didn't ask for a modal window, it means some other task already created it, @@ -1422,6 +1426,10 @@ void notification_window_init(bool is_modal) { } } +void notification_window_init_history(bool allow_dismiss_all) { + prv_init_notification_window(false, allow_dismiss_all); +} + void notification_window_show() { if (s_notification_window_data.is_modal) { return; @@ -1582,7 +1590,7 @@ static void prv_handle_notification_added_common(Uuid *id, NotificationType type } // will fail and return early if already init'ed. - prv_init_notification_window(true /*is_modal*/); + prv_init_notification_window(true /*is_modal*/, true /*allow_dismiss_all*/); if (!data->is_modal) { return; diff --git a/src/fw/popups/notifications/notification_window.h b/src/fw/popups/notifications/notification_window.h index 2ca90095d7..54fa994af0 100644 --- a/src/fw/popups/notifications/notification_window.h +++ b/src/fw/popups/notifications/notification_window.h @@ -14,6 +14,8 @@ void notification_window_service_init(void); void notification_window_init(bool is_modal); +void notification_window_init_history(bool allow_dismiss_all); + void notification_window_show(void); bool notification_window_is_modal(void); diff --git a/src/fw/popups/notifications/notification_window_private.h b/src/fw/popups/notifications/notification_window_private.h index c43400b4af..a8e4c957ab 100644 --- a/src/fw/popups/notifications/notification_window_private.h +++ b/src/fw/popups/notifications/notification_window_private.h @@ -18,6 +18,7 @@ typedef struct NotificationWindowData { bool pop_timer_is_final; // true, if pop_timer_id cannot be rescheduled anymore bool is_modal; + bool allow_dismiss_all; bool window_frozen; // Don't pop when performing an action via a hotkey until the action completes bool first_notif_loaded; From f000489a76736e9aaeccb464f42e46e055334dc8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 21:13:48 +0200 Subject: [PATCH 5/9] notifications: correct copyright attribution Attribute the new notification grouping sources and tests to their contributor. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications_history.c | 2 +- src/fw/apps/system/notifications_history.h | 2 +- .../apps/system_apps/notifications/test_notifications_history.c | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fw/apps/system/notifications_history.c b/src/fw/apps/system/notifications_history.c index 83b473be51..3a2cc9d21b 100644 --- a/src/fw/apps/system/notifications_history.c +++ b/src/fw/apps/system/notifications_history.c @@ -1,4 +1,4 @@ -/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-FileCopyrightText: 2026 Aliaksandr Karnilovich */ /* SPDX-License-Identifier: Apache-2.0 */ #include "notifications_history.h" diff --git a/src/fw/apps/system/notifications_history.h b/src/fw/apps/system/notifications_history.h index d27cdef249..2b0f1534a3 100644 --- a/src/fw/apps/system/notifications_history.h +++ b/src/fw/apps/system/notifications_history.h @@ -1,4 +1,4 @@ -/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-FileCopyrightText: 2026 Aliaksandr Karnilovich */ /* SPDX-License-Identifier: Apache-2.0 */ #pragma once diff --git a/tests/fw/apps/system_apps/notifications/test_notifications_history.c b/tests/fw/apps/system_apps/notifications/test_notifications_history.c index 24ffc86c23..d9516ec361 100644 --- a/tests/fw/apps/system_apps/notifications/test_notifications_history.c +++ b/tests/fw/apps/system_apps/notifications/test_notifications_history.c @@ -1,4 +1,4 @@ -/* SPDX-FileCopyrightText: 2026 Core Devices LLC */ +/* SPDX-FileCopyrightText: 2026 Aliaksandr Karnilovich */ /* SPDX-License-Identifier: Apache-2.0 */ #include "apps/system/notifications_history.h" From b84b1397e9f7307ed3232b11169831f11c7c326a Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 22:05:08 +0200 Subject: [PATCH 6/9] notifications: align grouped history UI Localize grouped titles, align setting labels, and use checked sender allocation. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications.c | 15 ++++++++++++--- src/fw/apps/system/settings/notifications.c | 14 ++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/fw/apps/system/notifications.c b/src/fw/apps/system/notifications.c index 9569730415..08eab1c527 100644 --- a/src/fw/apps/system/notifications.c +++ b/src/fw/apps/system/notifications.c @@ -315,7 +315,9 @@ static void prv_push_group_window(NotificationsData *data, const NotificationHis NotificationGroupWindow *group_window = app_zalloc_check(sizeof(*group_window)); group_window->notifications_data = data; group_window->count = row->group.count; - group_window->sender = app_strdup(row->group.sender); + const size_t sender_size = strlen(row->group.sender) + 1; + group_window->sender = app_malloc_check(sender_size); + memcpy(group_window->sender, row->group.sender, sender_size); group_window->notification_ids = app_malloc_check(sizeof(Uuid) * group_window->count); NotificationHistoryMember *member = row->group.members; @@ -670,7 +672,14 @@ static int16_t prv_get_cell_height(struct MenuLayer *menu_layer, MenuIndex *cell static const char *prv_get_group_title(NotificationsData *data, const NotificationHistoryRow *row) { const char *sender = row->group.sender; - const size_t required_size = strlen(sender) + 10; + /// Notification sender followed by the number of grouped notifications + const char *format = i18n_get("%s (%u)", data); + const int title_length = snprintf(NULL, 0, format, sender, (unsigned int)row->group.count); + if (title_length < 0) { + return sender; + } + + const size_t required_size = (size_t)title_length + 1; if (required_size > data->group_title_size) { char *group_title = app_realloc(data->group_title, required_size); if (!group_title) { @@ -680,7 +689,7 @@ static const char *prv_get_group_title(NotificationsData *data, const Notificati data->group_title_size = required_size; } - snprintf(data->group_title, data->group_title_size, "%s (%u)", sender, + snprintf(data->group_title, data->group_title_size, format, sender, (unsigned int)row->group.count); return data->group_title; } diff --git a/src/fw/apps/system/settings/notifications.c b/src/fw/apps/system/settings/notifications.c index 0bd0b802d3..483920f604 100644 --- a/src/fw/apps/system/settings/notifications.c +++ b/src/fw/apps/system/settings/notifications.c @@ -274,9 +274,13 @@ static void prv_status_bar_style_menu_push(SettingsNotificationsData *data) { //////////////////////// static const char *s_notification_grouping_range_labels[] = { + /// Disable notification grouping [NotificationGroupingRange_Never] = i18n_noop("Never"), - [NotificationGroupingRange_OneDay] = i18n_noop("1 day"), - [NotificationGroupingRange_OneWeek] = i18n_noop("1 week"), + /// Group notifications received within one day + [NotificationGroupingRange_OneDay] = i18n_noop("1 Day"), + /// Group notifications received within one week + [NotificationGroupingRange_OneWeek] = i18n_noop("1 Week"), + /// Group all notifications [NotificationGroupingRange_All] = i18n_noop("All"), }; @@ -293,7 +297,8 @@ static void prv_notification_grouping_range_menu_push(SettingsNotificationsData const OptionMenuCallbacks callbacks = { .select = prv_notification_grouping_range_menu_select, }; - const char *title = i18n_noop("Group by sender"); + /// Title for the notification sender grouping settings screen + const char *title = i18n_noop("Group by Sender"); settings_option_menu_push(title, OptionMenuContentType_SingleLine, alerts_preferences_get_notification_grouping_range(), &callbacks, ARRAY_LENGTH(s_notification_grouping_range_labels), @@ -346,7 +351,8 @@ static void prv_draw_row_cb(SettingsCallbacks *context, GContext *ctx, break; } case NotificationsItemGroupBySender: { - title = i18n_noop("Group by sender"); + /// Notification settings item for grouping notifications by sender + title = i18n_noop("Group by Sender"); subtitle = s_notification_grouping_range_labels [alerts_preferences_get_notification_grouping_range()]; break; From 36d5d681aac5fd9b609a06ddecfcdf4309a1c2be Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Sun, 30 Aug 2026 22:05:10 +0200 Subject: [PATCH 7/9] notifications: skip corrupt history records Keep age-aware iteration moving so malformed records do not hide later notifications. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- .../notifications/notification_storage.c | 26 ++++++++-- .../notifications/test_notification_storage.c | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/fw/services/notifications/notification_storage.c b/src/fw/services/notifications/notification_storage.c index 8055bdbfb3..54164d4f4d 100644 --- a/src/fw/services/notifications/notification_storage.c +++ b/src/fw/services/notifications/notification_storage.c @@ -757,7 +757,17 @@ void notification_storage_iterate_items_after( iter_init(&iter, (IteratorCallback)prv_iter_next, NULL, &iter_state); while (iter_next(&iter)) { - if (iter_state.header.common.status & TimelineItemStatusDeleted) { + const uint8_t status = iter_state.header.common.status; + if ((status & TimelineItemStatusUnused) || + (iter_state.header.common.type >= TimelineItemTypeOutOfRange) || + (iter_state.header.common.layout >= NumLayoutIds)) { + PBL_LOG_WRN("Skipping corrupt notification"); + if (pfs_seek(fd, iter_state.header.payload_length, FSeekCur) < 0) { + break; + } + continue; + } + if (status & TimelineItemStatusDeleted) { if (pfs_seek(fd, iter_state.header.payload_length, FSeekCur) < 0) { break; } @@ -766,8 +776,18 @@ void notification_storage_iterate_items_after( const bool deserialize = iter_state.header.common.timestamp >= item_cutoff; TimelineItem item; - if (deserialize && !prv_get_notification(&item, &iter_state.header, fd)) { - break; + if (deserialize) { + const int payload_offset = pfs_seek(fd, 0, FSeekCur); + if (payload_offset < 0) { + break; + } + if (!prv_get_notification(&item, &iter_state.header, fd)) { + PBL_LOG_WRN("Skipping corrupt notification payload"); + if (pfs_seek(fd, payload_offset + iter_state.header.payload_length, FSeekSet) < 0) { + break; + } + continue; + } } const bool should_continue = diff --git a/tests/fw/services/notifications/test_notification_storage.c b/tests/fw/services/notifications/test_notification_storage.c index 0b913aa164..ff6bb53a98 100644 --- a/tests/fw/services/notifications/test_notification_storage.c +++ b/tests/fw/services/notifications/test_notification_storage.c @@ -183,6 +183,22 @@ static bool prv_items_iterator_callback(void *data, const CommonTimelineItemHead return true; } +typedef struct { + Uuid expected_ids[2]; + uint8_t item_count; +} RecoveringNotificationItemsIteratorContext; + +static bool prv_recovering_items_iterator_callback(void *data, + const CommonTimelineItemHeader *header, + const TimelineItem *item) { + RecoveringNotificationItemsIteratorContext *context = data; + cl_assert(item); + cl_assert(context->item_count < ARRAY_LENGTH(context->expected_ids)); + cl_assert(uuid_equal(&item->header.id, &context->expected_ids[context->item_count])); + context->item_count++; + return true; +} + // Tests //////////////////////////////////// void test_notification_storage__basic(void) { @@ -258,6 +274,41 @@ void test_notification_storage__iterate_items_after_skips_old_and_deleted_payloa cl_assert_equal_i(context.item_count, 1); } +void test_notification_storage__iterate_items_after_skips_corrupt_record(void) { + TimelineItem first = { + .header = + { + .id = UuidMake(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), + .timestamp = 100, + .type = TimelineItemTypeNotification, + .layout = LayoutIdGeneric, + }, + .attr_list = + { + .num_attributes = ARRAY_LENGTH(attributes), + .attributes = attributes, + }, + }; + TimelineItem corrupt = first; + corrupt.header.id = UuidMake(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + corrupt.header.timestamp = 200; + corrupt.header.status = 0xC0; + TimelineItem last = first; + last.header.id = UuidMake(3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + last.header.timestamp = 300; + + notification_storage_store(&first); + notification_storage_store(&corrupt); + notification_storage_store(&last); + + RecoveringNotificationItemsIteratorContext context = { + .expected_ids = {first.header.id, last.header.id}, + }; + notification_storage_iterate_items_after(0, prv_recovering_items_iterator_callback, &context); + + cl_assert_equal_i(context.item_count, ARRAY_LENGTH(context.expected_ids)); +} + void test_notification_storage__multiple(void) { Uuid i1 ; uuid_generate(&i1); From c31568d359a3eafbff7665672812edbc65ea24d8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Mon, 31 Aug 2026 12:42:57 +0200 Subject: [PATCH 8/9] notifications: preserve ungrouped history behavior Only switch to grouped history controls when a collapsed sender group exists, so iOS and single-message histories retain navigation and dismiss-all behavior. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications.c | 10 ++++++---- src/fw/apps/system/notifications_history.c | 11 +++++++++++ src/fw/apps/system/notifications_history.h | 1 + .../notifications/test_notifications_history.c | 3 +++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/fw/apps/system/notifications.c b/src/fw/apps/system/notifications.c index 08eab1c527..08820e6789 100644 --- a/src/fw/apps/system/notifications.c +++ b/src/fw/apps/system/notifications.c @@ -339,7 +339,8 @@ static void prv_push_group_window(NotificationsData *data, const NotificationHis // Return true if successful static bool prv_push_notification_window(NotificationsData *data, NotificationHistoryRow *selected_row) { - notification_window_init_history(!data->history.group_by_sender); + const bool has_collapsed_groups = notifications_history_has_collapsed_groups(&data->history); + notification_window_init_history(!has_collapsed_groups); // Bail if a notification came in ahead of us and created a modal window // before we had a chance to react to the select button event. @@ -347,14 +348,15 @@ static bool prv_push_notification_window(NotificationsData *data, return false; } - if (data->history.group_by_sender) { + if (has_collapsed_groups) { notification_window_add_notification_by_id( (Uuid *)notifications_history_row_get_latest_id(selected_row)); } else { NotificationHistoryRow *row = (NotificationHistoryRow *)list_get_tail(&data->history.rows->node); while (row) { - notification_window_add_notification_by_id(&row->notification_id); + notification_window_add_notification_by_id( + (Uuid *)notifications_history_row_get_latest_id(row)); row = (NotificationHistoryRow *)list_get_prev(&row->node); } } @@ -882,7 +884,7 @@ static void prv_handle_notification_added(Uuid *id) { prv_group_window_add_notification(s_data, id); timeline_item_free_allocated_buffer(¬ification); - if (!s_data->history.group_by_sender) { + if (!notifications_history_has_collapsed_groups(&s_data->history)) { app_notification_window_add_new_notification_by_id(id); } } diff --git a/src/fw/apps/system/notifications_history.c b/src/fw/apps/system/notifications_history.c index 3a2cc9d21b..6b5af65a1c 100644 --- a/src/fw/apps/system/notifications_history.c +++ b/src/fw/apps/system/notifications_history.c @@ -258,6 +258,17 @@ NotificationHistoryRow *notifications_history_get_row(const NotificationHistory return (NotificationHistoryRow *)list_get_at((ListNode *)history->rows, index); } +bool notifications_history_has_collapsed_groups(const NotificationHistory *history) { + NotificationHistoryRow *row = history->rows; + while (row) { + if (notifications_history_row_is_collapsed_group(row)) { + return true; + } + row = (NotificationHistoryRow *)list_get_next(&row->node); + } + return false; +} + bool notifications_history_row_is_collapsed_group(const NotificationHistoryRow *row) { return row->is_group && row->group.count > 1; } diff --git a/src/fw/apps/system/notifications_history.h b/src/fw/apps/system/notifications_history.h index 2b0f1534a3..a8dbcdf426 100644 --- a/src/fw/apps/system/notifications_history.h +++ b/src/fw/apps/system/notifications_history.h @@ -50,6 +50,7 @@ bool notifications_history_remove(NotificationHistory *history, const Uuid *id); uint16_t notifications_history_get_row_count(const NotificationHistory *history); NotificationHistoryRow *notifications_history_get_row(const NotificationHistory *history, uint16_t index); +bool notifications_history_has_collapsed_groups(const NotificationHistory *history); bool notifications_history_row_is_collapsed_group(const NotificationHistoryRow *row); const Uuid *notifications_history_row_get_latest_id(const NotificationHistoryRow *row); uint16_t notifications_history_row_get_count(const NotificationHistoryRow *row); diff --git a/tests/fw/apps/system_apps/notifications/test_notifications_history.c b/tests/fw/apps/system_apps/notifications/test_notifications_history.c index d9516ec361..069dcaf98b 100644 --- a/tests/fw/apps/system_apps/notifications/test_notifications_history.c +++ b/tests/fw/apps/system_apps/notifications/test_notifications_history.c @@ -102,6 +102,7 @@ void test_notifications_history__same_sender_forms_one_group(void) { cl_assert(prv_row(0)->is_group); cl_assert_equal_i(notifications_history_row_get_count(prv_row(0)), 2); cl_assert(notifications_history_row_is_collapsed_group(prv_row(0))); + cl_assert(notifications_history_has_collapsed_groups(&s_history)); cl_assert_equal_s(prv_row(0)->group.sender, "Anna"); prv_assert_id(notifications_history_row_get_latest_id(prv_row(0)), 2); } @@ -111,6 +112,7 @@ void test_notifications_history__single_message_is_not_collapsed_group(void) { cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); cl_assert(!notifications_history_row_is_collapsed_group(prv_row(0))); + cl_assert(!notifications_history_has_collapsed_groups(&s_history)); prv_assert_id(notifications_history_row_get_latest_id(prv_row(0)), 1); } @@ -204,6 +206,7 @@ void test_notifications_history__removing_notifications_updates_and_removes_grou cl_assert_equal_s(prv_row(0)->group.sender, "Bob"); cl_assert_equal_i(prv_row(1)->group.count, 1); cl_assert(!notifications_history_row_is_collapsed_group(prv_row(1))); + cl_assert(!notifications_history_has_collapsed_groups(&s_history)); prv_assert_id(notifications_history_row_get_latest_id(prv_row(1)), 1); id = prv_id(1); From 96979e8cf7e9fd39a4ec2e5476b0384989161f68 Mon Sep 17 00:00:00 2001 From: Aliaksandr Karnilovich Date: Mon, 31 Aug 2026 13:06:40 +0200 Subject: [PATCH 9/9] notifications: group conversation title prefixes Use the text before a title's sender separator as a fallback conversation key when the Android companion does not provide sender metadata. Co-authored-by: GPT-5.6 Sol Signed-off-by: Aliaksandr Karnilovich --- src/fw/apps/system/notifications_history.c | 27 ++++++++++++++++--- .../test_notifications_history.c | 18 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/fw/apps/system/notifications_history.c b/src/fw/apps/system/notifications_history.c index 6b5af65a1c..a8859c584c 100644 --- a/src/fw/apps/system/notifications_history.c +++ b/src/fw/apps/system/notifications_history.c @@ -66,6 +66,25 @@ static StringRange prv_trimmed_string_range(const char *string) { }; } +static StringRange prv_conversation_range_from_title(const char *title) { + StringRange range = prv_trimmed_string_range(title); + if (!range.start) { + return range; + } + + const char *separator = strstr(range.start, ": "); + const char *end = range.start + range.length; + if (!separator || separator == range.start || separator + 2 >= end) { + return range; + } + + range.length = (size_t)(separator - range.start); + while (range.length > 0 && isspace((unsigned char)range.start[range.length - 1])) { + range.length--; + } + return range; +} + static bool prv_group_sender_for_item(const TimelineItem *item, StringRange *sender_out) { static const Uuid s_android_notifications_source = UUID_NOTIFICATIONS_DATA_SOURCE; @@ -75,11 +94,13 @@ static bool prv_group_sender_for_item(const TimelineItem *item, StringRange *sen } const char *sender = attribute_get_string(&item->attr_list, AttributeIdSender, NULL); - if (!sender) { - sender = attribute_get_string(&item->attr_list, AttributeIdTitle, NULL); + if (sender) { + *sender_out = prv_trimmed_string_range(sender); + } else { + const char *title = attribute_get_string(&item->attr_list, AttributeIdTitle, NULL); + *sender_out = prv_conversation_range_from_title(title); } - *sender_out = prv_trimmed_string_range(sender); return sender_out->length > 0; } diff --git a/tests/fw/apps/system_apps/notifications/test_notifications_history.c b/tests/fw/apps/system_apps/notifications/test_notifications_history.c index 069dcaf98b..d8500e853b 100644 --- a/tests/fw/apps/system_apps/notifications/test_notifications_history.c +++ b/tests/fw/apps/system_apps/notifications/test_notifications_history.c @@ -136,6 +136,24 @@ void test_notifications_history__title_is_used_when_sender_is_missing(void) { cl_assert_equal_i(prv_row(0)->group.count, 2); } +void test_notifications_history__title_conversation_prefix_groups_messages(void) { + prv_add_attribute(1, 100, AttributeIdTitle, "PG | Elite: Aloha"); + prv_add_attribute(2, 200, AttributeIdTitle, "PG | Elite: Yeuhen"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 1); + cl_assert_equal_s(prv_row(0)->group.sender, "PG | Elite"); + cl_assert_equal_i(prv_row(0)->group.count, 2); +} + +void test_notifications_history__sender_attribute_is_not_split(void) { + prv_add(1, 100, "PG | Elite: Aloha"); + prv_add(2, 200, "PG | Elite: Yeuhen"); + + cl_assert_equal_i(notifications_history_get_row_count(&s_history), 2); + cl_assert_equal_s(prv_row(0)->group.sender, "PG | Elite: Yeuhen"); + cl_assert_equal_s(prv_row(1)->group.sender, "PG | Elite: Aloha"); +} + void test_notifications_history__body_is_not_used_as_group_key(void) { prv_add_attribute(1, 100, AttributeIdBody, "Same body"); prv_add_attribute(2, 200, AttributeIdBody, "Same body");