Skip to content

Release/1.33.6 - #2238

Merged
mpretty-cyro merged 352 commits into
masterfrom
release/1.33.6
Oct 14, 2026
Merged

mpretty-cyro merged 352 commits into
masterfrom
release/1.33.6

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

No description provided.

mpretty-cyro and others added 30 commits August 12, 2026 09:12
It sat on the ComposeView host in XML while the tap target is the Avatar inside
it. Compose publishes its own semantics tree, so whether the host's description
survived depended on composition timing -- intermittently leaving the avatar
unlabelled for accessibility services, and unfindable by anything addressing it
by description.

Removed from the XML rather than left in both places: the same description on
two nodes of one tree is the ambiguity being fixed, not redundancy. Nothing read
it there -- the id is used as a constraint anchor and for setThemedContent only.
… failing quietly

- `sessionProBackendStatus=active` mapped to AUTO_GOOGLE, an auto-renewing plan with a
  fixed +14d renewal rendering `proAutoRenewTime`. iOS's `active` is not auto-renewing,
  so the shared token meant different things per platform. Map it to
  EXPIRING_GOOGLE_LATER, which matches iOS. Side effects, neither asserted today: the
  cancel-access row disappears (it is auto-renew only, as on iOS) and the plan length
  becomes 12 months.
- New `sessionProAccessExpiry` extra, applied over whichever fixture was selected: epoch
  seconds, or a relative `+<n><s|m|h|d>`, or `useactual`. Rejects a resolved instant more
  than ten years out, which catches milliseconds passed as seconds. Seconds because that
  is what iOS's field is; the preference keeps storing millis internally.
- Log loudly on any unrecognised `session*` extra. Previously a typo was a silent no-op,
  so it failed as a wrong assertion rather than a setup error.
- EXPIRED and EXPIRED_APPLE were labelled "Expired 2 days ago" while the code used
  now - 14 days. Correct the labels; the offsets are the contract.
- Build every instant in the debug branch from `snodeClock`, read once per recomputation.
  The render side already used it, so the fixture's device-clock instants left the offset
  between the two clocks in the result — a 30-day fixture could render "31 days".
  `quickRefundExpiry` had the same defect.
- Robolectric coverage over the new extra, driven through `QaLaunchConfig.apply` with real
  Intents so the parse, the range guard and the preference write are tested together.
… remaining surfaces

- `SessionProSettingsHeader` applied `clearAndSetSemantics` to the whole column, which
  erases descendants rather than merging them — so the status banner was absent from the
  tree entirely, text and all, and TalkBack announced "Session Pro" over a message users
  never heard. Narrow it to the decorative logo and badge, leaving `extraContent` a sibling
  inside the column that carries `onSizeChanged` (moving it out would change the gradient
  ratio).
- Tag both banner variants with `pro-settings-status-banner`. One id for the slot; the four
  states are told apart by their text.
- Section-header ids for Stats, Manage and Pro Beta Features, via one optional `CategoryCell`
  parameter so the other call sites are untouched.
- Name the conversation-header badge, matching iOS.
…de-qa

Pro: QA backend override, a proof-acquisition floor, and a unit-test class-init fix
…v/androidx.compose-compose-bom-2026.06.01

Bump androidx.compose:compose-bom from 2026.03.01 to 2026.06.01
…v/com.squareup.okhttp3-okhttp-5.4.0

Bump com.squareup.okhttp3:okhttp from 5.3.2 to 5.4.0
…v/lifecycleVersion-2.11.0

Bump lifecycleVersion from 2.10.0 to 2.11.0
…v/org.jetbrains.kotlinx-kotlinx-collections-immutable-0.5.1

Bump org.jetbrains.kotlinx:kotlinx-collections-immutable from 0.4.0 to 0.5.1
…v/coilVersion-3.5.0

Bump coilVersion from 3.4.0 to 3.5.0
Two independent dependabot bumps each broke the build on dev, and the first
masked the second.

- compileSdk 36 -> 37, required by lifecycle 2.11.0 (#2157), which fails
  checkAarMetadata below 37. targetSdk stays at 36. Reverting #2157 was
  considered and declined: compileSdk is a change we control. Note there is no
  `platforms;android-37` package — it is android-37.0, and AGP resolves
  compileSdk = 37 to it with no compileSdkMinor needed.
- Dagger/Hilt 2.59.2 -> 2.60.1, to read the 2.4.0 class metadata that coil
  3.5.0 (#2159) pulls in via kotlin-stdlib 2.4.0; 2.59.2's bundled reader caps
  at 2.3.0 and failed hiltJavaCompile. Kotlin stays at 2.3.20 deliberately —
  bumping it would emit 2.4.0 metadata from our own classes too.

CI provisions no Android SDK (no setup step in build_and_test.yml, and
gradle.properties sets no sdkDownload flag), so it relies on the runner image
plus AGP auto-download. If the image lacks android-37.0, CI will fail on the
SDK and that reads as unrelated to this change.
Fix the dev build: compileSdk 37 and Hilt 2.60.1
The comment here asserts the backend judges status against expiry + grace_period_duration.
It does not — it folds grace into the stored expiry before sending it, so the wire expiry is
coverage end, not paid-through. Two consequences below it: inGracePeriod sits in a branch
that requires now <= expiry so it is unreachable, and the renewal date renders one grace
period late (an hour on Apple, operator-configured days on Google Play).

Comment only. Desktop and iOS encode the same premise in different wordings, so correcting
the logic is a three-client change and is with the architect. The superseded text is kept
verbatim because the other clients' comments echo it and it needs to stay greppable.
Two couplings removed. It refreshed get_pro_status after minting a proof, which is redundant
— the access-expiry write it already makes is a config change that fires the status trigger
on its own. And it gated on the in-memory load state holding an ACTIVE status, which is wrong
in a way that only shows after a restart: WorkManager persists the proof schedule across
process death but that state restarts empty, so a renewal falling due while the app was dead
read as "not Pro, no purchase" and returned without renewing.

It now asks libsession the same question the scheduler asks — pro_renewal_target, from config.
…startup gate

Two new name/value rows, no migration. Both stamp the ATTEMPT rather than the success: a
request that reached the server costs it the same whether or not it succeeded, and stamping
on success makes a client retry hardest exactly when the server is least able to take it.

They are separate keys on purpose. A routine refresh must not consume the gate's 24h budget,
and a startup fetch from twenty hours ago must not satisfy the 60s floor — one value cannot
answer both. Neither can reuse pro_status_updated_at, which is written in the same statement
as the response blob and is unreadable without it, so a failed fetch has nothing to record
there.
…an unconfirmed status as confirmed

One commit because force -> immediate renames a parameter every one of these files calls, so
they cannot compile apart.

- Gate the startup get_pro_status fetch on whether a home CTA could plausibly fire, from
  synced config, with a persisted 24h minimum. Every cold start used to fetch, including for
  users who have never subscribed, and mobile cold-starts constantly.
- Make the 60s floor apply. It asked whether the load state was Loading or Loaded; a cold
  start begins at Init, which is neither, so it was skipped on exactly the path it exists for
  — and every background caller passed force=true anyway. It now reads the persisted attempt
  timestamp, and a process that has never fetched is exempt so the floor cannot become the
  "is one already running" mutex.
- force -> immediate, reserved to three callers named in the KDoc.
- Only report a confirmed status when THIS process confirmed it. The state mapping ended in a
  catch-all that swept up both Init and a Loaded restored from WorkManager's persisted work
  state, so at launch the refresh state read Success against a cached response and a renewal
  that happened while the app was closed splashed the Expired CTA off stale data. Reported in
  the wild. The catch-all is gone: the mapping is exhaustive, so the next state added has to
  declare which it is.
- Pro settings: refresh on open, and while open poll once a minute from when the renewal falls
  due until it lands, coverage ends or the screen closes. The grace label now needs a completed
  fetch at or after the crossing — a snapshot predating it cannot have seen the failure it warns
  about.
- Persist auto_renewing into config alongside the access expiry, unconditionally: libsession
  short-circuits a no-change write on a clean config, and a presence-based guard would be wrong
  because the key is erased rather than stored when false.

Two gate rows are deliberately built wrong and pinned by tests that will fail when the
decisions land: row 1 cannot see the real grace window (the expiry is grace-inclusive), and
the not-auto-renewing-but-active row declines to fetch while auto_renewing is presence-only.
Both match Desktop and iOS; the PR description carries them.
…e renewal is actually due

The access expiry get_pro_status sends is grace-INCLUSIVE — the backend folds grace in before
sending it and judges "active" against that same value, and its own test subtracts grace from
the wire value to recover the store's date. So the expiry is coverage end, and the renewal
falls due a grace period earlier.

Three things were wrong as a result:

- The renewal date rendered a whole grace period late.
- The grace indicator was unreachable: it tested now >= expiry inside a branch that requires
  now <= expiry, so only a single instant satisfied both.
- The startup gate's first row declined to fetch for the whole grace window, because that
  window lies inside "now < coverage end" — the gate was blind to exactly the state this
  rework exists to surface.

Subtracting is unconditional and needs no provider branching: the wire sends grace = 0
whenever the subscription is not auto-renewing.

Not cosmetic where it mattered. Apple configures no grace, so the backend's ~1h stand-in is
the whole of it. Google's is the operator-configured base-plan value in DAYS, fetched exactly
when the subscriber enters grace — so the date was days wrong on the screen whose purpose is
that date, and most wrong precisely when someone was looking at it.

Also moves the grace warning's "a completed fetch at or after the renewal fell due" condition
into toProStatus, where inGracePeriod is produced, instead of applying it at each consumer.
Five call sites read that flag; a sixth would have inherited no protection.

Needs the grace key in synced config, so the wrapper's submodule pin moves with it.
The proof response refreshes the cached access expiry, and the two fields that must travel with
it cannot be written yet. The intended lines are in the source, commented, with the blocker
named at the site.

Both incoherences are live today rather than introduced here:

- auto-renewing goes stale: an account whose expiry only came from a proof reads back as
  not-renewing while it is renewing, because the key is presence-only and nothing on this path
  sets it.
- grace pairs with the wrong expiry: paid-through is derived as E - G everywhere, so a fresh E
  beside a G from an older response is wrong by the difference. Worst where it matters, since
  Google reports its real multi-day grace only once the subscriber enters grace — a proof
  landing first pairs a new coverage end with the old stand-in.

The hole is in the middle of the chain: the backend sends both account_auto_renewing and
account_grace_period_duration, but parse_pro_proof reads only account_expiry_ts, so neither
reaches the response type or the wrapper. A core commit lands first.

No client-side workaround: reading the raw response behind libsession, or sourcing the flag
from anywhere other than this outcome, are both worse than the staleness.
… the access expiry

A proof response refreshes the cached access expiry, and the two fields that must travel with
it are now written alongside: the renewing flag, and the grace period the expiry has folded
into it. Without them an account whose expiry only ever came from a proof read back as
terminal while it was renewing, and `E - G` -- which everything now uses for the paid-through
instant -- was wrong by the difference between two responses. Worst where it matters: Google
reports its real multi-day grace only once the subscriber enters grace, so a proof landing
first paired a new coverage end with the ~1h stand-in.

Written ONLY when the backend actually sent them. Absent means "did not say", not false or
zero, and collapsing that is destructive rather than merely lossy: both config keys are
presence-only, so writing false or zero erases them, and a backend predating these fields
sends neither. A `?: false` in this path would make every proof fetch wipe a value correctly
learned from get_pro_status.

Needs the wrapper's matching commit, which re-pins libsession to the parse.
The single user_expiry wake fired 30s after the access expiry the backend sends. That value is
grace-inclusive, so it is the instant coverage ENDS -- the renewal became overdue a whole grace
period earlier, and the wake fired after the window it exists to catch had already closed. It
was the last trigger still keyed to the pre-correction reading of that field.

Now wakes 30s after the renewal falls due. Subtracting grace is a no-op for a non-auto-renewing
account, where the wire sends zero.

Note what this does NOT add: nothing wakes at the end of coverage once grace has been entered.
A failed renewal is learned at the renewal date, and after that the account is covered by the
startup gate -- which now fetches for exactly that state -- plus on-enter and the while-open
poll. Whether a second wake at coverage end is wanted is an open cross-client question.
…y comment

Trigger #6 becomes two instants: 30s after the renewal falls due, and 30s after coverage ends.
The second is guarded on the two coinciding, which is the case for every non-auto-renewing
account, where grace is zero.

It looks redundant with the proof loop and isn't. The backend issues a proof good until roughly
an hour past coverage end and the renewal target sits ~1h before that, so a proof attempt lands
near coverage end and its config write fires the config-change trigger -- but only when E
actually MOVES. A succeeded renewal advances E and is covered; a FAILED one leaves E unchanged
and nothing fires. The uncovered branch is the one the grace warning exists for.

No wake handles to leak: transformLatest cancels the whole body when E moves, so a wake armed
against a superseded expiry cannot outlive it. A scheduler holding ids would need a collection
for two instants -- holding one and scheduling two orphans a timer every period, while both
wakes still fire correctly.

Also corrects the severity claim in the grace comment. The multi-day operator-configured value
is written only on Google's IN_GRACE_PERIOD notification and reset to the ~1h stand-in on
RECOVERED/RENEWED, so it exists only during the grace window. A healthy auto-renewing
subscriber has grace of about an hour on both stores, so the renewal date was an hour late, not
days. The unreachable grace state was the real defect, and the gate's blindness to the window
is the consequence that genuinely spans days.
… test backend

Both user_expiry wake emits reach the network through the floored path, so when the grace period
is shorter than the 60s freshness floor the second wake lands inside the floor the first one just
armed and its fetch is dropped. The wake fires; the fetch does not, which looks exactly like the
wake was never scheduled.

Only reachable on a compressed testing backend -- google_play/mule.py overrides grace with
testing_grace_period_duration_ms = 10s. Production grace is either the backend's ~1h stand-in or
an operator-configured value in days, both far outside the floor.

Documented in two places because the two readers arrive from opposite directions and neither has
reason to open the other's file: at the floor constant, for someone tuning or removing it, and at
the wake, for someone debugging why it "didn't fire".

Left alone deliberately. If Pro UI-test work needs to exercise the coverage-end wake, the
sanctioned escape hatch is an env-var override of the floor, owned by that work. Not an immediate
fetch for a scheduled trigger -- that is what force -> immediate was introduced to stop.
libsession now REQUIRES both fields on a successful proof rather than treating them as optional,
so there is no "the backend did not say" state left to guard against and the presence checks come
out.

That is safe for a specific reason rather than by simplification: a missing or malformed field
fails the parse, so we never reach this write. Writing false to a presence-only config key ERASES
it, so a defaulted false would have been destructive rather than inert -- requiring the field is
what makes an unconditional write correct here. Inside the success branch both values are
truthful.

Needs the wrapper's matching commit, which re-pins libsession to 799f1972.
The write is already inside the success branch and its behaviour is unchanged. The comment
justifying it was wrong: it said a missing or malformed field fails the parse so the write is
never reached. That is not what protects it.

libsession's parse_pro_proof returns on the failure path BEFORE filling the renewal flag and the
grace period, so on every non-OK outcome they hold struct defaults of false and zero. There is no
presence flag on the C struct and the Kotlin type is non-nullable, so a read outside the success
branch gets false and cannot tell it from a backend that genuinely said "not renewing" -- and
writing false to a presence-only config key ERASES it.

Erasing is truthful for subscription_expired, not_subscribed and revoked. For a protocol error, a
stale request or a transport failure it would wipe a flag get_pro_status had correctly learned.

So the comment now says the protection is PLACEMENT, not the parse and not the type, and not to
hoist the two writes out of the success branch. Needs the wrapper's matching commit.
The backend no longer folds grace into the stored expiry — that fold was
removed upstream, and `grace_period_duration` now reports how much longer
we serve PAST the expiry sent. The backend states it directly: "expiry_ts
+ grace_period_duration is exactly when we stop serving".

So every subtraction goes away and the display gets simpler:

  - the renewal date is `expiry`, rendered as sent
  - the grace window is `[expiry, expiry + grace)`
  - coverage ends at `expiry + grace`
  - trigger #6 wakes at `expiry`, then again at `expiry + grace`

Grace now enters the startup gate in exactly one row, and earns its place
there: the auto-renewing row is bounded from COVERAGE end rather than the
payment date, so an account inside a multi-day grace is never mistaken
for a long-dead one, and an account whose renewing flag was never cleared
stops fetching on every cold start forever.

The earlier reading was traced against a checkout 70 commits behind
`origin/dev`, which is the whole lesson: the staleness trap already
documented for the client repos was never applied to the backend.
The status fetch wrote E and A but never G, so the expiry it stored paired
with whatever grace a proof outcome last left — or with nothing at all.
Everything downstream reads coverage as `E + G`, so the second wake at
coverage end and the settings grace poll's termination bound were both
computed against a grace this path never supplied.

Takes the ACCOUNT-level field, not `latestPayment.gracePeriod`: they share
a name and answer different questions — how much longer we serve, versus
what a store declared about one transaction.
mpretty-cyro and others added 28 commits September 28, 2026 16:03
…ooldown

Renew config message TTLs at most once an hour per swarm
Brings back the post-Pro-launch preference (pref_force_post_pro) removed in
0b751f7, now off by default in every build, with a `sessionPro` launch extra
(the iOS key) so QA harnesses can turn it on. Off, this account can neither use
nor buy Pro and nothing is restricted for lacking it; other people's Pro
(badges, message features) is still honoured. One commit so it reverts cleanly.

- Every self-facing guard 0b751f7 deleted, reapplied in the code's current
  shape. The logged-in Pro loops share one gate; turning it off cancels the
  status and proof workers, while the revocation list keeps polling for other
  people's proofs. Clearing our own revoked proof stays gated.
- Recipients always get a Pro data context; only our own proof is skipped, so a
  proof synced from another device grants nothing here and is left in config.
- Gates Pro code added since the removal: the display plan seeded from config,
  proof renewal scheduling and the proof worker, the access source (outgoing
  proof and declared features), the rotating-key signature, and the
  purchase-in-flight path.
- The conversation list's pref-event filter regains SET_FORCE_POST_PRO with the
  `||` it was missing, so the two force-Pro events are no longer ignored.
…in-translations

[Automated] Update translations from Crowdin
…gate

Pro: restore the Session Pro gate, off by default
…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 on Home, 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. Previously it was rejected as an
  invalid message.
- 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. An mms
  delete trigger keeps it in step with placeholder deletion.
- Retained messages are replayed at startup after an update, replacing placeholders in
  place.

The bubble and banner strings are hard-coded English and need adding to Crowdin.
- Push metadata `t` is already milliseconds; stop converting it, and pass
  the swarm expiry `z` through as the server expiry on both push paths.
- No placeholder in a hidden conversation; the record is still retained
  and the conversation stays hidden.
- Add `sender` and `sent_timestamp_ms` to `unsupported_message`, set for
  unknown types and NULL for the newer format.
- An unsend request deletes retained records with the same sender and
  sent timestamp.
- A failed reprocess keeps the record and stamps the version. The
  placeholder is detached before it's deleted, so a failed replay leaves
  the record in place without one.
- Enforce the retention limits and reprocess on launch and on returning
  to the foreground; limit failures are caught and logged.
- Add unsupported_message_stats (seeded single row), kept exact by
  insert/delete/update triggers, and the unsupported_message_kind_id index
- Count each row as length(data) + 256 against the 256 MB budget, evicting
  oldest newerFormat rows first in batches of up to 100
- Cap newerFormat rows at 10,000, evicting the oldest
- Check the caps from the stats row after each insert, with no table scan;
  scheduled maintenance still removes expired rows first
- Stamp newerFormat rows in one UPDATE and replay unknownType rows in pages
  of 50, choosing by stored kind rather than data bytes
- 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.
The daemon JVM was pinned to JetBrains 21, fetched through api.foojay.io package-id redirects. foojay
stopped serving those ids (foojayio/discoapi#165), and CI's setup-java Temurin 21 doesn't meet a
JetBrains criterion, so every CI build failed provisioning the daemon before it started.

Drop the vendor so any JDK 21 runs the daemon: CI's Temurin 21 is used as is, and locally Android
Studio's JBR 21 still is. For machines with no JDK 21, the Linux/macOS/Windows URLs now go straight
to Adoptium's API instead of through foojay; FreeBSD/Unix keep foojay, as Adoptium has no builds for
them. Re-running updateDaemonJvm overwrites the file.
The api.adoptium.net 'latest' URLs would hand a different JDK to a fresh machine whenever Adoptium
publishes a 21.x update. Point each platform at the 21.0.12.1+1 release asset instead, so a
provisioned daemon JDK only changes when this file does.
The previous commits edited gradle-daemon-jvm.properties by hand, so running updateDaemonJvm (to move
to a newer JDK, say) would have put the JetBrains vendor and foojay URLs back. Set languageVersion
and the Temurin download URLs on the updateDaemonJvm task instead, leaving the vendor unset; the
properties file is again plain generated output, and regenerating it reproduces it exactly.

Adoptium has no FreeBSD or other Unix builds, so those platforms get the Linux ones. That matches
what the generated foojay entries did (FREE_BSD/UNIX shared the Linux package ids), and those ids now
return 400 as well.
Run the Gradle daemon on any JDK 21 and download it from Adoptium
The erase ran inside the merge's write, which ConfigUploader doesn't push (it drops fromMerge
notifications) and which only dumps the merged config type: the UserGroups erase waited for some
later local change to be pushed, and the convo-info-volatile erase could be lost on restart. Finish
through removeGroup, whose write dumps and pushes like any local change; the erase inside the merge
stays so nothing reads the stub back first.

Also state the name assumption as the convention it is: libsession's header calls the name
invite-only.
Erase group entries a config merge recreated after another device erased them
…in-translations

[Automated] Update translations from Crowdin
…bubble

Show a placeholder for messages this version can't process, and retain them
1.34.0 was cancelled and its changes ship as a patch instead. The code stays above 453, which the cancelled 1.34.0 used (Play 4531-4535).
@mpretty-cyro
mpretty-cyro merged commit ab09bbc into master Oct 14, 2026
10 of 14 checks passed
@mpretty-cyro
mpretty-cyro deleted the release/1.33.6 branch October 14, 2026 02:19
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