Repository navigation
Release 2.15.5 - #814
Merged
Merged
Release 2.15.5#814
Conversation
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
…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.
Bump version to 2.15.5 (build 726)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's Changed
get_pro_statusrefresh design (iOS) by @mpretty-cyro in Pro: unify theget_pro_statusrefresh design (iOS) #734updateExpiryis given by @mpretty-cyro in Send the shorten/extend flagsupdateExpiryis given #793New Contributors
Full Changelog: 2.15.4...2.15.5