Skip to content

Release 2.15.5 - #814

Merged
mpretty-cyro merged 70 commits into
masterfrom
dev
Oct 8, 2026
Merged

mpretty-cyro merged 70 commits into
masterfrom
dev

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

What's Changed

New Contributors

Full Changelog: 2.15.4...2.15.5

mpretty-cyro and others added 30 commits September 2, 2026 12:11
Recording stopped at 180s where Android and Desktop both allow 300s, so the same
message could be recorded on one client and not another.

The cap is there to keep the attachment inside the 10MB upload limit, not to
limit the message, and 300s does not come close: the recorder above produces AAC
at 44.1kHz stereo, 128kbps — 16KB/s — so five minutes is about 4.9MB, with the
padding applied before encryption still leaving half the budget spare.

The comment said "Limit voice messages to a minute", which stopped being true in
593ab49 when the value went from 60 to 180. It now records why the limit
exists and what it is derived from, so the next change to the recorder settings
has something to check against.
Claude Code writes per-checkout tooling there; none of it belongs to the project.
It has not belonged to any target since 65e4d7f renamed SessionSnodeKit to
SessionNetworkingKit, and it no longer compiles: it calls
Identity.fetchUserEd25519KeyPair() with no arguments and uses Storage.shared,
neither of which exists. Nothing anywhere references SnodeAPI or SnodeAPIError.

It does still answer repo-wide greps for the storage and identity APIs, which is
the actual cost of leaving it in place.
fetchUserEd25519KeyPair collapsed every read error to nil, and nil is also what an
absent account returns, so a database that could not be read during startup was
indistinguishable from a fresh install. The user was returned to onboarding with
their account intact on disk, and creating one wrote the new identity over the old
with upsert while deleting nothing else - leaving them a new Session ID, their whole
previous history still visible and typeable, and no way back, since no copy of the
overwritten key is kept and the database is excluded from iOS backups. Nothing was
logged at any level, which is much of why this went unreported since 2021.

Both fetchers now throw, nil keeps meaning "genuinely absent", and the failure
propagates out of postMigrationSetup to the failed-startup alert. Registration
separately refuses to run against a database which already holds user data, which
stops the destructive outcome whatever caused the misread rather than only the cause
we know about; completeRegistration throws to do it, so the onboarding screens no
longer continue to the home screen afterwards. Reaching "no user" with data on disk
is now logged as critical.

Two consequences a reviewer wouldn't guess. databaseSuspended joins the alert branch
that withholds "Clear Data & Restore", because a suspension leaves the data intact
and this change sends real traffic to that alert. And the share extension's catch now
shows its own startup-failed UI, because that catch became reachable for a database
which is merely unreadable at the time, where it previously fell through to a working
share sheet.

Fixes 4 and 5 of the original spec are deliberately not here. Both wait for the
database to leave .suspended, which cannot happen mid-startup because
sceneWillEnterForeground gates resumeDatabaseAccess behind appReadiness.isReady, and
that is only set once startup finishes. The migration retry loop immediately above
postMigrationSetup already has the same exposure.
…tions

The notification service extension never set a sound on either of the paths that
build their own content, so every notification they produce is silent - the generic
"You've got a new message" shown whenever processing fails, and the incoming call
notification from handleFailureForVoIP. handleFailure already had the settings in
hand for previewType and simply never read sound. The success path is unaffected;
it builds content through NotificationContent.toMutableContent(shouldPlaySound:),
which does attach one.

handleFailure is the default: arm of handleError, so anything unrecognised lands
there. A user whose messages consistently fail to process in the extension gets a
banner with no sound for every message, with nothing in the app's settings to
explain it.

