- Tests for connection plans, prepared chats, edit/delete/selection, forwarding, drafts, group preferences and admission, group lifecycle, compose, live messages, item info, reports and moderation, privacy and SimpleX Lock, chat data and database, address and contact preferences, profiles, contact info, unread and search, files and reactions, group info and events, chat shell and statuses, joining, support and business chats, settings and appearance, network and servers, channels, remote desktop, Android intents and notifications.
- Harness: server stop/start, smp2/xftp2, SOCKS stub, CLI chat relays, CLI remote host/controller, chat scroll and drag helpers, file chooser answers, clipboard in popups.
- ui-tests workflow (manual dispatch only) running both suites on GitHub runners.
- App findings recorded in plan section 15.5 (items 7-23), each covered by a test marked @KnownAppIssue.
* desktop: open a pending connection's "Learn more" and "Incognito" pages once
On desktop the connection view stays clickable in the center pane, so each
click on these rows pushed another modal onto the right pane. Close the
right-pane modals first, as ChatView does when the connection opens. The page
is closed and shown again in one event, so its content is keyed by its
ModalData to start fresh.
Desktop only: on Android the view can itself be the open modal of the shared
manager ("Set contact name"), and the new page already covers the row.
Fixes#6270
* ui: open a modal once when its button is double-clicked
A modal slides in over 250 ms, and until it covers the button that opened it the button still takes clicks, so a double click opened two copies (left-pane pages and most stacked center and right-pane pages on desktop, animated modals on Android). When a modal opens, the page it covers draws an empty pointerInput box over itself while it slides out; when a pane's first modal slides in, the empty page sliding out covers the view under the pane. The page sliding in already takes clicks, and nothing is blocked when a page closes.
* ui: add isTopModalOpen, which ignores modals that are sliding out
A modal closed with animation stays in modalViews until it has slid out, and isLastModalOpen counts it. A check-then-closeModal() on it would close the modal under it. Uses the same index as isLastModal; isLastModalOpen and its callers are unchanged.
* desktop: don't open a second badges page or profile form
These buttons stay clickable on desktop while their page is shown in another pane, so each click opened another copy:
- "Support SimpleX" in the chat's large-file and badge alerts (Badges on the right): the alerts skip while Badges is open, on desktop only, as on Android the page covers the button;
- "+ Add profile" in Your chat profiles and "Create chat profile" in the picker (form in the center): skip while the form is on top, keeping what was typed, before asking for authentication; the picker row still hides the picker.
* desktop: replace a page of the same kind on top instead of stacking another
These buttons stay clickable on desktop while their page is shown in another pane, so each click pushed another copy:
- the chat banner's bio "Read more" (right pane);
- a markdown file clicked in a support chat (viewer in the center);
- message "Info" in the main chat while a support or reports chat is open on the right.
Their content depends on what was clicked and the pane can hold other pages (a support chat), so a page of the same kind on top is replaced. The new page is drawn where the old one was, so its content is keyed by its ModalData to start fresh. The markdown file and Info load on a background thread; their close and show now run on the main thread. The markdown check is desktop only, as on Android the viewer covers its file once it has loaded.
* plans: a page opens again on every click of its button (#6270)
* ui: create one group on a double click of Create group
"Create group" creates the group, opens it and only then closes its
page, and the core does not deduplicate, so a double click created two
groups. The page now ignores a click while its create is in progress,
on desktop and Android.
* plans: a double click on "create group" creates two groups
* plans: D2 is valid at 80 ms only
* ui: ignore a click on Create group until its page has closed after a create
* android, desktop: fix chat preview and unread counter updating wrong chat
While "Chat with admins", a member support chat or the reports view was
open, ChatsContext.addChatItem in the secondary context looked up the
chat's index in its own list but read and wrote the main chat list at
that index (chatsContext.chats[i]), so an unrelated chat in the main
list got the message as its preview and its unread count went up.
Use the context's own list at all four sites again. The main-list
qualifier had also made a pending invitee's sent support messages reach
the group's preview in the main list (at a secondary-list index), so
send that update to the main list explicitly: addSentChatItem passes a
sent item to the main chat list context as well when it is sent from a
secondary view and belongs in the main list (inMainChatList, the same
rule addChatItem uses for previews). Count unread stats only in the
main chat list context, so the revert does not make the open secondary
view count main-list messages in its own list.
* plans: wrong-chat preview and unread fix
* desktop: open a pending connection's "Learn more" and "Incognito" pages once
On desktop the connection view stays clickable in the center pane, so each
click on these rows pushed another modal onto the right pane. Close the
right-pane modals first, as ChatView does when the connection opens. The page
is closed and shown again in one event, so its content is keyed by its
ModalData to start fresh.
Desktop only: on Android the view can itself be the open modal of the shared
manager ("Set contact name"), and the new page already covers the row.
Fixes#6270
* ui: open a modal once when its button is double-clicked
A modal slides in over 250 ms, and until it covers the button that opened it the button still takes clicks, so a double click opened two copies (left-pane pages and most stacked center and right-pane pages on desktop, animated modals on Android). When a modal opens, the page it covers draws an empty pointerInput box over itself while it slides out; when a pane's first modal slides in, the empty page sliding out covers the view under the pane. The page sliding in already takes clicks, and nothing is blocked when a page closes.
* ui: add isTopModalOpen, which ignores modals that are sliding out
A modal closed with animation stays in modalViews until it has slid out, and isLastModalOpen counts it. A check-then-closeModal() on it would close the modal under it. Uses the same index as isLastModal; isLastModalOpen and its callers are unchanged.
* desktop: don't open a second badges page or profile form
These buttons stay clickable on desktop while their page is shown in another pane, so each click opened another copy:
- "Support SimpleX" in the chat's large-file and badge alerts (Badges on the right): the alerts skip while Badges is open, on desktop only, as on Android the page covers the button;
- "+ Add profile" in Your chat profiles and "Create chat profile" in the picker (form in the center): skip while the form is on top, keeping what was typed, before asking for authentication; the picker row still hides the picker.
* desktop: replace a page of the same kind on top instead of stacking another
These buttons stay clickable on desktop while their page is shown in another pane, so each click pushed another copy:
- the chat banner's bio "Read more" (right pane);
- a markdown file clicked in a support chat (viewer in the center);
- message "Info" in the main chat while a support or reports chat is open on the right.
Their content depends on what was clicked and the pane can hold other pages (a support chat), so a page of the same kind on top is replaced. The new page is drawn where the old one was, so its content is keyed by its ModalData to start fresh. The markdown file and Info load on a background thread; their close and show now run on the main thread. The markdown check is desktop only, as on Android the viewer covers its file once it has loaded.
* plans: a page opens again on every click of its button (#6270)
* plans: D2 is valid at 80 ms only
- apps/multiplatform/common/src/uiTestShared/README.md: rules for writing UI tests, from the test review;
- harness: any app crash fails the test (both platforms); known app bugs are marked with @KnownAppIssue and reported as skipped; no input retries (Android types through the input connection); peer error lines and app work still running at the end fail the test; restart re-reads settings from disk; Android stall watcher and resume after any abnormal instrumentation end; shared hideKeyboard();
- tests: ~25 merged or dropped, the rest strengthened per the review; ~25 new: files and images (local XFTP server), received replies, delivery status, disappearing messages, mentions, group links, incognito join, moderation, block for all, "show last messages", hidden profiles, Android onboarding;
- app: test keys only, on elements the new tests act on;
- plans: test review reports, findings 43-53, app findings 5-6, results.
- apps/multiplatform/scripts/waydroid: wd.sh (headless Waydroid for the Android UI tests and manual GUI testing), provision2.sh and watchdog2.sh for a second instance, weston.ini, fetch-images.sh, README; works on Qubes AppVMs and other Linux hosts (apt/dnf install, firewalld, own nft table, weston 10-14 flags);
- uitest-android.sh uses it by default (WD_SH overrides) and takes adb from it, so both use one adb;
- uitest.sh usage header;
- plans/ui-tests: test drafts (including unmerged batch 3), the 2026-10-14 review reports, results after the rebase.
Compose UI tests that drive the real app against local SMP servers and CLI peers:
- harness for desktop (in-process skiko scene, paused clock, parallel forks) and Android (instrumentation on any adb device or Waydroid, CLI peers through a host bridge);
- 112 tests shared by both platforms, 25 desktop-only, 15 Android-only;
- app code: test keys on the elements tests act on, and idle tracking of API calls and events, both inactive unless test hooks are enabled (debug Android builds, desktop tests);
- runners scripts/uitest.sh and scripts/uitest-android.sh, uitest-servers executable (cabal flag uitest);
- plans/ui-tests: the desktop and Android plans, the P1a start page, the Android research log, and the test catalogue with its review notes and scripts.
* desktop: fix crash in SimpleX address screen when there is no profile
* desktop: close SimpleX address screen when there is no profile
* desktop: rerun address screen self-close when its modal is reused or the stack changes
* desktop: close SimpleX address screen only when it is the top modal
* plans: correct address no-profile crash triggers and fix description
* plans: correct address no-profile crash fix description and verification
* plans: tighten address no-profile crash wording
* plans: add self-destruct trigger to address no-profile crash
* plans: qualify self-destruct trigger on desktop
* plans: drop desktop-only title from address no-profile crash
* plans: no connected mobile, not no mobile
* spec, product: document isLastModal and address screen without profile
* plans, spec, product: plan sections, address row spec, GAP-08 and GAP-09
* spec, product, plans: fix GAP-08/09 wording, table placement, ModalManager line
* spec, product: GAP-08/09 accuracy, gap count, clickable ModalManager location
* product, plans: GAP-09 at the level the code supports; qualify picker auto-open
* spec, product, plans: fix GAP-09 locations, stale line links, wording
* spec, product, plans: update plain-text line numbers; GAP-09 read wording
* plans: name the picker auto-open condition
* spec, plans: wording; security note
* plans: self-destruct did not crash on Android master; not a trigger
* plans: self-destruct path possible but unconfirmed; Android recovers silently
* spec, product, plans: drop GAP-09 and unrelated line refreshes
* plan: Android self-destruct path confirmed in logcat
* desktop: close Chat preferences screen when there is no profile, as the address screen does
* core: release core library to simplex-chat-libs every time cabal version changes
* implement
* test build
* update libs release
* test library release
* create release commit, so the date is correct
* rework to create release in advance in maybe-release job
* remove test build
* move postgresql job back
* test build
* remove test build
---------
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
* rfc: invite member of one group to another group
* update
* update
* implement
* version
* update
* do not show error if contact was removed when joining group
---------
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
* android: recognise passphrase fields as passwords for password managers
Passphrase fields used a hand-rolled VisualTransformation lambda, so Compose never
marked them as password fields: the autofill structure carried no hints and no
password inputType, and password managers could not identify them. The raw
passphrase was also exposed through the CopyText/ACTION_COPY accessibility action.
PassphraseField now declares an autofill content type and uses
PasswordVisualTransformation, which sets the password semantics Compose derives
that flag from.
Fields that create a passphrase declare NewPassword. Where no field would carry
plain password (first-time setup, hidden profile password) they declare both, as
managers do not recognise a form whose every field is newPassword. On the change
form the current-passphrase field supplies password, so the new fields declare
only newPassword - adding password there leaves the manager unable to tell which
field holds the new value and suppresses the save prompt.
* android: do not offer to save hidden profile passwords
Hidden profile password fields no longer declare an autofill content type.
Compose commits the autofill session, which shows the password manager's
save prompt, only after the last field with a content type leaves the
screen, so these fields never trigger it. A saved entry would reveal that
the hidden profile exists.
Rewrite the plan to match the code.
* android, desktop: storage breakdown in developer options
Port of the iOS Storage screen (#5529): lists each top-level entry of the app's data, config and temp folders with its recursive size, so it is possible to see what takes space.
* android, desktop: simplify storage view
Use DefaultProgressView, drop the bold total and redundant absoluteFile, and do not follow top-level symlinks (matches iOS; avoids counting the Android lib link).
* android, desktop: storage view: do not crash on names the JVM cannot convert to Path
Under a non-UTF-8 locale on Linux, File.toPath() throws InvalidPathException for a non-ASCII top-level name (e.g. a received file decrypted into tmpDir); log it and show 0 bytes. Also correct plan wording.
* android, desktop: storage view: fold path conversion into treeSize, use produceState
Also correct plan wording (du per-directory overhead, unlistable roots, Windows junctions).
* android, desktop: storage view: open as card screen like other settings screens
* android, desktop: storage view: list top-level entries as Path to keep their exact names
File.listFiles + toPath loses names that are not valid in the JVM file-name encoding (non-ASCII under a C locale, invalid UTF-8), so those entries showed 0 bytes. Files.newDirectoryStream keeps the raw bytes.
* android, desktop: storage view: normalize root paths before dropping nested roots
A root spelled with .. (e.g. XDG_DATA_HOME=/x/data/../tmp) compared as nested in another root and its size was shown nowhere.
* android, desktop: document storage view in spec and product docs
* android, desktop: storage view: single catch when listing a root
* android, desktop: do not log file paths in storage view
The walk's IOException messages contain the full path, which in tmpDir can be a received file's original name.
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
The core allocates every chat_* response with malloc and passes ownership to
the caller, as iOS does in API.swift. Both JNI bindings copied the bytes into a
Java string and dropped the pointer, so every command, event and file read
leaked its whole response into the C allocator - invisible to both the JVM and
the Haskell heap profiler.
Desktop frees in decode_to_utf8_string, the funnel every entry point already
goes through. Android has no such funnel because NewStringUTF is called inline,
so decode_and_free is added and the call sites routed through it. chatReadFile
frees directly on both.
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
* tests: close the walk stream in withTempDir
* desktop: receive app links from args and macOS
* desktop: forward an app link to the running app
* desktop: initialize the desktop platform lazily
* tests: share the temp directory helper
* desktop: register simplexchat scheme in packages
* desktop: register simplexchat scheme at runtime
* ui: end the badge page with a link on desktop
* desktop: raise the window over other apps
* docs: describe desktop deep links in the spec
* docs: plan desktop deep links
* docs: report on desktop deep links
* desktop: open simplex connection links
* docs: describe simplex links on desktop
* desktop: keep a link from opening twice
* desktop: retry taking a signal held open
* desktop: keep the user's AppImage link handler
* desktop: register Windows links through Capabilities
* desktop: add %u to the deb entry only once
* docs: describe the review fixes
* ui: open the badge link screen on the left
* ui: import moko accessors in the badge screens
---------
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
"Blur media" hid images and videos in a chat but showed the same media
sharp as the thumbnail of a quoted message and as the last message's
image, video or link preview in the chat list.
Small views are now blurred with the same reveal as chat media. They are
resampled relative to the width they are drawn at, so a thumbnail is
blurred as much on screen as media in a chat, and drawn as the centre
crop the thumbnail shows. In-chat media keeps the same blur.
Blurring the chat list also needs: each last message gets its own blur
state, loaded chat-list videos use it and hide their full-screen play
button while blurred, a right-click on a thumbnail no longer leaves it
revealed on desktop, and leaving a chat mid-scroll no longer undoes
reveals in the list on Android.
Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com>
* ui: blur media by resampling the preview, not a per-frame effect
Modifier.blur put a BlurEffect on a display-sized graphics layer, so the
Gaussian was re-evaluated on every frame the media was drawn. It also
requires RenderEffect, which Android applies only from API 31, so below
Android 12 media was drawn unblurred while the setting read as on.
A blur and a downscale discard the same thing - detail finer than their
radius - so the preview is now resampled to about one pixel per radius and
stretched back. That runs once when the item composes, needs no
RenderEffect, and removes the detail irreversibly rather than convolving
it. The first resampling step bounds both sides, so no image, however
shaped, can produce a large intermediate.
While the blur hides the media the file is also left unread, so an image
scrolled past is no longer read, decoded at its full size and kept in the
image cache only to be hidden again. blurHidesMedia() is the single
definition that both the drawing and the loading decision use, so they
cannot disagree about whether the media is on screen.
* plans: blur media by resampling the preview
* ui: shorten comments in media blur
* ui: smooth the media blur and match its strength to the old one
Stretching the resampled preview straight to the screen showed its pixel grid. Double it back
up until its longest side is at least half the reference width before drawing, so the last
stretch is short and the tents compose into a bell. The reference width moves from 360 to 400:
fitted against Gaussians, 360 blurred 5-75% more than Modifier.blur did, 400 is within 10% at
every setting.
(cherry picked from commit c0344a9a61)
video().track() in the buffer format callback takes the libvlc input lock,
which stop() holds while it waits for the decoder thread running that
callback. Take the video track from the media info only.
* android, desktop: open group member profile without waiting for the core
Tapping a member avatar awaited apiGroupMemberInfo and apiGetGroupMemberCode
before the profile modal was created. Both are single-row queries, 1-2 ms when
the core is idle, but sendCmd is serialized against everything else the core is
doing, so while it is busy - startup, a batch of incoming events, a long database
operation - the tap produces nothing at all until the core drains.
The modal is now opened first and the two values arrive in state the tap handler
creates, so the profile opens at the speed of the UI. The card is shown from the
member that is already known at the tap, preferring the model's copy once it is
there, so the member no longer has to be written to the model before the modal
can be shown.
* plans: open member profile without waiting for the core
* android, desktop: keep member profile rows in place while they load
The security code and the connection stats arrive after the profile is shown, so
"Verify security code" and the Servers section appeared under the already
visible card and pushed the rows below them down.
They are now rendered from the first frame in their final positions, disabled,
and enabled in place when the data arrives - SectionItemView already drops the
clickable modifier when disabled, so nothing moves. Only what can be decided from
the member known at the tap is reserved: "Fix connection" is not, as it applies
only while a ratchet needs syncing and a placeholder for it would disappear on
nearly every open.
* android, desktop: reserve the network status row with the servers
The reserved Servers section had three rows where the loaded one has four, so
everything below it - "Block member" - moved down by one row when the stats
arrived.
Network status looks like agent state that cannot be predicted before loading,
but connSubStatus returns Just whenever the connection has receiving queues, and
that is the same list that decides whether "Receiving via" is rendered. The two
rows appear together, so the status row is now reserved with them.
---------
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>