The report was three sentences long. A user had filtered their delivery app, opened Notic later to find the address the courier had left a note about, and found a saved notification with a title, a timestamp, an icon, and no text at all. The notification had been captured. The body was blank.

We could not reproduce it. On our devices the same app's notifications saved fine. That gap — works here, blank there — is usually a sign that you are reading a data structure that has more shapes than you thought, and that is exactly what was happening.

Notification extras are not a dictionary of strings

An Android notification carries its content in a Bundle called extras, keyed by constants on Notification. The obvious way to read the body is:

val text = sbn.notification.extras.getString(Notification.EXTRA_TEXT)

This is wrong, and it is wrong in a way that passes every test you are likely to write.

extras is typed as Bundle, and the values in it are CharSequence, not String. When an app builds a notification with plain text, the value happens to be a String, and getString returns it. When an app builds one with any formatting — bold, a coloured span, a link, an emoji shortcode expanded by a library — the value is a SpannableString, which is a CharSequence but not a String. Bundle.getString does a cast and returns null on failure. No exception, no warning, no log line.

So the rule is simple: notifications with any styling saved as blank. Notifications from apps that use plain strings saved fine. Our test devices ran the plain-string apps. Our reporter's delivery app used bold text for the tracking number.

The correct call is getCharSequence, and then toString() if you want plain text. We now read every text field that way, and we kept the styled CharSequence where we display it, so bold stays bold in the history list instead of being flattened.

That one line accounted for most of the blank notifications. It did not account for all of them.

Four styles, four places the body lives

The remaining blanks came from notifications where EXTRA_TEXT genuinely was empty, because the app had put its content somewhere else. Android's notification styles each have their own storage:

BigTextStyle puts the long form in EXTRA_BIG_TEXT and often leaves EXTRA_TEXT as a truncated preview — or empty. This is the style used by email clients and anything sending a paragraph.

InboxStyle puts a CharSequence[] in EXTRA_TEXT_LINES, one element per line. Read only EXTRA_TEXT and you get the summary, not the seven lines the user could see by expanding.

MessagingStyle stores a Parcelable[] in EXTRA_MESSAGES, where each element is a bundle containing the message text, the sender, and a timestamp. From API 28 there is Notification.MessagingStyle.extractMessagingStyleFromNotification(), which is much less fragile than unpacking the array by hand. This is the style used by most chat apps, and it is the one where reading only EXTRA_TEXT gives you the most recent message and silently drops the rest of the conversation.

BigPictureStyle may put a separate caption in EXTRA_SUMMARY_TEXT and a different headline in EXTRA_TITLE_BIG.

EXTRA_TEMPLATE tells you which style a notification was built with, which is useful, but it is not always populated the way you would like — some notification-compat paths leave it out. Notic now reads it when present and falls back to probing the extras keys in a fixed order when it is not, taking the richest field it finds rather than the first.

The result is that a saved notification contains what the user could have seen by expanding it, not what fit in the collapsed preview. For a chat notification that means the conversation lines instead of one message. For an email that means the paragraph instead of the first clause.

Repeated captures, one entry

Fixing the parsing surfaced a second issue that had been hidden behind it. Apps update a notification by re-posting the same id, which fires onNotificationPosted again with the same key. A delivery app might do this six times for one package — accepted, picked up, in transit, out for delivery, nearby, delivered.

Previously each post became a row, because the earlier of them were often blank and did not look like duplicates. With the parsing fixed, users would have gotten six near-identical entries per package.

Notic now folds updates to the same key into a single history entry that carries the latest content and keeps the earlier versions accessible underneath. The list shows one delivery, not six. The count of updates is visible, because "this notification changed four times" is sometimes the information you want.

Deduplication is on key plus a content hash, not on key alone, because notification ids are reused. An app that posts all its alerts on id 1 would otherwise collapse unrelated notifications into one row.

Per-item actions, and why there are exactly four

With more content preserved, the old "select and delete" list stopped being enough. Each saved notification now exposes four actions, and we spent longer than expected deciding that four was the number.

Copy takes the text without the surrounding item. In practice this is the most used action by a wide margin, because the common case is that you want one line — a tracking number, an address, a code, a reference — and you want it in another app. Copy puts only the body on the clipboard, not the app name and timestamp, because pasting metadata into a search field is annoying.

Favourite exempts the item from automatic cleanup. Notic expires captured notifications on a retention window by default; favouriting is how you say this one stays. It is the same signal used by the retention system rather than a separate concept, which keeps the model to one idea instead of two.

Mark read exists because the unread count is only useful if it can be made accurate. Without it, people either clear the whole list to reset the badge or stop looking at the badge.

Delete removes the entry now, rather than waiting for the retention window.

Everything else we considered — tags, notes, due dates, reminders — would have turned the history into a task manager, and a task manager cannot have aggressive default retention. We keep the boundary deliberately.

Fuller capture makes cleanup matter more

There is a trade-off in this update that we want to state plainly rather than bury.

Before, a partially captured notification was less useful and also less sensitive. Now that Notic saves complete message bodies, the history contains more of the actual content of your messages: full addresses, longer conversation excerpts, complete account notices. That is the point of the change, and it also means the history is a more attractive thing to leave lying around.

The mitigations are the ones already in the app, and this update leans on them harder. Retention is on by default. Likely-authentication messages get a shorter window regardless of your setting. Favourite only what you will genuinely return to. And be more careful than before about showing the history on a device someone else is holding.

Copying a sensitive line to another app also makes a second copy that Notic's cleanup will never reach. That is not a thing we can solve from inside the app, but it is worth saying every time we make copy easier.

What we would do differently

The getString bug shipped because our test corpus was too clean. We were generating notifications with a test harness that built them from plain strings, so the entire class of styled-content failures was invisible to us.

We now keep a set of notifications captured from real apps as fixtures — all four styles, plain and styled text, single and grouped, with and without EXTRA_TEMPLATE — and the parser runs against them on every build. It is not a large test suite. It would have caught this in an afternoon.

The remaining gap is that we still parse extras by key rather than rendering the notification's own RemoteViews and reading what the user would actually see. Rendering is the technique that cannot be fooled by a custom layout, and apps with fully custom notification layouts are the case where we still capture less than we would like. It is slow and awkward, which is why we have not done it, and it is the honest answer to why a handful of apps still save with less text than they display.