This was fixed once before, in ecf92ce ("fix PN sound settings not applying to
remote PNs"), and dropped while resolving the merge in a68ed28 where the other
branch had rewritten the same block. That is why it is a named function rather than
an assignment repeated at each call site - a bare `content.sound = ...` in the
middle of a rewritten block is exactly what went missing last time.

Also fixes Sound.default resolving to UNNotificationSound(named: ""), which plays
nothing: filename() returns an empty string rather than nil for that case, so the
existing nil guard never caught it and the UNNotificationSound.default fallback was
unreachable. Not currently reachable through the UI - default isn't offered in the
sound picker and libSession decodes a stored 0 to note - but the fallback paths now
depend on this function resolving correctly.

The extension has no test target, so the regression tests cover the sound resolution
and NotificationContent instead.
The badge is off by default, so a new subscriber had to find the toggle in Pro
settings before anyone could tell they had subscribed. Desktop has done this
since session-desktop 266ac2c9e; this is the same behaviour.

The condition is that config carries no proof, no access expiry and no profile
features - that this account has never held Pro - rather than that Pro is active
now. Enabling on "active" would turn the badge back on at every renewal for a
subscriber who had deliberately turned it off, and since a lapsed plan keeps its
access expiry after the proof is cleared, that expiry is what tells a returning
subscriber from a first-time one. It is read inside the same `mutate(cache:)`
that writes the proof and the expiry, and before either of them.

The badge itself goes through `Profile.updateLocal(proFeatures:)`, which the Pro
settings toggle already uses, so there is no second writer for it.

A linked device is unaffected: it takes the badge from the user profile config it
merges, and that config's proof or expiry fails the check in any case.

`ProBadgeFirstProofSpec` covers the check. It does not cover the call site:
`GenerateProProofResponse` can only be built by parsing raw backend bytes, and
there is still no `MockSessionProManager`.
Mnemonic.Language.loadWordSet() force-tried the bundled word list, so a failed read
trapped instead of reaching the try? that ConversationVC already wraps the recovery
phrase in — for an affected install, every send killed the app. The load now throws
through encode/hash/decode and Identity.mnemonic, and the warning is skipped when the
phrase is unavailable rather than blocking the send.

Five reports across five devices and five iOS versions on 2.15.4. Why the read fails
is still open, so the failure is logged with its underlying error to make the next
occurrence diagnosable without a crash-report round trip.

Mnemonic.hash also now forwards its language argument, which it previously dropped.
The .standard link-preview branch capped the body with a height constraint while the
other three body branches use numberOfLines, and the shared tap handler only knows how
to lift a numberOfLines cap - so the tap removed the button and left the text clipped
until the cell was next reused. bodyLabelHeightConstraint had no other reader and goes
with it.

Also assign bodyContainerStackView in that branch and clear it in prepareForReuse; the
handler's removeArrangedSubview was otherwise aimed at whichever stack view the cell
last held, or at a torn-down one after reuse. Those two are wrong rather than dangerous
- removeArrangedSubview on a non-member is a documented no-op.

Not the #28409 crash, which is still undiagnosed.
EXC_BAD_ACCESS inside Dictionary.subscript.getter while a photo or video is open
full-screen and the underlying album changes — five identical reports on 2.15.4,
five devices, all iOS 26.x. A SIGSEGV there is a torn read of a dictionary being
mutated on another thread rather than a nil unwrap.

`albumData` had no synchronisation and two writers in different isolation domains.
`prefetchAdjacentAlbums` fires on every page transition, on `setCurrentItem` and
in `viewDidLoad`, and reaches `loadAndCacheAlbumData` through `Task.detached`, so
its write lands off the main thread while `MediaPageViewController.handleUpdates`
reads the same dictionary on the main actor.

Isolating the property to the main actor makes the compiler place that write: a
synchronous call into it from a non-isolated context is now an error. Nothing
expensive moves — `loadAndCacheAlbumData` and `createDetailViewController` stay
non-isolated `async`, the database read still runs on the storage actor, and only
the dictionary write hops. `createDetailViewController`'s album lookup moves
inside the `MainActor.run` it already had, which also collapses the two reads
either side of the `isEmpty` check into one.

The observation callback was never the second writer: `Storage.start` declares
`onChange` `@MainActor` and `MediaPageViewController` inherits main-actor
isolation from `UIPageViewController`, so callbacks arriving together already
serialise.
Message Info rendered a long message past the bounds of the screen, in both directions,
and `AttributedLabel` was the cause of each.

**Width.** It set `preferredMaxLayoutWidth` only when a caller passed one, so a `UILabel`
measured its whole text as a single line and - at the default horizontal compression
resistance - refused to be squeezed below that, widening its container instead of
wrapping. The quote passes no width, so the bubble laid out 1560pt wide on a 390pt
screen and the whole screen drew off both edges. The label now takes its width from its
own bounds, bounded by a caller's width where there is one, and yields horizontally.

**Lines.** `.lineLimit` is a SwiftUI environment modifier that reaches `Text`, so the
quote's `.lineLimit(2)` never reached the UILabel; the body relied on
`.frame(maxHeight:)`, which constrains the layout proposal without clipping.
`AttributedLabel` now takes an explicit `numberOfLines` - two for a quote, matching the
UIKit `QuoteView` it was ported from, and the conversation cell's limit for the body.

The "Read more" gate consequently measures what the label renders.

Follow-up, deliberately not in here: the width that gate and the body label are measured
at still comes from `UIScreen.main.bounds.width` rather than the container, so it is
wrong under Split View and Stage Manager, and `UIScreen.main` is deprecated on iOS 26.
The import summary printed successes over failures for config messages and successes
over the total for standard ones, so the two halves of one line meant different things
and a reader could not tell how many config messages had been dropped.

This is the line used to triage the "notification arrives, message never appears"
reports from a user's log, so both halves now read as processed-out-of-attempted.
Resetting a swarm's poll cursor asks the next poll to fetch that swarm's
messages from the beginning: on promotion to admin (admins get historic
access), on removal from a group (so a re-invite downloads history
again), and when the developer settings switch network. A poll of the
same swarm already in flight finished afterwards and stored the newest
hash it had received as the cursor, undoing the reset, and the history
was never fetched.

processPollResponse already re-read the cursors inside its write and
dropped the poll when they no longer matched what it sent. That misses
two cases. It is skipped whenever the poll started with no cursor, so a
reset of an empty cursor is invisible to it. And it runs once, before
any cursor write, so a reset landing later in the same transaction gets
past it.

Each reset now bumps a per-swarm generation, inside the transaction that
performs it. A poll reads the generation before its cursors and passes
it through. Every cursor write re-checks it inside the poll's write
transaction and is skipped if it changed. Writes are serialised, so no
reset can land between the check and the write. The poll still processes
its messages; the next poll fetches them again and dedupe drops the
repeats.

The reset sites go through two helpers on SnodeReceivedMessageInfo,
invalidateCursor and deleteCursor, plus deleteAllCursors for the network
switch, so a new reset site cannot forget the bump.

Tested: 2189 passed / 0 failed on the prebuilt libSession pinned by dev, across SessionTests, SessionUtilitiesKitTests, SessionNetworkingKitTests and SessionMessagingKitTests.
updateExpiry accepted shortenOnly and extendOnly and dropped both on the
way to the request. ExpirationUpdateJob asks for shortenOnly, so the
server received a plain expire. It set the expiry literally, which can
extend a message that should only ever expire sooner, and it never
returned "unchanged": the server only includes that field when shorten
or extend is given. The job's branch that restarts the local timer from
an unchanged expiry could therefore never run.

Both flags are now forwarded to prepareUpdateExpiryRequest.

Tested: 2191 passed / 0 failed on the prebuilt libSession pinned by dev, across SessionTests, SessionUtilitiesKitTests, SessionNetworkingKitTests and SessionMessagingKitTests.
Turn the Pro badge on when an account gets its first ever proof
…fallback-sound

Notifications: restore the sound on the extension's fallback notifications
…not-a-new-user

Don't treat an unreadable identity as a new user
Allow voice messages to run to five minutes
Stop a failed word-set read from crashing every message send
Stop the full-screen media viewer racing on its album cache
…truncation

Expand the body when "Read more" is tapped on a link preview message
Count config messages against the same denominator as standard ones
# Conflicts:
#	Session.xcodeproj/project.pbxproj
…erflow

Keep the Message Info quote and body inside the screen
Quitting from the startup-failure alert, finishing a data reset, and the other
deliberate terminations called exit(0), which tears down SQLCipher and libSession's
logger while their threads are still running. That was the second-largest crash
cause on 2.15.4, and in a synthetic harness it also corrupts an encrypted WAL
database with a writer in flight.

Every production termination now goes through Log.flushAndTerminate(), including
Onboarding's registration-refused alert. The Developer Settings exits are unchanged.
startCapture runs on every SwiftUI update, and its "no session yet" check ran on
main while the session was built on a concurrent queue and only recorded at the end,
so overlapping calls each configured the same camera. That corrupted the heap in
AVFoundation and crashed across several unrelated-looking stacks on 2.15.4.

The check, the assignment and start/stop now all happen on one serial queue, which
also keeps a stop from overtaking the start before it.
…uctors

Terminate with _exit so a deliberate quit stops crashing
…re-session

Stop the QR scanner configuring two camera sessions at once
Stop an in-flight poll undoing a cursor reset
session-localisation Bot and others added 29 commits October 4, 2026 19:01
…n-translations

[Automated] Update translations from Crowdin
…ests

Session Router is unfinished on this client but was selectable in Developer
Settings, and libSession builds without session-router support throw when it
is requested. Mark it an invalid Router option so it drops out of the picker
and a previously stored selection reads back as onion requests, rather than
only hiding it and leaving existing users stuck on it.

The router testing env var now falls back to onion requests for
sessionRouter too.
Stop offering Session Router and move anyone on it back to onion requests
…-snode-list

[Automated] Update fallback static snode list
Pro: restore the Session Pro gate, off by default
Xcode 27 rejects session-lucide 0.473.2 with "ambiguous use of
'font(ofSize:)'", since it has both UIFont and SwiftUI Font overloads.
0.473.3 fixed this by renaming the UIFont variant to uiFont(ofSize:), so
both of our call sites are updated.

The minimum version is now 0.473.4 rather than 0.473.3. 0.473.3 left the
old name in its own LucideIconView, and it still fails to compile on
Xcode 27.
Update session-lucide to 0.473.4 so the app builds on Xcode 27
…n them

Messages the client positively identifies as unprocessable are now kept instead of
dropped. Random decryption failures still produce nothing.

- One-to-one data starting 0x00 is a newer wire format. It's retained without
  decrypting and raises an account-level "update" banner, with no bubble, since its
  sender can't be known.
- Decrypted content with a top-level field above 18 and nothing usable gets a
  placeholder bubble in an existing conversation (never a new one), incoming or
  outgoing for our own syncs and group messages.
- Raw swarm data is kept in `unsupported_message` (same schema on all clients, for the
  future 2.0 import), capped at 256 MB and expired with disappearing settings.
- A startup job replays retained messages after an update and replaces placeholders in
  place.

The bubble and banner strings are hard-coded English and need adding to Crowdin.
Autolink iconv when compiling libSession from source
- Start the placeholder variants at 100 to leave room for more standard variants.
- Enforce the retention limits from the reprocess job (launch and becoming active), not
  inside the disappearing-messages delete, and catch its failures.
- A failed replay keeps the retained message (stamped and logged) for a later version
  instead of dropping it.
- Only add a placeholder to a visible conversation, and never un-hide one.
- Record the sender and sent timestamp, so an unsend removes a retained message that has
  no placeholder rather than letting a later replay restore it.
- Add the new migration and table to DatabaseSpec.
The notification extension notified for a hidden conversation, where the main app adds no placeholder, so the notification pointed at nothing. Also covers the replay savepoint rollback in UnsupportedMessageSpec.
1.9.4 adds quic_max_udp_payload. Setting it replaces
quic_disable_mtu_discovery, which pinned datagrams to the 1200-byte
minimum and advertised that as the client's maximum. Path-MTU discovery
can now grow up to 1372 bytes, but no further.
- Count each retained row as its data plus 256 bytes against the 256 MB budget, and cap
  newer-format rows at 10,000, so a flood of tiny rows can't outgrow the limit.
- Keep a running total in `unsupported_message_stats` (maintained by triggers) instead of
  summing the table on every insert.
- Stamp newer-format rows as attempted in one update without loading them, and page the
  rest; replay decides by the stored kind, never the first byte.
- Name the protobuf wire types in the field scan.
…ased them

When an admin deletes a group, the deleting device destroys the group's info, pushes it, then erases
the group from UserGroups. A linked device usually sees the destroyed info first and marks its
UserGroups entry destroyed. libsession merges the two diverged configs by replaying each diff, and if
the erase applies first the destroyed-flag diff recreates the erased key holding only that field: an
entry with the removed status and no name or keys. That entry then syncs back, so the group reappears
on the deleting device ("Unknown Group" on iOS, the "deleted by a group admin" preview elsewhere) and
stays on the linked device.

A real kicked or destroyed entry always keeps its name, so a removed entry with no name, no admin key
and no auth data can only be this. Erase it as part of handling the merge and never build a
conversation from it; any conversation we still have for it is removed through the normal path. Other
admins keep their full entry and still see the deleted state.

The same rule is applied on iOS, Android and Desktop. The underlying merge behaviour is tracked
separately for libsession.
libsession's header describes the UserGroups name as invite-only; every client keeps it today, which
is what the rule relies on.
…ayload

Update libSession to 1.9.4 and cap the QUIC UDP payload at 1372
…-snode-list

[Automated] Update fallback static snode list
Erase group entries a config merge recreated after another device erased them
…n-translations

[Automated] Update translations from Crowdin
Show a placeholder for messages this version can't process, and retain them
2.16.0 was cancelled and its changes ship as a patch instead. The build number stays above 725, which the cancelled 2.16.0 build used.
@mpretty-cyro
mpretty-cyro merged commit 73c028f into master Oct 8, 2026
1 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants