From a5c6ea8e5d45d8ce2299328a05b2af70e6939d40 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:25:09 +0000 Subject: [PATCH 01/48] core: member relations vector migration; set index in group for members; rework logic for avoiding duplicate introductions (#6445) --- bots/api/TYPES.md | 1 + .../types/typescript/src/types.ts | 1 + simplex-chat.cabal | 2 + src/Simplex/Chat/Library/Internal.hs | 4 +- src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 338 ++++++++++-------- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20251117_member_relations_vector.hs | 62 ++++ .../Store/Postgres/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20251117_member_relations_vector.hs | 106 ++++++ .../SQLite/Migrations/chat_query_plans.txt | 256 +++++++------ .../Store/SQLite/Migrations/chat_schema.sql | 9 +- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 1 + tests/ChatTests/Groups.hs | 2 +- 17 files changed, 534 insertions(+), 287 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 923fab14c2..9140f3b164 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -2198,6 +2198,7 @@ Known: **Record type**: - groupMemberId: int64 - groupId: int64 +- indexInGroup: int64 - memberId: string - memberRole: [GroupMemberRole](#groupmemberrole) - memberCategory: [GroupMemberCategory](#groupmembercategory) diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index b8095affdf..e67eae39a0 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2487,6 +2487,7 @@ export namespace GroupLinkPlan { export interface GroupMember { groupMemberId: number // int64 groupId: number // int64 + indexInGroup: number // int64 memberId: string memberRole: GroupMemberRole memberCategory: GroupMemberCategory diff --git a/simplex-chat.cabal b/simplex-chat.cabal index a731f14eb9..bd3ca6e353 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -121,6 +121,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade + Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector else exposed-modules: Simplex.Chat.Archive @@ -266,6 +267,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade + Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 163a37cb3f..711a24c79e 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1034,14 +1034,14 @@ introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRol introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do - members <- withStore' $ \db -> getGroupMembers db vr user gInfo + members <- withStore' $ \db -> getGroupMembersForIntroduction db vr user gInfo m let recipients = filter memberCurrent members introduceMember vr user gInfo m recipients Nothing introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToRemaining vr user gInfo m = do (members, introducedGMIds) <- - withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m + withStore' $ \db -> (,) <$> getGroupMembersForIntroduction db vr user gInfo m <*> getIntroducedGroupMemberIds db m let recipients = filter (introduceMemP introducedGMIds) members introduceMember vr user gInfo m recipients Nothing where diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 9467675272..bbb5b6a8c0 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -142,14 +142,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 00aa527603..3852665766 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -62,6 +62,7 @@ module Simplex.Chat.Store.Groups getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, + getGroupMembersForIntroduction, getGroupModerators, getGroupRelays, getGroupMembersForExpiration, @@ -190,11 +191,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -452,18 +453,35 @@ getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) +getUpdateNextIndexInGroup_ :: DB.Connection -> GroupId -> ExceptT StoreError IO Int64 +getUpdateNextIndexInGroup_ db groupId = + ExceptT . firstRow fromOnly (SEGroupNotFound groupId) $ + DB.query + db + [sql| + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index + |] + (Only groupId) + createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId - (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of - (Just profile@LocalProfile {displayName}, Just profileId) -> - (,profile) <$> insertMemberIncognitoProfile_ displayName profileId - _ -> (,profile' userOrContact) <$> liftIO insertMember_ + (indexInGroup, localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of + (Just profile@LocalProfile {displayName}, Just profileId) -> do + (indexInGroup, localDisplayName) <- insertMemberIncognitoProfile_ displayName profileId + pure (indexInGroup, localDisplayName, profile) + _ -> do + (indexInGroup, localDisplayName) <- insertMember_ + pure (indexInGroup, localDisplayName, profile' userOrContact) groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -484,40 +502,44 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe } where memberChatVRange@(VersionRange minV maxV) = vr - insertMember_ :: IO ContactName + insertMember_ :: ExceptT StoreError IO (Int64, ContactName) insertMember_ = do let localDisplayName = localDisplayName' userOrContact - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) - :. (minV, maxV) - ) - pure localDisplayName - insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName - insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = ExceptT $ - withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) :. (minV, maxV) ) - pure $ Right incognitoLdn + pure (indexInGroup, localDisplayName) + insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO (Int64, ContactName) + insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = + ExceptT . withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + pure (indexInGroup, incognitoLdn) deleteContactCardKeepConn :: DB.Connection -> Int64 -> Contact -> IO () deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {profileId}} = do @@ -542,16 +564,17 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" hostProfile = profileFromName $ nameFromMemberId memberId (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -737,16 +760,17 @@ createGroupViaLink' insertHost_ currentTs groupId = do (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -1000,6 +1024,14 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) +getGroupMembersForIntroduction :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> IO [GroupMember] +getGroupMembersForIntroduction db vr user@User {userId, userContactId} GroupInfo {groupId} _introduced@GroupMember {indexInGroup} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?") + (userId, groupId, userContactId, indexInGroup) + getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1067,21 +1099,22 @@ getGroupInvitation db vr user groupId = createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {connChatVersion, peerChatVRange}} memberRole agentConnId connRequest subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + void $ liftIO $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode pure member where VersionRange minV maxV = peerChatVRange invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do - insertMember_ + indexInGroup <- insertMember_ groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory = GCInviteeMember, @@ -1101,45 +1134,50 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, supportChat = Nothing } where - insertMember_ = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) - :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) + :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + :. (minV, maxV) + ) + pure indexInGroup createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode - setCommandConnId db user cmdId connId + Connection {connId} <- liftIO $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode + liftIO $ setCommandConnId db user cmdId connId where VersionRange minV maxV = peerChatVRange - insertMember_ memberId createdAt = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ memberId createdAt = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) + :. (minV, maxV) + ) createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember @@ -1161,26 +1199,28 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) profileId <- liftIO $ insertedRowId db - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do insertMember_ ldn profileId (MemberId memId) currentTs groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where VersionRange minV maxV = cReqChatVRange - insertMember_ ldn profileId memberId currentTs = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) - :. (minV, maxV) - ) + insertMember_ ldn profileId memberId currentTs = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) + :. (minV, maxV) + ) getMemberJoinRequest :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (Maybe (Maybe XContactId, Maybe SharedMsgId)) getMemberJoinRequest db User {userId} GroupInfo {groupId} GroupMember {groupMemberId = mId} = @@ -1242,22 +1282,24 @@ createBusinessRequestGroup membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr pure (groupId, membership) VersionRange minV maxV = cReqChatVRange - insertClientMember_ currentTs groupId membership = ExceptT $ do - withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do - createWithRandomId gVar $ \memId -> do - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - :. (minV, maxV) - ) + insertClientMember_ currentTs groupId membership = + ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + createWithRandomId' gVar $ \memId -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (minV, maxV) + ) groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) @@ -1417,7 +1459,7 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memContactId = Nothing, memProfileId } - liftIO $ createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember currentTs createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = @@ -1429,7 +1471,7 @@ createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDe profileId <- insertedRowId db pure $ Right (ldn, profileId) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -1449,24 +1491,27 @@ createNewMember_ let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange - DB.execute - db - [sql| - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) - :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) - :. (minV, maxV) - ) - groupMemberId <- insertedRowId db + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -1523,33 +1568,20 @@ createIntroductions db chatV members toMember = do then pure [] else do currentTs <- getCurrentTime - catMaybes <$> mapM (createIntro_ currentTs) reMembers + mapM (insertIntro_ currentTs) reMembers where - createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMemberIntro) - createIntro_ ts reMember = - -- when members connect concurrently, host would try to create introductions between them in both directions; - -- this check avoids creating second (redundant) introduction - checkInverseIntro >>= \case - Just _ -> pure Nothing - Nothing -> do - DB.execute - db - [sql| - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) - introId <- insertedRowId db - pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} - where - checkInverseIntro :: IO (Maybe Int64) - checkInverseIntro = - maybeFirstRow fromOnly $ - DB.query - db - "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1" - (groupMemberId' toMember, groupMemberId' reMember) + insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro + insertIntro_ ts reMember = do + DB.execute + db + [sql| + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) + introId <- insertedRowId db + pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do @@ -1704,11 +1736,10 @@ createIntroReMember currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} - liftIO $ do - member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode - liftIO $ setCommandConnId db user groupCmdId groupConnId - pure (member :: GroupMember) {activeConn = Just conn} + member <- createNewMember_ db user gInfo newMember currentTs + conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode + liftIO $ setCommandConnId db user groupCmdId groupConnId + pure (member :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -2468,21 +2499,22 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs - groupMemberId <- liftIO $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) - insertedRowId db + groupMemberId <- liftIO $ insertedRowId db getGroupMemberById db vr user groupMemberId where VersionRange minV maxV = vr diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 47aabd16ee..d7dd0fde01 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -674,7 +674,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe [sql| SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -2998,7 +2998,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -3006,13 +3006,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 89f8f6070b..9361713ea2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -21,6 +21,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade +import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -41,7 +42,8 @@ schemaMigrations = ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), - ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..197c90a66c --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,62 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251117_member_relations_vector :: Text +m20251117_member_relations_vector = + T.pack + [r| +ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BYTEA; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_numbered AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) AS rn +FROM group_members; + +CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = n.rn +FROM tmp_members_numbered n +WHERE n.group_member_id = gm.group_member_id; + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_numbered; +DROP TABLE tmp_members_numbered; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); +|] + +down_m20251117_member_relations_vector :: Text +down_m20251117_member_relations_vector = + T.pack + [r| +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 601dd97a6e..712099d7c9 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -706,7 +706,9 @@ CREATE TABLE test_chat_schema.group_members ( support_chat_items_mentions bigint DEFAULT 0 NOT NULL, support_chat_last_msg_from_member_ts timestamp with time zone, member_xcontact_id bytea, - member_welcome_shared_msg_id bytea + member_welcome_shared_msg_id bytea, + index_in_group bigint DEFAULT 0 NOT NULL, + member_relations_vector bytea ); @@ -805,7 +807,8 @@ CREATE TABLE test_chat_schema.groups ( request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, via_group_link_uri bytea, - summary_current_members_count bigint DEFAULT 0 NOT NULL + summary_current_members_count bigint DEFAULT 0 NOT NULL, + member_index bigint DEFAULT 0 NOT NULL ); @@ -2081,6 +2084,10 @@ CREATE INDEX idx_group_members_group_id ON test_chat_schema.group_members USING +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON test_chat_schema.group_members USING btree (group_id, index_in_group); + + + CREATE INDEX idx_group_members_invited_by ON test_chat_schema.group_members USING btree (invited_by); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 1c819e6537..13ada872b2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -144,6 +144,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -287,7 +288,8 @@ schemaMigrations = ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), - ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade) + ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..09ebe63708 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,106 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- to do list: +-- - directory migration +-- - background process to set member_relations_vector based on group_member_intros +-- - also set member_relations_vector on forward (recipient list for sender is known there) +-- - take member locks when updating member_relations_vector +-- - for duration of migration forwarding operates in 2 modes simultaneously: +-- - if member_relations_vector is set, use it +-- - otherwise, use existing logic based on group_member_intros +-- - new invitees start with member_relations_vector = 0 for all existing (pre) members -> +-- member_relations_vector immediately can be used when new invitee sends +-- - pre members are not updated right away for new invitee, if their member_relations_vector is not set yet, +-- as it will be costly to update them all at once; instead it will be set once background process processes them; +-- also this means group_member_intros have to be maintained for them until then +-- - GroupMember.memberStatusVector is Maybe to make this differentiation +-- - user clients migration +-- - once directory service migrates to new state, member_relations_vector can be updated in db migration +-- as user clients wouldn't have as large group_member_intros +-- - TBC migration SQL +-- - alternative approach for member_relations_vector migration (both directory and user clients): +-- - set to 0 for all existing members right away in sql migration +-- (possibly limit to groups where user is admin or above, otherwise NULL) +-- - means that initially after migration new messages will be forwarded to all members, +-- however they will quickly report connected state via XGrpMemCon -> member_relations_vector will self-adjust +-- - allows for simple migration path, with immediate switch from group_member_intros, +-- avoids complexity of dual-mode forwarding during migration for directory / complex sql migration for user clients +-- - rework forwarding logic to use member_relations_vector: +-- - create new members with correct index_in_group = group's member_index + 1, +-- maintain groups.member_index +-- - when new invitee joins, set member_relations_vector to all 0 for them, update for pre members (set 0 for invitee's seq id) +-- - on XGrpMemCon update bitvectors for sender and referenced member (set 1 for corresponding seq ids) +-- - don't maintain group_member_intros (don't create, update status) +-- - on forwarding, get recipients based on sender's member_relations_vector +-- - for all 0s in bitvector, get members by index_in_group in corresponding positions +-- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members +-- - has to be reworked to not rely on group_member_intros +-- - one approach could be to introduce accepted member to all (so, repeatedly introduce to moderators), +-- this idea was tested in PR 6327 +-- - another use of group_member_intros - createIntroductions, checkInverseIntro logic +-- - TBC how to avoid making redundant introductions between concurrently joining members +-- - second vector - for member introductions, or track in same vector +-- - when introducing to moderators only, do nothing - new moderators are introduced only to current members, +-- no pending in progress members, so race can't happen there +-- - when introducing to all, filter out members who already were introduced to this member +-- - can also solve previous issue of introducing remaining members in same way - don't introduce +-- to members this member already was introduced to +m20251117_member_relations_vector :: Query +m20251117_member_relations_vector = + [sql| +ALTER TABLE group_members ADD COLUMN index_in_group INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BLOB; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_numbered AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) AS rn +FROM group_members; + +CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = ( + SELECT rn + FROM tmp_members_numbered + WHERE tmp_members_numbered.group_member_id = gm.group_member_id +); + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_numbered; +DROP TABLE tmp_members_numbered; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups AS g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); +|] + +down_m20251117_member_relations_vector :: Query +down_m20251117_member_relations_vector = + [sql| +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index dbb4823024..e343eadaeb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -22,6 +22,40 @@ Query: Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + Query: UPDATE groups SET chat_ts = ?, @@ -42,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -113,14 +147,14 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -224,18 +258,11 @@ Plan: SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) SEARCH users USING INTEGER PRIMARY KEY (rowid=?) -Query: - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - -Plan: - Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -266,10 +293,44 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -431,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -464,8 +525,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -496,40 +557,6 @@ SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_mem SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) -Query: - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - Query: INSERT INTO messages ( msg_sent, chat_msg_event, msg_body, connection_id, group_id, @@ -844,7 +871,7 @@ SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_mem Query: SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -962,12 +989,19 @@ Query: Plan: +Query: + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -1064,7 +1098,7 @@ Query: -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -1072,13 +1106,13 @@ Query: -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember - rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, @@ -1611,44 +1645,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3790,6 +3790,15 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ? @@ -4460,7 +4469,7 @@ Query: SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? Plan: -SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) Query: SELECT DISTINCT group_id, worker_scope @@ -4970,7 +4979,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5004,7 +5013,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5031,7 +5040,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5080,7 +5089,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5107,7 +5116,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5126,7 +5135,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5139,13 +5148,13 @@ Query: LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.member_category = ? Plan: -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5164,7 +5173,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5183,7 +5192,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5202,7 +5211,26 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -702,7 +702,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -742,7 +742,7 @@ groupInfoQueryFields = g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index bb86cb2522..667660c97b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -921,6 +921,7 @@ type GroupMemberId = Int64 data GroupMember = GroupMember { groupMemberId :: GroupMemberId, groupId :: GroupId, + indexInGroup :: Int64, memberId :: MemberId, memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 52a015c31f..4c5efa26b6 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3663,7 +3663,7 @@ testGroupMsgDecryptError ps = withTestChat ps "bob" $ \bob -> do bob <## "subscribed 2 connections on server localhost" alice #> "#team hello again" - bob <# "#team alice> skipped message ID 9..11" + bob <# "#team alice> skipped message ID 8..10" bob <# "#team alice> hello again" bob #> "#team received!" alice <# "#team bob> received!" From af7540248ece729fbc0bb216b2b107384ce69bf6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 24 Nov 2025 07:36:44 +0000 Subject: [PATCH 02/48] core: relations vector operations (#6459) * core: relations vector operations * tests * simplify * comment * remove space * core: use 1 byte for each member-to-member relation (#6462) --------- Co-authored-by: Evgeny --- simplex-chat.cabal | 2 + src/Simplex/Chat/Types/MemberRelations.hs | 74 ++++++++ tests/MemberRelationsTests.hs | 209 ++++++++++++++++++++++ tests/Test.hs | 2 + 4 files changed, 287 insertions(+) create mode 100644 src/Simplex/Chat/Types/MemberRelations.hs create mode 100644 tests/MemberRelationsTests.hs diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bd3ca6e353..24ccf24ded 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -84,6 +84,7 @@ library Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Types + Simplex.Chat.Types.MemberRelations Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme @@ -544,6 +545,7 @@ test-suite simplex-chat-test JSONFixtures JSONTests MarkdownTests + MemberRelationsTests MessageBatching OperatorTests ProtocolTests diff --git a/src/Simplex/Chat/Types/MemberRelations.hs b/src/Simplex/Chat/Types/MemberRelations.hs new file mode 100644 index 0000000000..7245b22472 --- /dev/null +++ b/src/Simplex/Chat/Types/MemberRelations.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE LambdaCase #-} + +module Simplex.Chat.Types.MemberRelations + ( MemberRelation (..), + getRelation, + setRelation, + setRelations, + ) +where + +import Control.Monad +import Data.Bits ((.&.), (.|.), complement) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.ByteString.Internal (toForeignPtr, unsafeCreate) +import Data.Int (Int64) +import Data.Word (Word8) +import Foreign.ForeignPtr (withForeignPtr) +import Foreign.Marshal.Utils (copyBytes, fillBytes) +import Foreign.Ptr (plusPtr) +import Foreign.Storable (peekByteOff, pokeByteOff) + +data MemberRelation + = MRNew + | MRIntroduced + | MRConnected + deriving (Eq, Show) + +toRelationInt :: MemberRelation -> Word8 +toRelationInt = \case + MRNew -> 0 + MRIntroduced -> 1 + MRConnected -> 2 + +fromRelationInt :: Word8 -> MemberRelation +fromRelationInt = \case + 0 -> MRNew + 1 -> MRIntroduced + 2 -> MRConnected + _ -> MRNew + +-- | Get the relation status of a member at a given index from the relations vector. +-- Returns 'MRNew' if the vector is not long enough (lazy initialization). +getRelation :: Int64 -> ByteString -> MemberRelation +getRelation i v + | i < 0 || fromIntegral i >= B.length v = MRNew + | otherwise = fromRelationInt $ (v `B.index` fromIntegral i) .&. relationMask + + +-- | Set the relation status of a member at a given index in the relations vector. +-- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelation i r v + | i >= 0 = setRelations [(i, r)] v + | otherwise = v + +-- | Set multiple relations at once. +-- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString +setRelations [] v = v +setRelations relations v = + let (fp, off, len) = toForeignPtr v + newLen = max len $ fromIntegral $ maximum (map fst relations) + 1 + in unsafeCreate newLen $ \ptr -> do + withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len + when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len) + forM_ relations $ \(ix, r) -> when (ix >= 0) $ do + let i = fromIntegral ix + b <- peekByteOff ptr i + let b' = (b .&. complement relationMask) .|. toRelationInt r + pokeByteOff ptr i b' + +relationMask :: Word8 +relationMask = 0x07 -- reserving 3 bits diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs new file mode 100644 index 0000000000..968dcbec43 --- /dev/null +++ b/tests/MemberRelationsTests.hs @@ -0,0 +1,209 @@ +{-# LANGUAGE OverloadedStrings #-} + +module MemberRelationsTests where + +import Control.Monad +import qualified Data.ByteString as B +import Simplex.Chat.Types.MemberRelations +import Test.Hspec + +memberRelationsTests :: Spec +memberRelationsTests = do + describe "MemberRelation vector operations" $ do + describe "getRelation" $ do + it "returns MRNew for empty vector" $ do + getRelation 0 B.empty `shouldBe` MRNew + getRelation 5 B.empty `shouldBe` MRNew + getRelation 100 B.empty `shouldBe` MRNew + + it "returns MRNew for negative index" $ do + getRelation (-1) B.empty `shouldBe` MRNew + getRelation (-5) (B.pack [0xFF]) `shouldBe` MRNew + + it "returns MRNew for index beyond vector length" $ do + let vec = B.pack [0x00] + getRelation 10 vec `shouldBe` MRNew + + it "reads single relation from byte" $ do + let vec = B.pack [0x01] + getRelation 0 vec `shouldBe` MRIntroduced + + it "reads multiple relations" $ do + let vec = B.pack [0, 0, 1, 2] + getRelation 0 vec `shouldBe` MRNew + getRelation 1 vec `shouldBe` MRNew + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 3 vec `shouldBe` MRConnected + + it "reads multiple relations 2" $ do + let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0] + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRIntroduced + getRelation 4 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRConnected + + it "ignore reserved bits" $ do + let vec = B.pack [0xF9] -- 11111001 + getRelation 0 vec `shouldBe` MRIntroduced + + describe "setRelation" $ do + it "sets relation in empty vector (lazy expansion)" $ do + let vec = setRelation 0 MRIntroduced B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "ignores negative index" $ do + let vec = setRelation (-1) MRIntroduced B.empty + vec `shouldBe` B.empty + + it "expands vector to required length" $ do + let vec = setRelation 5 MRConnected B.empty + B.length vec `shouldBe` 6 + getRelation 5 vec `shouldBe` MRConnected + -- Other positions should be MRNew (0) + getRelation 0 vec `shouldBe` MRNew + getRelation 10 vec `shouldBe` MRNew + B.length vec `shouldBe` 6 + + it "updates existing relation without affecting others" $ do + -- Start: [01][01][00][00] + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + -- Update: [01][10][00][00] + let vec3 = setRelation 1 MRConnected vec2 + getRelation 0 vec3 `shouldBe` MRIntroduced + getRelation 1 vec3 `shouldBe` MRConnected + + it "updates relation in specific byte of multi-byte vector" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 10 MRConnected vec1 + B.length vec2 `shouldBe` 11 + getRelation 0 vec2 `shouldBe` MRIntroduced + getRelation 10 vec2 `shouldBe` MRConnected + forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew + + it "handles setting relation at last position in byte" $ do + let vec = setRelation 3 MRConnected B.empty + getRelation 3 vec `shouldBe` MRConnected + + it "preserves vector when setting same value" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 0 MRIntroduced vec1 + vec2 `shouldBe` vec1 + getRelation 0 vec2 `shouldBe` MRIntroduced + + it "preserves reserved bits" $ do + let v = B.pack [0xF8] -- 11111000 + getRelation 0 v `shouldBe` MRNew + let v' = setRelation 0 MRIntroduced v + getRelation 0 v' `shouldBe` MRIntroduced + B.unpack v' `shouldBe` [0xF9] -- 11111001 + + describe "setRelations" $ do + it "returns same vector for empty list" $ do + let vec = B.pack [0x42] + setRelations [] vec `shouldBe` vec + + it "sets multiple relations in empty vector" $ do + let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRConnected + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 3 vec `shouldBe` MRNew -- Unset position + + it "sets multiple relations 1" $ do + let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRConnected), (3, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 4 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRConnected + getRelation 2 vec `shouldBe` MRConnected + getRelation 3 vec `shouldBe` MRIntroduced + + it "sets multiple relations 2" $ do + let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 11 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRConnected + getRelation 10 vec `shouldBe` MRIntroduced + getRelation 7 vec `shouldBe` MRNew -- Unset position between + + it "handles sparse updates (few indices in large range)" $ do + -- Sparse: 3 updates in large group + let updates = [(0, MRIntroduced), (100, MRConnected), (5000, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 100 vec `shouldBe` MRConnected + getRelation 5000 vec `shouldBe` MRIntroduced + getRelation 50 vec `shouldBe` MRNew -- Untouched position + + it "handles dense updates (many consecutive indices)" $ do + -- Dense: many consecutive updates + let updates = [(i, if even i then MRIntroduced else MRConnected) | i <- [0 .. 99]] + let vec = setRelations updates B.empty + all (\i -> getRelation i vec == (if even i then MRIntroduced else MRConnected)) [0 .. 99] `shouldBe` True + + it "handles unsorted input correctly" $ do + let updates = [(10, MRConnected), (2, MRIntroduced), (5, MRConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRConnected + getRelation 10 vec `shouldBe` MRConnected + + it "handles duplicate indices (last one wins)" $ do + let updates = [(0, MRIntroduced), (0, MRConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "preserves existing relations not in update list" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelation 5 MRIntroduced vec1 + let updates = [(10, MRConnected)] + let vec3 = setRelations updates vec2 + getRelation 0 vec3 `shouldBe` MRConnected + getRelation 5 vec3 `shouldBe` MRIntroduced + getRelation 10 vec3 `shouldBe` MRConnected + + describe "edge cases and invariants" $ do + it "round-trip: set then get returns same value" $ do + let vec1 = setRelation 42 MRConnected B.empty + getRelation 42 vec1 `shouldBe` MRConnected + + it "multiple round-trips preserve values" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRConnected vec1 + let vec3 = setRelation 2 MRIntroduced vec2 + getRelation 0 vec3 `shouldBe` MRIntroduced + getRelation 1 vec3 `shouldBe` MRConnected + getRelation 2 vec3 `shouldBe` MRIntroduced + + it "setRelations equivalent to multiple setRelation calls" $ do + let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let vecBatch = setRelations updates B.empty + let vecSeq = setRelation 10 MRIntroduced $ setRelation 5 MRConnected $ setRelation 0 MRIntroduced B.empty + vecBatch `shouldBe` vecSeq + getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq + getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq + getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq + + it "handles large group size (10000 members)" $ do + let updates = [(0, MRIntroduced), (5000, MRConnected), (9999, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 10000 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5000 vec `shouldBe` MRConnected + getRelation 9999 vec `shouldBe` MRIntroduced + + it "all status values can be stored and retrieved" $ do + let vec1 = setRelation 0 MRNew B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + let vec3 = setRelation 2 MRConnected vec2 + getRelation 0 vec3 `shouldBe` MRNew + getRelation 1 vec3 `shouldBe` MRIntroduced + getRelation 2 vec3 `shouldBe` MRConnected + + it "vector length is minimal (lazy expansion)" $ do + let vec = setRelation 3 MRConnected B.empty + B.length vec `shouldBe` 4 diff --git a/tests/Test.hs b/tests/Test.hs index e4e76fd43e..e1a5a58c7d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -13,6 +13,7 @@ import Control.Logger.Simple import Data.Time.Clock.System import JSONTests import MarkdownTests +import MemberRelationsTests import MessageBatching import ProtocolTests import OperatorTests @@ -59,6 +60,7 @@ main = do #endif describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests + describe "Member relations" memberRelationsTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests From 65e3d8ca7cd5753dc9869280dfb4e1878484a624 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:43:33 +0000 Subject: [PATCH 03/48] core: fix relation vector indexes in code and migrations to start from 0 instead of 1 (#6460) --- src/Simplex/Chat/Store/Groups.hs | 2 +- .../M20251117_member_relations_vector.hs | 18 +++++------ .../M20251117_member_relations_vector.hs | 30 +++++++------------ .../SQLite/Migrations/chat_query_plans.txt | 2 +- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 3852665766..c04a31c0a9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -462,7 +462,7 @@ getUpdateNextIndexInGroup_ db groupId = UPDATE groups SET member_index = member_index + 1 WHERE group_id = ? - RETURNING member_index + RETURNING member_index - 1 |] (Only groupId) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs index 197c90a66c..3f6deeb80f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -18,31 +18,31 @@ ALTER TABLE group_members ADD COLUMN member_relations_vector BYTEA; CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); -CREATE TEMPORARY TABLE tmp_members_numbered AS +CREATE TEMPORARY TABLE tmp_members_indexed AS SELECT group_member_id, ROW_NUMBER() OVER ( PARTITION BY group_id ORDER BY group_member_id ASC - ) AS rn + ) - 1 AS idx_in_group FROM group_members; -CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); UPDATE group_members AS gm -SET index_in_group = n.rn -FROM tmp_members_numbered n -WHERE n.group_member_id = gm.group_member_id; +SET index_in_group = tmi.idx_in_group +FROM tmp_members_indexed tmi +WHERE tmi.group_member_id = gm.group_member_id; DROP INDEX tmp_idx_group_members_group_id_group_member_id; -DROP INDEX tmp_idx_members_numbered; -DROP TABLE tmp_members_numbered; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); UPDATE groups g SET member_index = COALESCE(( - SELECT MAX(index_in_group) + SELECT MAX(index_in_group) + 1 FROM group_members WHERE group_members.group_id = g.group_id ), 0); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index 09ebe63708..5c4251cc55 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -40,16 +40,8 @@ import Database.SQLite.Simple.QQ (sql) -- - for all 0s in bitvector, get members by index_in_group in corresponding positions -- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members -- - has to be reworked to not rely on group_member_intros --- - one approach could be to introduce accepted member to all (so, repeatedly introduce to moderators), --- this idea was tested in PR 6327 --- - another use of group_member_intros - createIntroductions, checkInverseIntro logic --- - TBC how to avoid making redundant introductions between concurrently joining members --- - second vector - for member introductions, or track in same vector --- - when introducing to moderators only, do nothing - new moderators are introduced only to current members, --- no pending in progress members, so race can't happen there --- - when introducing to all, filter out members who already were introduced to this member --- - can also solve previous issue of introducing remaining members in same way - don't introduce --- to members this member already was introduced to +-- - set MRIntroduced status in member relations vector on first introduction, +-- exclude previously introduced members on second introduction m20251117_member_relations_vector :: Query m20251117_member_relations_vector = [sql| @@ -61,33 +53,33 @@ ALTER TABLE group_members ADD COLUMN member_relations_vector BLOB; CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); -CREATE TEMPORARY TABLE tmp_members_numbered AS +CREATE TEMPORARY TABLE tmp_members_indexed AS SELECT group_member_id, ROW_NUMBER() OVER ( PARTITION BY group_id ORDER BY group_member_id ASC - ) AS rn + ) - 1 AS idx_in_group FROM group_members; -CREATE INDEX tmp_idx_members_numbered ON tmp_members_numbered(group_member_id); +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); UPDATE group_members AS gm SET index_in_group = ( - SELECT rn - FROM tmp_members_numbered - WHERE tmp_members_numbered.group_member_id = gm.group_member_id + SELECT idx_in_group + FROM tmp_members_indexed + WHERE tmp_members_indexed.group_member_id = gm.group_member_id ); DROP INDEX tmp_idx_group_members_group_id_group_member_id; -DROP INDEX tmp_idx_members_numbered; -DROP TABLE tmp_members_numbered; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); UPDATE groups AS g SET member_index = COALESCE(( - SELECT MAX(index_in_group) + SELECT MAX(index_in_group) + 1 FROM group_members WHERE group_members.group_id = g.group_id ), 0); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index e343eadaeb..9156b56fcb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3794,7 +3794,7 @@ Query: UPDATE groups SET member_index = member_index + 1 WHERE group_id = ? - RETURNING member_index + RETURNING member_index - 1 Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) From 594a74e337296a4ac35a12605257cb3792441ca3 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 25 Nov 2025 07:31:35 +0000 Subject: [PATCH 04/48] docs: relations vector rfc (#6463) --- .../2025-11-24-member-relations-vector.md | 133 ++++++++++++++++++ .../M20251117_member_relations_vector.hs | 37 ----- 2 files changed, 133 insertions(+), 37 deletions(-) create mode 100644 docs/rfcs/2025-11-24-member-relations-vector.md diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md new file mode 100644 index 0000000000..c4e8cf7224 --- /dev/null +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -0,0 +1,133 @@ +# Member relations vectors + +## Problem + +Maintaining member introduction records takes N^2 space. + +## Solution + +Migrate to member relations byte vector, with per member relation encoded by member index. + +Requires: + +1. Per group member index (Done). +2. Primitives to work with byte vector (Done). +3. Rework forwarding logic to use relations vector. +4. Rework introductions logic to use relations vector (avoid duplicate introductions). +5. Migration from introductions to vector. + +Migration is 2-stage: + +1. Live migration to accommodate large volume of introductions data, with admin client choosing mode of operation based on presence of relation vector for member. +2. Offline migration of remaining introduction records. Drop mode of operation based on introductions. + +### Forwarding + +When new invitee connects (CON) -> host makes introductions: + +1. For this invitee: set member relations to 'MRIntroduced' for respective members. _**(Take member lock)**_ +2. For pre-members: + - Member has vector: Set relation to 'MRIntroducedTo' for invitee member - N updates. _**(Take member locks/take group lock?)**_ + - No vector: Create introduction record (Transitional mode of operation based on introductions). + +When member reports XGrpMemCon ("connected with another member"), for both reporting and referenced members: + +1. Member has vector: Set relation to 'MRConnected'. _**(Take member lock)**_ +2. No vector: Update introduction record status (Transitional). + +When member sends message -> host forwards: + +1. Member has vector: Get recipients based on sender relations vector ('MRIntroduced' + 'MRIntroducedTo' members). +2. No vector: Get recipients based on introduction records (Transitional), set sender's vector. _**(Take member lock)**_ + - Compiled list of recipients to be marked as introduced; differentiate 'MRIntroduced'/'MRIntroducedTo'? (Complication of splitting introduced into 2 relations). + - Additionally get Connected members, currently they are filtered out as not requiring forward. (It is necessary to make a complete computation of vector in one go, as this member will then be skipped in background updates) + +#### Avoid duplicate forwards + +N updates approach allows us to avoid duplicate forwards: + +- Admin only forwards based on introductions embedded into relations vector: 'MRIntroduced', 'MRIntroducedTo'. + +- Admin doesn't forward to 'MRNew' members. + +Following diagram illustrates that in multi-admin scenario only host of "later" invitee (Bob) will forward messages between his and other admin's invitees. + +```mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + participant C as Cath + +note over A, C: Alice invites and introduces Cath +A <<->> C: invite, CON +A ->> B: announce Cath +A ->> C: introduce Bob +note over A, C: Bob invites and introduces Dan +create participant D as Dan +B <<->> D: invite, CON +B ->> A: announce Dan +B ->> C: announce Dan +B ->> D: introduce Alice, Cath +note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avoid duplicate forwards) +note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew +note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo +note over A, B: Only Bob forwards between Cath and Dan +C ->> B: x.grp.mem.con (connected to Dan) +D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) +note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected +note over A, B: Only Bob forwards between Cath and Dan +``` + +### Avoid duplicate introductions + +Scenario 1. Pending member is accepted to group -> avoid repeat introductions to moderators and above. + +Scenario 2. Two invitees connect to host concurrently -> avoid introductions race. + +Both can be solved by excluding already introduced members: +- Member (new invitee) has vector: filter out 'MRIntroduced', 'MRIntroducedTo' members from list of members to introduce. +- No vector: filter out based on introduction records (Transitional; `introduceToRemaining` + restore `checkInverseIntro` logic). + +### Live migration (Stage 1) + +Background process to set members' vectors based on introductions. + +Goes over members with NULL relation vector. Logic to determine relations is same as when setting sender's vector on forwarding. The latter is optimization -> faster migration of hot paths. _**(Take member locks)**_ + +TBC report when done - for directory service. Or we can track remaining member records with NULL vector. + +### Offline migration (Stage 2) + +TBC SQL to set relations vectors based on remaining introductions records. + +### Other considerations + +#### 1. Introductions race - missed introductions + +We may have identified race where some pairs of members may never become introduced to each other. It can occur if 2 hosts concurrently invite (announce) and introduce their respective invitees based to their respective local member lists. + +Consider such timeline: + + 1. Admin 1 invites Invitee 1. + + Invitee 1 connects to Admin 1 (CON). + + Admin 1 announces (x.grp.mem.new) Invitee 1 and introduces him to known members (Admin 1 hasn't seen Invitee 2). + + 2. Admin 2 invites Invitee 2. + + Invitee 2 connects to Admin 2 (CON). + + _Consider following scenario: Admin 2 hasn't received x.grp.mem.new for Invitee 1 from Admin 1._ Admin 2 announces (x.grp.mem.new) Invitee 2 and introduces him to known members (Admin 2 hasn't seen Invitee 1). + + 3. Both admins receive (with delay) opposite x.grp.mem.new -> both admins already made introductions before and consider opposite admin would introduce "new" member to their "older" invitee. + +This is status quo, this work will not improve it. + +We will revert change of admins making decision of introductions lists based purely on member index, which may have made such race more likely. Instead they will determine introductions lists as following: all current members minus already introduced members (see "Avoid duplicate introductions" section). + +#### 2. Double x.grp.mem.con notifications + +As alternative to N updates for introduced members, we considered redundant forwarding in multi-admin scenario and modifying user clients (2-stage release) to send x.grp.mem.con notifications to both own host and host of connected member. + +Not symmetrical: a "later" invitee doesn't know which member is the host of an "earlier" invitee. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index 5c4251cc55..bf5308bcfd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -5,43 +5,6 @@ module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector wh import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- to do list: --- - directory migration --- - background process to set member_relations_vector based on group_member_intros --- - also set member_relations_vector on forward (recipient list for sender is known there) --- - take member locks when updating member_relations_vector --- - for duration of migration forwarding operates in 2 modes simultaneously: --- - if member_relations_vector is set, use it --- - otherwise, use existing logic based on group_member_intros --- - new invitees start with member_relations_vector = 0 for all existing (pre) members -> --- member_relations_vector immediately can be used when new invitee sends --- - pre members are not updated right away for new invitee, if their member_relations_vector is not set yet, --- as it will be costly to update them all at once; instead it will be set once background process processes them; --- also this means group_member_intros have to be maintained for them until then --- - GroupMember.memberStatusVector is Maybe to make this differentiation --- - user clients migration --- - once directory service migrates to new state, member_relations_vector can be updated in db migration --- as user clients wouldn't have as large group_member_intros --- - TBC migration SQL --- - alternative approach for member_relations_vector migration (both directory and user clients): --- - set to 0 for all existing members right away in sql migration --- (possibly limit to groups where user is admin or above, otherwise NULL) --- - means that initially after migration new messages will be forwarded to all members, --- however they will quickly report connected state via XGrpMemCon -> member_relations_vector will self-adjust --- - allows for simple migration path, with immediate switch from group_member_intros, --- avoids complexity of dual-mode forwarding during migration for directory / complex sql migration for user clients --- - rework forwarding logic to use member_relations_vector: --- - create new members with correct index_in_group = group's member_index + 1, --- maintain groups.member_index --- - when new invitee joins, set member_relations_vector to all 0 for them, update for pre members (set 0 for invitee's seq id) --- - on XGrpMemCon update bitvectors for sender and referenced member (set 1 for corresponding seq ids) --- - don't maintain group_member_intros (don't create, update status) --- - on forwarding, get recipients based on sender's member_relations_vector --- - for all 0s in bitvector, get members by index_in_group in corresponding positions --- - second use of group_member_intros is targeted introductions of knocking member to "remaining" members --- - has to be reworked to not rely on group_member_intros --- - set MRIntroduced status in member relations vector on first introduction, --- exclude previously introduced members on second introduction m20251117_member_relations_vector :: Query m20251117_member_relations_vector = [sql| From efbc4835a8e64ae85fb11cc0d38bff4f0fcab2f4 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:06:40 +0000 Subject: [PATCH 05/48] tests: skip multicast discovery in Mac CI (#6458) * tests: skip multicast discovery in Mac CI * tests: remove unneeded xitMacCI'' --- tests/ChatTests/Utils.hs | 3 +++ tests/RemoteTests.hs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index dfb0aece74..778ddec72f 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -93,6 +93,9 @@ xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) xit'' = ifCI xit Hspec.it +xitMacCI :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) +xitMacCI = ifCI (if os == "darwin" then xit else it) it + xdescribe'' :: HasCallStack => String -> SpecWith a -> SpecWith a xdescribe'' = ifCI xdescribe describe diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e21f97d69e..9790681f3b 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -33,7 +33,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest - it "connects with multicast discovery" remoteHandshakeDiscoverTest + xitMacCI "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest it "connects with stored server bindings" storedBindingsTest it "sends messages" remoteMessageTest From 5e160298410b87975b194163d843d18e7360cecd Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 25 Nov 2025 15:49:33 +0000 Subject: [PATCH 06/48] ios: reduce memory used by iOS share extension (#6465) --- apps/ios/SimpleX SE/ShareAPI.swift | 38 +++++++++++++++++++++++++--- apps/ios/SimpleX SE/ShareModel.swift | 12 ++++----- apps/ios/SimpleXChat/ChatUtils.swift | 4 +-- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 4dad9d5d15..6495d09b03 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -48,7 +48,7 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r.unexpected } -func apiGetChats(userId: User.ID) throws -> Array { +func apiGetChats(userId: User.ID) throws -> Array { let r: APIResult = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) if case let .result(.apiChats(user: _, chats: chats)) = r { return chats } throw r.unexpected @@ -170,7 +170,7 @@ enum SEChatResponse: Decodable, ChatAPIResult { case activeUser(user: User) case chatStarted case chatRunning - case apiChats(user: UserRef, chats: [ChatData]) + case apiChats(user: UserRef, chats: [SEChatData]) case newChatItems(user: UserRef, chatItems: [AChatItem]) case cmdOk(user_: UserRef?) @@ -199,7 +199,7 @@ enum SEChatResponse: Decodable, ChatAPIResult { } static func fallbackResult(_ type: String, _ json: NSDictionary) -> SEChatResponse? { - if type == "apiChats", let r = parseApiChats(json) { + if type == "apiChats", let r = seParseApiChats(json) { .apiChats(user: r.user, chats: r.chats) } else { nil @@ -239,3 +239,35 @@ enum SEChatEvent: Decodable, ChatAPIResult { } } } + +public struct SEChatData: Decodable, Identifiable, Hashable, ChatLike { + public var chatInfo: ChatInfo + + public var id: ChatId { get { chatInfo.id } } + + public init(chatInfo: ChatInfo) { + self.chatInfo = chatInfo + } + + public static func invalidJSON(_ json: Data?) -> SEChatData { + SEChatData(chatInfo: .invalidJSON(json: json)) + } +} + +public func seParseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [SEChatData])? { + if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChats["user"] as Any), + let jChats = jApiChats["chats"] as? NSArray { + let chats: [SEChatData] = jChats.map { jChat in + if let jChatDict = jChat as? NSDictionary, + let jChatInfo = jChatDict["chatInfo"], + let chatInfo: ChatInfo = try? decodeObject(jChatInfo) { + return SEChatData(chatInfo: chatInfo) + } + return SEChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted)) + } + return (user, chats) + } else { + return nil + } +} diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 5080cf2040..fd5c4c990f 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -19,11 +19,11 @@ private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000 class ShareModel: ObservableObject { @Published var sharedContent: SharedContent? - @Published var chats: [ChatData] = [] + @Published var chats: [SEChatData] = [] @Published var profileImages: [ChatInfo.ID: UIImage] = [:] @Published var search = "" @Published var comment = "" - @Published var selected: ChatData? + @Published var selected: SEChatData? @Published var isLoaded = false @Published var bottomBar: BottomBar = .loadingSpinner @Published var errorAlert: ErrorAlert? @@ -60,13 +60,13 @@ class ShareModel: ObservableObject { } } - func isProhibited(_ chat: ChatData?) -> Bool { + func isProhibited(_ chat: SEChatData?) -> Bool { if let chat, let sharedContent { sharedContent.prohibited(in: chat, hasSimplexLink: hasSimplexLink) } else { false } } - var filteredChats: [ChatData] { + var filteredChats: [SEChatData] { search.isEmpty ? filterChatsToForwardTo(chats: chats) : filterChatsToForwardTo(chats: chats) @@ -253,7 +253,7 @@ class ShareModel: ObservableObject { } } - private func fetchChats() -> Result, ErrorAlert> { + private func fetchChats() -> Result, ErrorAlert> { do { guard let user = try apiGetActiveUser() else { return .failure( @@ -396,7 +396,7 @@ enum SharedContent { } } - func prohibited(in chatData: ChatData, hasSimplexLink: Bool) -> Bool { + func prohibited(in chatData: SEChatData, hasSimplexLink: Bool) -> Bool { chatData.prohibitedByPref( hasSimplexLink: hasSimplexLink, isMediaOrFileAttachment: cryptoFile != nil, diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 98ee9cd5d4..451ac8b4ef 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -9,9 +9,7 @@ import Foundation public protocol ChatLike { - var chatInfo: ChatInfo { get} - var chatItems: [ChatItem] { get } - var chatStats: ChatStats { get } + var chatInfo: ChatInfo { get } } extension ChatLike { From 4827e6c736fc00259668b64d117630543c63d97e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 27 Nov 2025 00:35:12 +0000 Subject: [PATCH 07/48] website: update donations page --- docs/DONATIONS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md index 3fdf362021..be8010eef2 100644 --- a/docs/DONATIONS.md +++ b/docs/DONATIONS.md @@ -19,7 +19,7 @@ Please donate via: - BTC: [bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u](bitcoin:bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u) - XMR: [8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt](monero:8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt) - BCH: [qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg](bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg) -- ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) +- ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) ([donate.simplexchat.eth](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a)) - ZEC: [t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg](zcash:t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg) - ZEC shielded: [u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq](zcash:u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq) - DOGE: [D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf](dogecoin:D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf) @@ -30,3 +30,7 @@ Thank you, Evgeny SimpleX Chat founder + +## SimpleX Community Vouchers + +Please comment on our plan to make SimpleX network sustainable and get a free access pass (an NFT) for early testing: https://simplex.chat/vouchers From 3691bf9e67ec1ed6ab7f1675ffeae4c9754418e4 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 27 Nov 2025 23:36:04 +0000 Subject: [PATCH 08/48] website: update donations page --- docs/DONATIONS.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md index be8010eef2..276a57ed8a 100644 --- a/docs/DONATIONS.md +++ b/docs/DONATIONS.md @@ -15,21 +15,15 @@ Your donations help us raise more funds - any amount, even the price of the cup Please donate via: -- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission). +- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission) - BTC: [bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u](bitcoin:bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u) - XMR: [8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt](monero:8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt) -- BCH: [qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg](bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg) - ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) ([donate.simplexchat.eth](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a)) -- ZEC: [t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg](zcash:t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg) -- ZEC shielded: [u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq](zcash:u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq) -- DOGE: [D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf](dogecoin:D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf) -- SOL: [7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu](solana:7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu) +- [Other cryptocurrencies](https://github.com/simplex-chat/simplex-chat#please-support-us-with-your-donations) Thank you, -Evgeny - -SimpleX Chat founder +Evgeny, SimpleX Chat founder ## SimpleX Community Vouchers From f5896d6ac51baab1469dffd3ad74ada7fb568217 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 29 Nov 2025 22:59:58 +0000 Subject: [PATCH 09/48] website: update jobs --- docs/JOIN_TEAM.md | 54 +++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 2e428409df..edf3ba199b 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -6,31 +6,49 @@ layout: layouts/jobs.html # Join SimpleX Chat team -SimpleX Chat is a seed stage startup with a lot of user growth in 2022-2025, and a lot of exciting technical and product problems to solve to grow faster. - -We currently have 4 people in the team. - -We are looking for passionate and creative people to help us! +Join SimpleX Chat team to build the future of secure, private, and decentralized communications. ## Who we are looking for -### Mobile application developer +### iOS Engineer -You: -- created mobile applications for Android platforms as **your own full-time or side projects**, -- expertise with Android APIs, Kotlin and JetPack Compose framework, -- [a good taste](https://paulgraham.com/taste.html) for mobile apps design would be a bonus. +We are looking for an entrepreneurial iOS engineer. -It is not a full time job yet, we have some specific problems to solve in the Android app. If we are happy working together it is likely to evolve into a full-time job offer in 2026. +Please send your exceptional achievements related to iOS (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular iOS open-source libraries or frameworks. +- iOS apps you developed on your own or as part of 2-people team. +- technical publications about iOS engineering. -Please ONLY apply if you created and released your own apps (not as a job or contract for somebody else). +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +### Application Engineer + +We are looking for an entrepreneurial Android/Desktop applications engineer with an advanced expertise in: +- Kotlin Multiplatform. +- Jetpack Compose and JVM development. +- desktop applications for Linux, Mac and Windows. +- advanced knowledge of C/C++, Java, JavaScript and some other programming languages. + +Please send your exceptional engineering achievements (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular open-source libraries, frameworks or applications. +- apps you developed on your own or as part of 2-3 people team. +- technical publications about engineering. + +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +

Community Builder

+ +We are looking for an entrepreneurial Community Builder and Marketer, with successful crowdfunding experience. + +Please send your exceptional achievements related to community building, social media marketing, or crowdfunding (created in your free time or in startups you co-founded, but not as part of contract or employment): +- large, active online communities, social media accounts, podcasts, or YouTube channels. +- successful crowdfunding campaigns that raised significant funds. +- high-profile publications, interviews, or viral content pieces. + +This is a part-time remote contract. You must be in UTC +/- 8 hours timezone. ## How to join the team -1. [Install the app](https://github.com/simplex-chat/simplex-chat#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. +To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). -2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test. - -3. [Connect to us](https://smp4.simplex.im/a#IWCurmcnKDvfOzGrQdqlXjKinqkvO10a2q__nWBVG6c) via SimpleX Chat to chat about what you want to contribute and about joining the team. - -4. You can also email [jobs@simplex.chat](mailto:jobs@simplex.chat?subject=Join%20SimpleX%20Chat%20team) +**We do NOT review CVs at the initial stage, please only send the links to your achievements**. From 8ff0ccf3922c8ecdd054bef9a8abba1aa2cf1582 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:13:18 +0000 Subject: [PATCH 10/48] ci: build simplex-chat CLI deb package (#6474) * ci: build simplex-chat CLI deb package * scripts/build-cli-deb: reproducible size (also actual file size) * scripts/build-cli-deb: fix epoch * scripts/build-cli-deb: set epoch to 1764547200 --- .github/workflows/build.yml | 40 ++++++++++++++++++++++++++++---- scripts/desktop/build-cli-deb.sh | 37 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) create mode 100755 scripts/desktop/build-cli-deb.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fc1c18fe1..04c70546fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -221,6 +221,16 @@ jobs: done strip /out/simplex-chat + - name: Build CLI deb + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + shell: docker exec -t builder sh -eu {0} + run: | + version=${{ github.ref }} + version=${version#refs/tags/v} + version=${version%-*} + + ./scripts/desktop/build-cli-deb.sh "$version" + - name: Copy tests from container if: matrix.should_run == true shell: bash @@ -232,21 +242,41 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: bash run: | - docker cp builder:/out/simplex-chat ./simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }} - path="${{ github.workspace }}/simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}" - echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-256\(simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + cli_name="simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}" + cli_deb_name="${cli_name}.deb" + cli_path="${{ github.workspace }}" + + docker cp builder:/out/simplex-chat "./${cli_name}" + docker cp builder:/out/deb-build/simplex-chat.deb "./${cli_deb_name}" + + echo "bin_name=${cli_name}" >> $GITHUB_OUTPUT + echo "bin_path=${cli_path}/${cli_name}" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-256\(${cli_name}\)= $(openssl sha256 "${cli_path}/${cli_name}" | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + echo "deb_name=${cli_deb_name}" >> $GITHUB_OUTPUT + echo "deb_path=${cli_path}/${cli_deb_name}" >> $GITHUB_OUTPUT + echo "deb_hash=$(echo SHA2-256\(${cli_deb_name}\)= $(openssl sha256 "${cli_path}/${cli_deb_name}" | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true uses: ./.github/actions/prepare-release with: + bin_name: ${{ steps.linux_cli_prepare.outputs.bin_name }} bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} - bin_name: simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }} bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }} github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Upload CLI deb + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + uses: ./.github/actions/prepare-release + with: + bin_name: ${{ steps.linux_cli_prepare.outputs.deb_name }} + bin_path: ${{ steps.linux_cli_prepare.outputs.deb_path }} + bin_hash: ${{ steps.linux_cli_prepare.outputs.deb_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Build Desktop if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} diff --git a/scripts/desktop/build-cli-deb.sh b/scripts/desktop/build-cli-deb.sh new file mode 100755 index 0000000000..7422520d71 --- /dev/null +++ b/scripts/desktop/build-cli-deb.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +set -eu + +export SOURCE_DATE_EPOCH=1764547200 + +CLI_VERSION="$1" +CLI_PATH_TO_BIN="${2:-/out/simplex-chat}" +BUILD_FOLDER="${3:-/out/deb-build}" + +size=$(stat -c '%s' "$CLI_PATH_TO_BIN" | awk '{printf "%.0f\n", ($1+1023)/1024}') +arch=$(case "$(uname -m)" in x86_64) printf "amd64" ;; aarch64) printf "arm64" ;; *) printf "unknown" ;; esac) +package='simplex-chat' + +mkdir "$BUILD_FOLDER" +cd "$BUILD_FOLDER" + +mkdir -p ./${package}/DEBIAN +mkdir -p ./${package}/usr/bin +cat > ./${package}/DEBIAN/control << EOF +Package: ${package} +Version: ${CLI_VERSION} +Section: Messenger +Priority: optional +Architecture: ${arch} +Maintainer: SimpleX Chat +Description: SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! (CLI) +Installed-Size: ${size} +EOF + +cp "$CLI_PATH_TO_BIN" ./${package}/usr/bin/simplex-chat +chmod +x ./${package}/usr/bin/simplex-chat + +find ./${package} -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + + +dpkg-deb --build --root-owner-group --uniform-compression ./${package} + +strip-nondeterminism "./${package}.deb" From 702f198566bc9a0bdada26eda5fccf580af3df8b Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:33:30 +0000 Subject: [PATCH 11/48] ci: add cli deb and fix formatting to reproducible-builds (#6483) * scripts/reproducible-builds: add cli deb and fix formatting * scripts/reproducible-builds: properly strip v from tag --- scripts/simplex-chat-reproduce-builds.sh | 216 ++++++++++++----------- 1 file changed, 117 insertions(+), 99 deletions(-) diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index c7d9170c4a..e1a62dc73a 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -20,115 +20,133 @@ package direct-sqlcipher export DOCKER_BUILDKIT=1 +version=${TAG#v} +version=${version%-*} + cleanup() { - docker exec -t "${container_name}" sh -c 'rm -rf ./dist-newstyle ./apps' 2>/dev/null || : - rm -rf -- "${tempdir}" - docker rm --force "${container_name}" 2>/dev/null || : - docker image rm "${image_name}" 2>/dev/null || : - cd "${init_dir}" + docker exec -t "${container_name}" sh -c 'rm -rf ./dist-newstyle ./apps' 2>/dev/null || : + rm -rf -- "${tempdir}" + docker rm --force "${container_name}" 2>/dev/null || : + docker image rm "${image_name}" 2>/dev/null || : + cd "${init_dir}" } trap 'cleanup' EXIT INT mkdir -p "${init_dir}/${TAG}-${repo_name}/from-source" "${init_dir}/${TAG}-${repo_name}/prebuilt" git -C "${tempdir}" clone "${repo}.git" &&\ - cd "${tempdir}/${repo_name}" &&\ - git checkout "${TAG}" + cd "${tempdir}/${repo_name}" &&\ + git checkout "${TAG}" for os in '22.04' '24.04'; do - os_url="$(printf '%s' "${os}" | tr '.' '_')" + os_url="$(printf '%s' "${os}" | tr '.' '_')" - cli_name="simplex-chat-ubuntu-${os_url}-x86_64" - deb_name="simplex-desktop-ubuntu-${os_url}-x86_64.deb" - appimage_name="simplex-desktop-x86_64.AppImage" + cli_name="simplex-chat-ubuntu-${os_url}-x86_64" + deb_name="simplex-desktop-ubuntu-${os_url}-x86_64.deb" + appimage_name="simplex-desktop-x86_64.AppImage" - # Build image - docker build \ - --no-cache \ - --build-arg TAG="${os}" \ - --build-arg GHC="${ghc}" \ - -f "${tempdir}/${repo_name}/Dockerfile.build" \ - -t "${image_name}" \ - . + # Build image + docker build \ + --no-cache \ + --build-arg TAG="${os}" \ + --build-arg GHC="${ghc}" \ + -f "${tempdir}/${repo_name}/Dockerfile.build" \ + -t "${image_name}" \ + . - printf '%s' "${cabal_local}" > "${tempdir}/${repo_name}/cabal.project.local" + printf '%s' "${cabal_local}" > "${tempdir}/${repo_name}/cabal.project.local" - # Run container in background - docker run -t -d \ - --name "${container_name}" \ - --device /dev/fuse \ - --cap-add SYS_ADMIN \ - --security-opt apparmor:unconfined \ - -v "${tempdir}/${repo_name}:/project" \ - "${image_name}" + # Run container in background + docker run -t -d \ + --name "${container_name}" \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --security-opt apparmor:unconfined \ + -v "${tempdir}/${repo_name}:/project" \ + "${image_name}" - # Consistent permissions - docker exec \ - -t "${container_name}" \ - sh -c 'find /project -type d -exec chmod 755 {} \; ; find /project -type f -perm /111 -exec chmod 755 {} \; ; find /project -type f ! -perm /111 -exec chmod 644 {} \;' + # Consistent permissions + docker exec \ + -t "${container_name}" \ + sh -c 'find /project -type d -exec chmod 755 {} \; ; find /project -type f -perm /111 -exec chmod 755 {} \; ; find /project -type f ! -perm /111 -exec chmod 644 {} \;' - # CLI - docker exec \ - -t "${container_name}" \ - sh -c 'cabal clean && cabal update && cabal build -j && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' + # CLI + docker exec \ + -t "${container_name}" \ + sh -c 'cabal clean && cabal update && cabal build -j && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' - # Copy CLI - docker cp \ - "${container_name}":/out/simplex-chat \ - "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}" + # Copy CLI + docker cp \ + "${container_name}":/out/simplex-chat \ + "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}" - # Download prebuilt CLI binary - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${cli_name}" + # Download prebuilt CLI binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${cli_name}" - # Desktop: deb - docker exec \ - -t "${container_name}" \ - sh -c './scripts/desktop/make-deb-linux.sh' + # CLI: deb + docker exec \ + -t "${container_name}" \ + sh -c "./scripts/desktop/build-cli-deb.sh ${version}" - # Copy deb - docker cp \ - "${container_name}":/project/apps/multiplatform/release/main/deb/simplex_x86_64.deb \ - "${init_dir}/${TAG}-${repo_name}/from-source/${deb_name}" + # Copy CLI: deb + docker cp \ + "${container_name}":/out/deb-build/simplex-chat.deb \ + "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}.deb" - # Download prebuilt deb package - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${deb_name}" + # Download prebuilt CLI: deb binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${cli_name}.deb" - # Desktop: appimage. Build only on 22.04 - case "$os" in - 22.04) - # Appimage - docker exec \ - -t "${container_name}" \ - sh -c './scripts/desktop/make-appimage-linux.sh && mv ./apps/multiplatform/release/main/*imple*.AppImage ./apps/multiplatform/release/main/simplex.appimage' + # Desktop: deb + docker exec \ + -t "${container_name}" \ + sh -c './scripts/desktop/make-deb-linux.sh' - # Copy appimage - docker cp \ - "${container_name}":/project/apps/multiplatform/release/main/simplex.appimage \ - "${init_dir}/${TAG}-${repo_name}/from-source/${appimage_name}" + # Copy deb + docker cp \ + "${container_name}":/project/apps/multiplatform/release/main/deb/simplex_x86_64.deb \ + "${init_dir}/${TAG}-${repo_name}/from-source/${deb_name}" - # Download prebuilt appimage binary - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${appimage_name}" - ;; - esac - - # Important! Remove dist-newstyle for the next interation - docker exec \ - -t "${container_name}" \ - sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' + # Download prebuilt deb package + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${deb_name}" - # Also restore git to previous state - git reset --hard && git clean -dfx + # Desktop: appimage. Build only on 22.04 + case "$os" in + 22.04) + # Appimage + docker exec \ + -t "${container_name}" \ + sh -c './scripts/desktop/make-appimage-linux.sh && mv ./apps/multiplatform/release/main/*imple*.AppImage ./apps/multiplatform/release/main/simplex.appimage' - # Stop containers, delete images - docker stop "${container_name}" - docker rm --force "${container_name}" - docker image rm "${image_name}" + # Copy appimage + docker cp \ + "${container_name}":/project/apps/multiplatform/release/main/simplex.appimage \ + "${init_dir}/${TAG}-${repo_name}/from-source/${appimage_name}" + + # Download prebuilt appimage binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${appimage_name}" + ;; + esac + + # Important! Remove dist-newstyle for the next interation + docker exec \ + -t "${container_name}" \ + sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' + + # Also restore git to previous state + git reset --hard && git clean -dfx + + # Stop containers, delete images + docker stop "${container_name}" + docker rm --force "${container_name}" + docker image rm "${image_name}" done # Cleanup @@ -145,27 +163,27 @@ bad=0 # Check hashes for all binaries for file in "${path_bin}"/from-source/*; do - # Extract binary name - app="$(basename ${file})" + # Extract binary name + app="$(basename ${file})" - # Compute hash for compiled binary - compiled=$(sha256sum "${path_bin}/from-source/${app}" | awk '{print $1}') - # Compute hash for prebuilt binary - prebuilt=$(sha256sum "${path_bin}/prebuilt/${app}" | awk '{print $1}') + # Compute hash for compiled binary + compiled=$(sha256sum "${path_bin}/from-source/${app}" | awk '{print $1}') + # Compute hash for prebuilt binary + prebuilt=$(sha256sum "${path_bin}/prebuilt/${app}" | awk '{print $1}') - # Compare - if [ "${compiled}" != "${prebuilt}" ]; then - # If hashes doesn't match, set bad... - bad=1 + # Compare + if [ "${compiled}" != "${prebuilt}" ]; then + # If hashes doesn't match, set bad... + bad=1 - # ... and print affected binary - printf "%s - sha256sum hash doesn't match\n" "${app}" - fi + # ... and print affected binary + printf "%s - sha256sum hash doesn't match\n" "${app}" + fi done # If everything is still okay, compute checksums file if [ "${bad}" = 0 ]; then - sha256sum "${path_bin}"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' -e "s|-$repo_name||g" > "${path_bin}/_sha256sums" + sha256sum "${path_bin}"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' -e "s|-$repo_name||g" > "${path_bin}/_sha256sums" - printf 'Checksums computed - %s\n' "${path_bin}/_sha256sums" + printf 'Checksums computed - %s\n' "${path_bin}/_sha256sums" fi From 2ed76fd3867c0962c7b2518b5a4191933eda15f2 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:16:37 +0000 Subject: [PATCH 12/48] ci: execute reproducible script from tag (#6485) * ci: execute reproducible script from tag * ci: target our repository tags --- .github/workflows/reproduce-schedule.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml index 7364976467..0febed4c87 100644 --- a/.github/workflows/reproduce-schedule.yml +++ b/.github/workflows/reproduce-schedule.yml @@ -9,9 +9,6 @@ jobs: reproduce: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Get latest release shell: bash run: | @@ -23,6 +20,12 @@ jobs: grep -i "tag_name" | \ awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ env.TAG }} + repository: simplex-chat/simplex-chat + # Otherwise we run out of disk space with Docker build - name: Free disk space shell: bash From f76e994034934f01b3ed6bafe7eb99021f077f88 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:48:32 +0000 Subject: [PATCH 13/48] core: forward based on relations vector (#6464) * core: forward based on relations vector wip * fix introductions * fix forwarding tests * fix forwarding inside support scope * fix deduplication test * fix more tests * plans, api * live migration wip * enable tests * member locks * api * plans * fix for postgres * fix for postgres * rename predicate * rename predicate * optimize * refactor * fix * check * move part of migration to sql * plans * core: preserve detailed information in relation vectors (#6484) * core: relations vector live migrations; stage 2 migration sql (#6472) * rework forwarding in support scope * move operations inside transactions * set_member_vector_new_relation function * read vector ad-hoc * partition in transaction * fix postgres * postgres schema * api * plans * remove comment * lock before migration computation * refactor * simplify set relations * retreive only support scope members * fix * refactor * fix comment * enable tests * 1 second * for update * locks * fix mask * plans * fix * postgres --------- Co-authored-by: Evgeny Poberezkin --- .../src/Directory/Store/Migrate.hs | 2 +- bots/api/TYPES.md | 14 +- cabal.project | 2 +- .../2025-11-24-member-relations-vector.md | 3 +- .../types/typescript/src/types.ts | 26 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 22 + src/Simplex/Chat/Library/Internal.hs | 82 +-- src/Simplex/Chat/Library/Subscriber.hs | 129 ++--- src/Simplex/Chat/Mobile.hs | 4 +- src/Simplex/Chat/Options/Postgres.hs | 7 +- src/Simplex/Chat/Options/SQLite.hs | 13 +- src/Simplex/Chat/Store/Delivery.hs | 2 +- src/Simplex/Chat/Store/Groups.hs | 466 +++++++++++------- src/Simplex/Chat/Store/Postgres/Migrations.hs | 2 + .../M20251117_member_relations_vector.hs | 97 ++++ ...0251128_member_relations_vector_stage_2.hs | 45 ++ .../Store/Postgres/Migrations/chat_schema.sql | 74 +++ src/Simplex/Chat/Store/SQLite/Migrations.hs | 2 + .../M20251117_member_relations_vector.hs | 80 +++ ...0251128_member_relations_vector_stage_2.hs | 42 ++ .../SQLite/Migrations/chat_query_plans.txt | 240 +++++---- src/Simplex/Chat/Store/Shared.hs | 4 +- src/Simplex/Chat/Types/MemberRelations.hs | 124 ++++- src/Simplex/Chat/View.hs | 1 - tests/ChatTests/Groups.hs | 54 +- tests/MemberRelationsTests.hs | 201 +++++--- tests/SchemaDump.hs | 11 +- 30 files changed, 1260 insertions(+), 497 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index ad37eba6d7..e22f4ed470 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -46,7 +46,7 @@ runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = migrateDBSchema chatStore - (toDBOpts dbOptions chatSuffix False) + (toDBOpts dbOptions chatSuffix False []) (Just "sx_directory_migrations") directorySchemaMigrations MigrationConfig {confirm, backupPath = Nothing} diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 9140f3b164..62c5dff3b5 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -3349,6 +3349,14 @@ GroupMemberNotFound: - type: "groupMemberNotFound" - groupMemberId: int64 +GroupMemberNotFoundByIndex: +- type: "groupMemberNotFoundByIndex" +- groupMemberIndex: int64 + +MemberRelationsVectorNotFound: +- type: "memberRelationsVectorNotFound" +- groupMemberId: int64 + GroupHostMemberNotFound: - type: "groupHostMemberNotFound" - groupId: int64 @@ -3361,6 +3369,9 @@ MemberContactGroupMemberNotFound: - type: "memberContactGroupMemberNotFound" - contactId: int64 +InvalidMemberRelationUpdate: +- type: "invalidMemberRelationUpdate" + GroupWithoutUser: - type: "groupWithoutUser" @@ -3447,9 +3458,6 @@ PendingConnectionNotFound: - type: "pendingConnectionNotFound" - connId: int64 -IntroNotFound: -- type: "introNotFound" - UniqueID: - type: "uniqueID" diff --git a/cabal.project b/cabal.project index df5ac1ba24..9f8d821a2e 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3016b929b48d3116f8ee60169c06383ca57f78e0 + tag: 92a9579e6958026e42a45e32c0e62fca243b4b0f source-repository-package type: git diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md index c4e8cf7224..087541fda3 100644 --- a/docs/rfcs/2025-11-24-member-relations-vector.md +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -72,10 +72,11 @@ note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avo note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo note over A, B: Only Bob forwards between Cath and Dan +C <<->> D: connect C ->> B: x.grp.mem.con (connected to Dan) D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected -note over A, B: Only Bob forwards between Cath and Dan +note over A, B: Bob stops forwarding between Cath and Dan ``` ### Avoid duplicate introductions diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index e67eae39a0..5e3309238b 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3735,9 +3735,12 @@ export type StoreError = | StoreError.GroupNotFoundByName | StoreError.GroupMemberNameNotFound | StoreError.GroupMemberNotFound + | StoreError.GroupMemberNotFoundByIndex + | StoreError.MemberRelationsVectorNotFound | StoreError.GroupHostMemberNotFound | StoreError.GroupMemberNotFoundByMemberId | StoreError.MemberContactGroupMemberNotFound + | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember | StoreError.GroupAlreadyJoined @@ -3761,7 +3764,6 @@ export type StoreError = | StoreError.ConnectionNotFoundById | StoreError.ConnectionNotFoundByMemberId | StoreError.PendingConnectionNotFound - | StoreError.IntroNotFound | StoreError.UniqueID | StoreError.LargeMsg | StoreError.InternalError @@ -3820,9 +3822,12 @@ export namespace StoreError { | "groupNotFoundByName" | "groupMemberNameNotFound" | "groupMemberNotFound" + | "groupMemberNotFoundByIndex" + | "memberRelationsVectorNotFound" | "groupHostMemberNotFound" | "groupMemberNotFoundByMemberId" | "memberContactGroupMemberNotFound" + | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" | "groupAlreadyJoined" @@ -3846,7 +3851,6 @@ export namespace StoreError { | "connectionNotFoundById" | "connectionNotFoundByMemberId" | "pendingConnectionNotFound" - | "introNotFound" | "uniqueID" | "largeMsg" | "internalError" @@ -3988,6 +3992,16 @@ export namespace StoreError { groupMemberId: number // int64 } + export interface GroupMemberNotFoundByIndex extends Interface { + type: "groupMemberNotFoundByIndex" + groupMemberIndex: number // int64 + } + + export interface MemberRelationsVectorNotFound extends Interface { + type: "memberRelationsVectorNotFound" + groupMemberId: number // int64 + } + export interface GroupHostMemberNotFound extends Interface { type: "groupHostMemberNotFound" groupId: number // int64 @@ -4003,6 +4017,10 @@ export namespace StoreError { contactId: number // int64 } + export interface InvalidMemberRelationUpdate extends Interface { + type: "invalidMemberRelationUpdate" + } + export interface GroupWithoutUser extends Interface { type: "groupWithoutUser" } @@ -4112,10 +4130,6 @@ export namespace StoreError { connId: number // int64 } - export interface IntroNotFound extends Interface { - type: "introNotFound" - } - export interface UniqueID extends Interface { type: "uniqueID" } diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7acb51e97c..406ef76cc5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3016b929b48d3116f8ee60169c06383ca57f78e0" = "1gvv96w1ag8xh19an5j50isjw0yq1cbsywii5s1adaplr6fw34mb"; + "https://github.com/simplex-chat/simplexmq.git"."92a9579e6958026e42a45e32c0e62fca243b4b0f" = "0hacyi4hsbca55l4n2n4pcsmsfvh2jaw681j6106y5mkillmb3fv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 24ccf24ded..bc49fc0b45 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -123,6 +123,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 else exposed-modules: Simplex.Chat.Archive @@ -269,6 +270,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9b711c2b50..93132fb413 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -118,8 +118,8 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} createChatDatabase :: ChatDbOpts -> MigrationConfig -> IO (Either MigrationError ChatDatabase) createChatDatabase chatDbOpts migrationConfig = runExceptT $ do - chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) migrationConfig - agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) migrationConfig + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False chatDBFunctions) migrationConfig + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False []) migrationConfig pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 630cad4e70..7f4ee238b1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -167,6 +167,9 @@ startChatController mainApp enableSndFiles = do runExceptT (syncConnections' users) >>= \case Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e Right _ -> pure () + runExceptT migrateMemberRelations >>= \case + Left e -> liftIO $ putStrLn $ "Error migrating member relations: " <> show e + Right _ -> pure () restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) @@ -178,6 +181,10 @@ startChatController mainApp enableSndFiles = do (userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds) withFastStore' setConnectionsSyncTs toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) + migrateMemberRelations = + when mainApp $ + whenM (withStore' hasMembersWithoutVector) $ + void $ forkIO runRelationsVectorMigration start s users = do a1 <- async agentSubscriber a2 <- @@ -4163,6 +4170,21 @@ agentSubscriber = do type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) +runRelationsVectorMigration :: CM () +runRelationsVectorMigration = do + liftIO $ threadDelay' 5000000 -- 5 seconds (initial delay) + migrateMembers + where + stepDelay = 1000000 -- 1 second + migrateMembers = flip catchAllErrors eToView $ do + lift waitChatStartedAndActivated + gmIds <- withStore' getGMsWithoutVectorIds + forM_ gmIds $ \gmId -> do + lift waitChatStartedAndActivated + withStore' (`migrateMemberRelationsVector'` gmId) `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay + unless (null gmIds) migrateMembers + cleanupManager :: CM () cleanupManager = do interval <- asks (cleanupManagerInterval . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 711a24c79e..523224c76d 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -73,6 +73,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, shuffle) @@ -1024,65 +1025,84 @@ introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = - if (maxVersion (memberChatVRange m) >= groupKnockingVersion) + if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing void $ sendDirectMemberMessage mConn msg groupId modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs + let rcpModMs = filter shouldIntroduce modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + where + shouldIntroduce :: GroupMember -> Bool + shouldIntroduce mem = + memberCurrent mem + && groupMemberId' mem /= groupMemberId' m + && maxVersion (memberChatVRange mem) >= groupKnockingVersion introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do - members <- withStore' $ \db -> getGroupMembersForIntroduction db vr user gInfo m - let recipients = filter memberCurrent members + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + vector_ <- withStore' (`getMemberRelationsVector_` m) + let recipients = filter (shouldIntroduce vector_) members introduceMember vr user gInfo m recipients Nothing + where + shouldIntroduce :: Maybe ByteString -> GroupMember -> Bool + shouldIntroduce vector_ m' = + memberCurrent m' + && groupMemberId' m' /= groupMemberId' m + && maybe True (\v -> getRelation (indexInGroup m') v == MRNew) vector_ introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToRemaining vr user gInfo m = do - (members, introducedGMIds) <- - withStore' $ \db -> (,) <$> getGroupMembersForIntroduction db vr user gInfo m <*> getIntroducedGroupMemberIds db m - let recipients = filter (introduceMemP introducedGMIds) members + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + vector_ <- withStore' (`getMemberRelationsVector_` m) + recipients <- filterRecipients vector_ members introduceMember vr user gInfo m recipients Nothing where - introduceMemP introducedGMIds mem = - memberCurrent mem - && groupMemberId' mem `notElem` introducedGMIds - && groupMemberId' mem /= groupMemberId' m + filterRecipients :: Maybe ByteString -> [GroupMember] -> CM [GroupMember] + filterRecipients vector_ members = do + newRelation <- case vector_ of + Nothing -> do + introducedGMIds <- S.fromList <$> withStore' (`getIntroducedGroupMemberIds` m) + pure $ \m' -> groupMemberId' m' `S.notMember` introducedGMIds + Just vec -> pure $ \m' -> getRelation (indexInGroup m') vec == MRNew + pure $ filter (\m' -> groupMemberId' m' /= groupMemberId' m && memberCurrent m' && newRelation m') members introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" -introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope +introduceMember vr user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope sendIntroductions introduceToMembers where - sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m - shuffledIntros <- liftIO $ shuffleIntros intros - if m `supportsVersion` batchSendVersion + sendIntroductions reMembers = do + updateToMemberVector reMembers + reMembers' <- withStore' $ \db -> createIntrosOrUpdateVectors db vr reMembers toMember + shuffledReMembers <- liftIO $ shuffleMembers reMembers' + if toMember `supportsVersion` batchSendVersion then do - let events = map (memberIntro . reMember) shuffledIntros + let events = map memberIntro shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId - else forM_ shuffledIntros $ \intro -> - processIntro intro `catchAllErrors` eToView + else forM_ shuffledReMembers $ \reMember -> + void $ sendDirectMemberMessage conn (memberIntro reMember) groupId + updateToMemberVector :: [GroupMember] -> CM () + updateToMemberVector reMembers = do + let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers + withStore' $ \db -> setMemberVectorNewRelations db toMember relations memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo gInfo reMember mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions - shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] - shuffleIntros intros = do - let (admins, others) = partition isAdmin intros + shuffleMembers :: [GroupMember] -> IO [GroupMember] + shuffleMembers reMembers = do + let (admins, others) = partition isAdmin reMembers (admPics, admNoPics) = partition hasPicture admins (othPics, othNoPics) = partition hasPicture others mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] where - isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin - hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image - processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId - withStore' $ \db -> updateIntroStatus db introId GMIntroSent + isAdmin GroupMember {memberRole} = memberRole >= GRAdmin + hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks @@ -2047,8 +2067,8 @@ readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {c | otherwise = Nothing readyMemberConn GroupMember {activeConn = Nothing} = Nothing -sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () -sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do +sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe GroupMemberIntro -> CM () -> CM () +sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent intro_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) messageMember msg `catchAllErrors` eToView where @@ -2056,7 +2076,7 @@ sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} c messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSASendBatched conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver - MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId (introId <$> intro_) MSAForwarded -> pure () -- TODO ensure order - pending messages interleave with user input messages diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 5073da9c77..27f3a4f61c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,7 +28,7 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl') +import Data.List (find) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Description (ValidFileDescription) @@ -2615,13 +2616,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do - introId <- withStore $ \db -> do - GroupMemberIntro {introId} <- getIntroduction db reMember m - liftIO $ updateIntroStatus db introId GMIntroInvReceived - pure introId - sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) (Just introId) $ - withStore' $ - \db -> updateIntroStatus db introId GMIntroInvForwarded + intro_ <- withStore' $ \db -> getIntroduction db reMember m + update intro_ GMIntroInvReceived + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) intro_ $ + update intro_ GMIntroInvForwarded + where + update (Just GroupMemberIntro {introId}) status = withStore' $ \db -> updateIntroStatus db introId status + update Nothing _ = pure () _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () @@ -2715,45 +2716,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () - xGrpMemCon gInfo sendingMember memId = do - refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId - case (memberCategory sendingMember, memberCategory refMember) of - (GCInviteeMember, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (GCInviteeMember, _) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (_, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn" - -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding, - -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon - -- to any forwarding member, not only host/inviting member; - -- database would track all members connections then - -- (currently it's done via group_member_intros for introduced connections only) - _ -> - messageWarning "x.grp.mem.con: neither member is invitee" - where - inviteeXGrpMemCon :: GroupMemberIntro -> CM () - inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroReConnected -> updateStatus introId GMIntroConnected - GMIntroToConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroToConnected - forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () - forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroToConnected -> updateStatus introId GMIntroConnected - GMIntroReConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroReConnected - updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status + xGrpMemCon gInfo sendingMem memId = do + refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId + withStore' (`migrateMemberRelationsVector` sendingMem) + withStore' (`migrateMemberRelationsVector` refMem) + -- Updating vectors in separate transactions to avoid deadlocks. + withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected + withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do @@ -3238,10 +3207,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let moderatorFilter mem = - memberCurrent mem - && maxVersion (memberChatVRange mem) >= groupKnockingVersion - && Just (groupMemberId' mem) /= singleSenderGMId_ + let moderatorFilter m = + memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion + && Just (groupMemberId' m) /= singleSenderGMId_ modMs' = filter moderatorFilter modMs mems <- if Just scopeGMId == singleSenderGMId_ @@ -3255,42 +3224,30 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" Just singleSenderGMId -> do sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId - mems <- buildMemberList sender - unless (null mems) $ deliver body mems + ms <- buildMemberList sender + unless (null ms) $ deliver body ms where - buildMemberList sender = case jobScope of - DJSGroup {jobSpec} - | jobSpecImpliedPending jobSpec -> - filter memberCurrentOrPending <$> getAllIntroducedAndInvited - | otherwise -> - filter memberCurrent <$> getAllIntroducedAndInvited - DJSMemberSupport scopeGMId -> do - -- moderators introduced to this invited member - introducedModMs <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedModerators db vr user sender - else pure [] - -- invited moderators to which this member was introduced - invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user sender - let modMs = introducedModMs <> invitedModMs - modMs' = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs - if scopeGMId == groupMemberId' sender - then pure modMs' - else - withStore' (\db -> getForwardScopeMember db vr user sender scopeGMId) >>= \case - Just scopeMem -> pure $ scopeMem : modMs' - _ -> pure modMs' - where - getAllIntroducedAndInvited = do - ChatConfig {highlyAvailable} <- asks config - -- members introduced to this invited member - introducedMembers <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db vr user sender highlyAvailable - else pure [] - -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user sender highlyAvailable - pure $ introducedMembers <> invitedMembers + buildMemberList sender = do + vec <- withStore $ \db -> migrateGetMemberRelationsVector db sender + -- this excludes the sender + let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec + case jobScope of + DJSGroup {jobSpec} -> do + ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m + | jobSpecImpliedPending jobSpec = memberCurrentOrPending m + | otherwise = memberCurrent m + DJSMemberSupport scopeGMId -> do + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m + currentModerator m@GroupMember {memberRole} = + memberRole >= GRModerator + && memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion where deliver :: ByteString -> [GroupMember] -> CM () deliver msgBody mems = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b22cfebcdd..3c8f170b0d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -295,8 +295,8 @@ chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigra chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") - chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig - agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index 13af13b20a..c74ae37750 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -58,8 +58,8 @@ migrationBackupPathP = pure Nothing dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey = +toDBOpts :: ChatDbOpts -> String -> Bool -> [()] -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey _dbFunctions = DBOpts { connstr = B.pack dbConnstr, schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix, @@ -73,6 +73,9 @@ chatSuffix = "_chat_schema" agentSuffix :: String agentSuffix = "_agent_schema" +chatDBFunctions :: [()] +chatDBFunctions = [] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts schemaPrefix connstr = do dbSchemaPrefix <- peekCString schemaPrefix diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 8591f0801c..7e25bb2217 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -11,7 +11,9 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.Common (SQLiteFuncDef (..), SQLiteFuncPtrs (..)) import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) @@ -70,10 +72,11 @@ migrationBackupPathP = dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts :: ChatDbOpts -> String -> Bool -> [SQLiteFuncDef] -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey dbFunctions = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, + dbFunctions, dbKey, keepKey, vacuum = vacuumOnMigration, @@ -86,6 +89,12 @@ chatSuffix = "_chat.db" agentSuffix :: String agentSuffix = "_agent.db" +chatDBFunctions :: [SQLiteFuncDef] +chatDBFunctions = + [ SQLiteFuncDef "migrate_relations_vector" 3 (SQLiteAggrPtrs sqliteMemberRelationsStepPtr sqliteMemberRelationsFinalPtr), + SQLiteFuncDef "set_member_vector_new_relation" 4 (SQLiteFuncPtr True sqliteSetMemberVectorNewRelationPtr) + ] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts fp key = do dbFilePrefix <- peekCString fp diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 30fffac0e7..c1da436f04 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -185,7 +185,7 @@ getNextDeliveryTasks db gInfo task = | otherwise = -- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering -- on sender_group_member_id here, so that the job can then retrieve less members as recipients, - -- optimizing for this single sender (see processDeliveryJob -> getForwardIntroducedMembers, etc.). + -- optimizing for this single sender (see processDeliveryJob -> fully connected group branch). -- We do this optimization in the job to decrease load on admins using mobile devices for clients. map fromOnly <$> DB.query diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c04a31c0a9..44dcd3c7f9 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -58,11 +58,13 @@ module Simplex.Chat.Store.Groups getMentionedGroupMember, getMentionedMemberByMemberId, getGroupMemberById, + getGroupMemberByIndex, getGroupMemberByMemberId, getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, - getGroupMembersForIntroduction, + getGroupMembersByIndexes, + getSupportScopeMembersByIndexes, getGroupModerators, getGroupRelays, getGroupMembersForExpiration, @@ -98,14 +100,17 @@ module Simplex.Chat.Store.Groups deleteGroupMemberConnection, updateGroupMemberRole, createIntroductions, + createIntrosOrUpdateVectors, + setMemberVectorNewRelations, + setMembersVectorsNewRelation, + setMemberVectorRelationConnected, + migrateGetMemberRelationsVector, + migrateMemberRelationsVector, + migrateMemberRelationsVector', + getMemberRelationsVector_, updateIntroStatus, getIntroduction, getIntroducedGroupMemberIds, - getForwardIntroducedMembers, - getForwardIntroducedModerators, - getForwardInvitedMembers, - getForwardInvitedModerators, - getForwardScopeMember, createIntroReMember, createIntroToMemberContact, getMatchingContacts, @@ -146,6 +151,8 @@ module Simplex.Chat.Store.Groups setGroupChatTTL, getGroupChatTTL, getUserGroupsToExpire, + hasMembersWithoutVector, + getGMsWithoutVectorIds, updateGroupAlias, ) where @@ -155,8 +162,11 @@ import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Bifunctor (second) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B import Data.Char (toLower) import Data.Either (rights) +import Data.Foldable (foldrM) import Data.Int (Int64) import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) @@ -170,6 +180,7 @@ import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), setNewRelations, setRelationConnected, toIntroDirInt, toRelationInt) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -180,11 +191,12 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import qualified Data.Set as S +import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (Only (..), Query, (:.) (..)) @@ -511,12 +523,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) :. (minV, maxV) ) @@ -530,12 +542,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -570,11 +582,11 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -766,11 +778,11 @@ createGroupViaLink' db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -994,6 +1006,22 @@ getGroupMemberById db vr user@User {userId} groupMemberId = (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) +getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember +getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") + (groupId, indexInGroup) + +getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember +getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) + getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ @@ -1017,20 +1045,38 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do +getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) -getGroupMembersForIntroduction :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> IO [GroupMember] -getGroupMembersForIntroduction db vr user@User {userId, userContactId} GroupInfo {groupId} _introduced@GroupMember {indexInGroup} = do - map (toContactMember vr user) - <$> DB.query +getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember] +getGroupMembersByIndexes db vr user gInfo indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query db - (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ?") - (userId, groupId, userContactId, indexInGroup) + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") + (groupId, In indexesInGroup) +#else + rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup +#endif + +getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] +getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) +#else + rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup +#endif getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1141,12 +1187,12 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) :. (minV, maxV) ) @@ -1169,12 +1215,12 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) :. (minV, maxV) ) @@ -1212,12 +1258,12 @@ createJoiningMember db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) :. (minV, maxV) ) @@ -1291,12 +1337,12 @@ createBusinessRequestGroup db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -1497,12 +1543,12 @@ createNewMember_ db [sql| INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, memRestriction, invitedById, memInvitedByGroupMemberId) :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -1561,27 +1607,196 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId) -createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] -createIntroductions db chatV members toMember = do - let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members - if null reMembers - then pure [] - else do +createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntroductions db chatV reMembers toMember + | null reMembers = pure [] + | otherwise = do currentTs <- getCurrentTime - mapM (insertIntro_ currentTs) reMembers + catMaybes <$> mapM (createIntro_ currentTs) reMembers where - insertIntro_ :: UTCTime -> GroupMember -> IO GroupMemberIntro - insertIntro_ ts reMember = do - DB.execute + createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMember) + createIntro_ ts reMember = + -- when members connect concurrently, host would try to create introductions between them in both directions; + -- this check avoids creating second (redundant) introduction + checkInverseIntro >>= \case + Just _ -> pure Nothing + Nothing -> do + DB.execute + db + [sql| + INSERT INTO group_member_intros + (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) + pure $ Just reMember + where + checkInverseIntro :: IO (Maybe Int64) + checkInverseIntro = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1" + (groupMemberId' toMember, groupMemberId' reMember) + +-- Create introductions for members without vectors and update vectors for members with vectors. +-- Partitioning and updates happen in same transaction to avoid race conditions. +createIntrosOrUpdateVectors :: DB.Connection -> VersionRangeChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntrosOrUpdateVectors db vr reMembers toMember + | null reMembers = pure [] + | otherwise = do + (memsWithVec, memsWithoutVec) <- partitionByVector reMembers + let GroupMember {indexInGroup} = toMember + setMembersVectorsNewRelation db memsWithVec indexInGroup IDSubjectIntroduced MRIntroduced + memsWithoutVec' <- createIntroductions db (maxVersion vr) memsWithoutVec toMember + pure $ memsWithoutVec' <> memsWithVec + where + partitionByVector :: [GroupMember] -> IO ([GroupMember], [GroupMember]) +#if defined(dbPostgres) + partitionByVector members = do + let memberIds = map groupMemberId' members + -- Lock rows first to ensure partitioning doesn't change in case of concurrent updates + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id IN ? FOR UPDATE" + (Only $ In memberIds) + memberIdsWithVec <- S.fromList . map fromOnly <$> + DB.query + db + "SELECT group_member_id FROM group_members WHERE group_member_id IN ? AND member_relations_vector IS NOT NULL" + (Only $ In memberIds) + pure $ partition (\m -> groupMemberId' m `S.member` memberIdsWithVec) members +#else + partitionByVector = foldrM checkMember ([], []) + where + checkMember m (withVec, withoutVec) = do + hasVec <- isJust <$> maybeFirstRow fromOnly + (DB.query db "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" (Only $ groupMemberId' m) :: IO [Only Int64]) + pure $ if hasVec then (m : withVec, withoutVec) else (withVec, m : withoutVec) +#endif + +setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () +setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do + v_ <- maybeFirstRow fromOnly $ + DB.query + db +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" +#endif + (Only groupMemberId) + let v' = setNewRelations relations $ fromMaybe B.empty v_ + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +setMembersVectorsNewRelation :: DB.Connection -> [GroupMember] -> Int64 -> IntroductionDirection -> MemberRelation -> IO () +setMembersVectorsNewRelation db members idx dir status = do + currentTs <- getCurrentTime +#if defined(dbPostgres) + let memberIds = map groupMemberId' members + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id IN ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, In memberIds) +#else + forM_ members $ \GroupMember {groupMemberId} -> + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, groupMemberId) +#endif + +setMemberVectorRelationConnected :: DB.Connection -> GroupMember -> GroupMember -> MemberRelation -> ExceptT StoreError IO () +setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {indexInGroup} newStatus = do + when (newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected) $ + throwError SEInvalidMemberRelationUpdate + v <- ExceptT $ + firstRow fromOnly (SEMemberRelationsVectorNotFound groupMemberId) $ + DB.query db - [sql| - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - |] - (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) - introId <- insertedRowId db - pure GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" +#endif + (Only groupMemberId) + let v' = setRelationConnected indexInGroup newStatus v + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +migrateGetMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString +migrateGetMemberRelationsVector db m@GroupMember {groupMemberId} = do + liftIO $ migrateMemberRelationsVector db m + ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) + +migrateMemberRelationsVector :: DB.Connection -> GroupMember -> IO () +migrateMemberRelationsVector db GroupMember {groupMemberId} = + migrateMemberRelationsVector' db groupMemberId + +migrateMemberRelationsVector' :: DB.Connection -> GroupMemberId -> IO () +migrateMemberRelationsVector' db groupMemberId = do + currentTs <- liftIO getCurrentTime + liftIO $ do +#if defined(dbPostgres) + -- Lock the row first to ensure computation runs only after lock is acquired + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NULL FOR UPDATE" + (Only groupMemberId) +#endif + DB.execute + db + [sql| + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + |] + (currentTs, groupMemberId) + +getMemberRelationsVector_ :: DB.Connection -> GroupMember -> IO (Maybe ByteString) +getMemberRelationsVector_ db GroupMember {groupMemberId} = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do @@ -1595,9 +1810,9 @@ updateIntroStatus db introId introStatus = do |] (introStatus, currentTs, introId) -getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction db reMember toMember = ExceptT $ - firstRow toIntro SEIntroNotFound $ +getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> IO (Maybe GroupMemberIntro) +getIntroduction db reMember toMember = + maybeFirstRow toIntro $ DB.query db [sql| @@ -1619,106 +1834,6 @@ getIntroducedGroupMemberIds db invitee = "SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?" (Only $ groupMemberId' invitee) -getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardIntroducedMembers db vr user invitee highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardIntroducedMembers) -getForwardIntroducedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardIntroducedModerators db vr user@User {userContactId} invitee = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query = - DB.query - db - [sql| - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardInvitedMembers db vr user forwardMember highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardInvitedMembers) -getForwardInvitedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardInvitedModerators db vr user@User {userContactId} forwardMember = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query = - DB.query - db - [sql| - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardScopeMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMemberId -> IO (Maybe GroupMember) -getForwardScopeMember db vr user GroupMember {groupMemberId = sendingGMId} scopeGMId = do - (introExists_ :: Maybe Int64) <- - liftIO $ maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - |] - (sendingGMId, scopeGMId, scopeGMId, sendingGMId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - pure introExists_ $>> (eitherToMaybe <$> runExceptT (getGroupMemberById db vr user scopeGMId)) - createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db @@ -2505,12 +2620,12 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -2608,6 +2723,25 @@ getUserGroupsToExpire db User {userId} globalTTL = where cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" +hasMembersWithoutVector :: DB.Connection -> IO Bool +hasMembersWithoutVector db = + fromOnly . head + <$> DB.query_ + db + "SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1)" + +getGMsWithoutVectorIds :: DB.Connection -> IO [GroupMemberId] +getGMsWithoutVectorIds db = + map fromOnly <$> + DB.query_ + db + [sql| + SELECT group_member_id + FROM group_members + WHERE member_relations_vector IS NULL + LIMIT 1000 + |] + updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 9361713ea2..9dd388be0a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -22,6 +22,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connection import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -44,6 +45,7 @@ schemaMigrations = ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs index 3f6deeb80f..33583cc076 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -6,10 +6,91 @@ import Data.Text (Text) import qualified Data.Text as T import Text.RawString.QQ (r) +-- This migration creates custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- Final byte combines direction and status: byte = (direction << 3) | status + m20251117_member_relations_vector :: Text m20251117_member_relations_vector = T.pack [r| +CREATE FUNCTION set_member_vector_new_relation(v BYTEA, idx BIGINT, direction INT, status INT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE FUNCTION migrate_relations_vector_step(state BYTEA, idx BIGINT, direction INT, intro_status TEXT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT) ( + SFUNC = migrate_relations_vector_step, + STYPE = BYTEA, + INITCOND = '' +); + ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0; ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0; @@ -46,12 +127,28 @@ SET member_index = COALESCE(( FROM group_members WHERE group_members.group_id = g.group_id ), 0); + +UPDATE group_members +SET member_relations_vector = ''::BYTEA +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN ('admin', 'owner') + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); |] down_m20251117_member_relations_vector :: Text down_m20251117_member_relations_vector = T.pack [r| +DROP AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT); +DROP FUNCTION migrate_relations_vector_step(BYTEA, BIGINT, INT, TEXT); +DROP FUNCTION set_member_vector_new_relation(BYTEA, BIGINT, INT, INT); + DROP INDEX idx_group_members_group_id_index_in_group; ALTER TABLE group_members DROP COLUMN index_in_group; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..7cfc273d62 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Text +m20251128_member_relations_vector_stage_2 = + T.pack + [r| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Text +down_m20251128_member_relations_vector_stage_2 = + T.pack + [r| + +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 712099d7c9..567aca0144 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -34,6 +34,41 @@ $$; +CREATE FUNCTION test_chat_schema.migrate_relations_vector_step(state bytea, idx bigint, direction integer, intro_status text) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -85,6 +120,45 @@ END; $$; + +CREATE FUNCTION test_chat_schema.set_member_vector_new_relation(v bytea, idx bigint, direction integer, status integer) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + +CREATE AGGREGATE test_chat_schema.migrate_relations_vector(bigint, integer, text) ( + SFUNC = test_chat_schema.migrate_relations_vector_step, + STYPE = bytea, + INITCOND = '' +); + + SET default_table_access_method = heap; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 13ada872b2..0358ae621d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -145,6 +145,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -290,6 +291,7 @@ schemaMigrations = ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector) + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs index bf5308bcfd..3e4b4157f0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -1,9 +1,77 @@ +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where +import qualified Data.ByteString as B import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) +import Database.SQLite3 (funcArgBlob, funcArgInt64, funcArgText, funcResultBlob) +import Database.SQLite3.Bindings +import Foreign.C.Types +import Foreign.Ptr +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), fromIntroDirInt, fromRelationInt, setNewRelation, setNewRelations) +import Simplex.Messaging.Agent.Store.SQLite.Util (SQLiteFunc, SQLiteFuncFinal, mkSQLiteAggFinal, mkSQLiteAggStep, mkSQLiteFunc) + +-- This module defines custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- It is passed via DBOpts and registered on DB open. +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- The final function builds the vector using setNewRelations. + +foreign export ccall "simplex_member_relations_step" sqliteMemberRelationsStep :: SQLiteFunc + +foreign import ccall "&simplex_member_relations_step" sqliteMemberRelationsStepPtr :: FunPtr SQLiteFunc + +foreign export ccall "simplex_member_relations_final" sqliteMemberRelationsFinal :: SQLiteFuncFinal + +foreign import ccall "&simplex_member_relations_final" sqliteMemberRelationsFinalPtr :: FunPtr SQLiteFuncFinal + +-- Step function for migrate_relations_vector aggregate. +-- Accumulates (idx, direction, relation) tuples. +sqliteMemberRelationsStep :: SQLiteFunc +sqliteMemberRelationsStep = mkSQLiteAggStep [] $ \_ args acc -> do + idx <- funcArgInt64 args 0 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 1 + introStatus <- funcArgText args 2 + let relation = introStatusToRelation direction introStatus + pure $ (idx, (direction, relation)) : acc + where + introStatusToRelation dir status = case status of + "re-con" -> if dir == IDSubjectIntroduced then MRSubjectConnected else MRReferencedConnected + "to-con" -> if dir == IDSubjectIntroduced then MRReferencedConnected else MRSubjectConnected + "con" -> MRConnected + _ -> MRIntroduced -- 'new', 'sent', 'rcv', 'fwd' + +-- Final function for migrate_relations_vector aggregate. +-- Builds the vector from accumulated tuples using setNewRelations. +sqliteMemberRelationsFinal :: SQLiteFuncFinal +sqliteMemberRelationsFinal = mkSQLiteAggFinal [] $ \cxt acc -> funcResultBlob cxt $ setNewRelations acc B.empty + +-- Non-aggregate function set_member_vector_new_relation(vector, idx, direction, status). +-- Sets a new relation in the vector and returns the updated vector. + +foreign export ccall "simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelation :: SQLiteFunc + +foreign import ccall "&simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelationPtr :: FunPtr SQLiteFunc + +sqliteSetMemberVectorNewRelation :: SQLiteFunc +sqliteSetMemberVectorNewRelation = mkSQLiteFunc $ \cxt args -> do + v <- funcArgBlob args 0 + idx <- funcArgInt64 args 1 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 2 + status <- fromRelationInt . fromIntegral <$> funcArgInt64 args 3 + funcResultBlob cxt $ setNewRelation idx direction status v m20251117_member_relations_vector :: Query m20251117_member_relations_vector = @@ -46,6 +114,18 @@ SET member_index = COALESCE(( FROM group_members WHERE group_members.group_id = g.group_id ), 0); + +UPDATE group_members +SET member_relations_vector = x'' +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN (CAST('admin' AS BLOB), CAST('owner' AS BLOB)) + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); |] down_m20251117_member_relations_vector :: Query diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..f510a50410 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Query +m20251128_member_relations_vector_stage_2 = + [sql| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Query +down_m20251128_member_relations_vector_stage_2 = + [sql| + +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 9156b56fcb..c46f6a5e7d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -24,10 +24,10 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -76,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -260,9 +260,9 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -293,10 +293,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -327,10 +327,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -492,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -525,10 +525,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -989,19 +989,12 @@ Query: Plan: -Query: - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - -Plan: - Query: INSERT INTO group_members - (group_id, index_in_group, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -1191,24 +1184,6 @@ SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_con SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - -Plan: -MULTI-INDEX OR -INDEX 1 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) -INDEX 2 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) - Query: SELECT 1 FROM users WHERE (user_id = ? AND local_display_name = ?) @@ -1455,28 +1430,6 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT member_status FROM group_members @@ -1645,10 +1598,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3509,6 +3462,14 @@ Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + +Plan: +SCAN group_members + Query: SELECT m.group_member_id FROM group_members m @@ -3559,14 +3520,6 @@ SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -Query: - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) - Query: SELECT reaction FROM chat_item_reactions @@ -3655,14 +3608,6 @@ Query: Plan: SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) -Query: - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) - Query: SELECT usage_conditions_id, conditions_commit, notified_at, created_at FROM usage_conditions @@ -3782,6 +3727,40 @@ SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_ LIST SUBQUERY 2 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +Query: + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 3 +CO-ROUTINE relations +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +UNION ALL +SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SCAN relations + Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -3790,6 +3769,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + +Plan: +SCAN group_members + Query: UPDATE groups SET member_index = member_index + 1 @@ -4739,12 +4726,12 @@ Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) Query: - UPDATE group_member_intros - SET intro_status = ?, updated_at = ? - WHERE group_member_intro_id = ? + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? Plan: -SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_members @@ -5133,6 +5120,44 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5209,25 +5234,6 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN -Query: - SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.group_member_id = m.group_member_id - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND index_in_group < ? -Plan: -SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group Word8 +toIntroDirInt = \case + IDSubjectIntroduced -> 0 + IDReferencedIntroduced -> 1 + +fromIntroDirInt :: Word8 -> IntroductionDirection +fromIntroDirInt = \case + 0 -> IDSubjectIntroduced + 1 -> IDReferencedIntroduced + _ -> IDSubjectIntroduced + data MemberRelation = MRNew | MRIntroduced - | MRConnected - deriving (Eq, Show) + | MRSubjectConnected -- Subject member notified about connection to referenced member + | MRReferencedConnected -- Referenced member notified about connection to subject member + | MRConnected -- Both members notified about connection + deriving (Eq, Ord, Show) toRelationInt :: MemberRelation -> Word8 toRelationInt = \case MRNew -> 0 MRIntroduced -> 1 - MRConnected -> 2 + MRSubjectConnected -> 2 + MRReferencedConnected -> 3 + MRConnected -> 4 fromRelationInt :: Word8 -> MemberRelation fromRelationInt = \case 0 -> MRNew 1 -> MRIntroduced - 2 -> MRConnected + 2 -> MRSubjectConnected + 3 -> MRReferencedConnected + 4 -> MRConnected _ -> MRNew +-- Bit layout: 4 reserved | 1 direction | 3 status + -- | Get the relation status of a member at a given index from the relations vector. -- Returns 'MRNew' if the vector is not long enough (lazy initialization). getRelation :: Int64 -> ByteString -> MemberRelation -getRelation i v - | i < 0 || fromIntegral i >= B.length v = MRNew - | otherwise = fromRelationInt $ (v `B.index` fromIntegral i) .&. relationMask +getRelation i v = snd $ getRelation' i v +-- | Get both direction and status of a member at a given index from the relations vector. +-- Returns (IDSubjectIntroduced, MRNew) if the vector is not long enough (lazy initialization). +getRelation' :: Int64 -> ByteString -> (IntroductionDirection, MemberRelation) +getRelation' i v + | i < 0 || fromIntegral i >= B.length v = (IDSubjectIntroduced, MRNew) + | otherwise = + let b = v `B.index` fromIntegral i + in (fromIntroDirInt $ (b .&. directionMask) `shiftR` 3, fromRelationInt $ b .&. statusMask) + +-- | Get the indexes of members with the given relation status from the relations vector. +getRelationsIndexes :: MemberRelation -> ByteString -> [Int64] +getRelationsIndexes r v = [i | i <- [0 .. fromIntegral (B.length v) - 1], getRelation i v == r] -- | Set the relation status of a member at a given index in the relations vector. --- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +-- Preserves the introduction direction. Expands the vector lazily if needed. setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString setRelation i r v | i >= 0 = setRelations [(i, r)] v | otherwise = v --- | Set multiple relations at once. --- Expands the vector lazily if needed (padding with zeros for 'MRNew' relation). +-- | Set multiple relation statuses at once. +-- Preserves the introduction direction. Expands the vector lazily if needed. setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString -setRelations [] v = v -setRelations relations v = +setRelations = setRelations_ $ \r b -> (b .&. complement statusMask) .|. toRelationInt r + +-- | Set relation to connected state based on passed status and current status. +-- newStatus should be MRSubjectConnected or MRReferencedConnected, otherwise returns vector unchanged. +-- Logic: +-- - if newStatus is complementary to oldStatus -> set MRConnected +-- - if newStatus > oldStatus (by enum order) -> set newStatus +-- - otherwise don't update +setRelationConnected :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelationConnected i newStatus v + | newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected = v + | otherwise = case status' of + Nothing -> v + Just s -> setRelation i s v + where + oldStatus = getRelation i v + status' = case (oldStatus, newStatus) of + -- complementary statuses -> MRConnected + (MRSubjectConnected, MRReferencedConnected) -> Just MRConnected + (MRReferencedConnected, MRSubjectConnected) -> Just MRConnected + -- newStatus > oldStatus -> set newStatus + _ | newStatus > oldStatus -> Just newStatus + | otherwise -> Nothing + +-- | Set a new relation with both direction and status at a given index. +-- Expands the vector lazily if needed. +setNewRelation :: Int64 -> IntroductionDirection -> MemberRelation -> ByteString -> ByteString +setNewRelation i dir r v + | i >= 0 = setNewRelations [(i, (dir, r))] v + | otherwise = v + +-- | Set multiple new relations with both direction and status at once. +-- Expands the vector lazily if needed. +setNewRelations :: [(Int64, (IntroductionDirection, MemberRelation))] -> ByteString -> ByteString +setNewRelations = setRelations_ $ \(dir, r) b -> (b .&. relationMask) .|. (toIntroDirInt dir `shiftL` 3) .|. toRelationInt r + where + relationMask = complement (statusMask .|. directionMask) + +setRelations_ :: (r -> Word8 -> Word8) -> [(Int64, r)] -> ByteString -> ByteString +setRelations_ _ [] v = v +setRelations_ updateByte relations v = let (fp, off, len) = toForeignPtr v newLen = max len $ fromIntegral $ maximum (map fst relations) + 1 in unsafeCreate newLen $ \ptr -> do withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len) - forM_ relations $ \(ix, r) -> when (ix >= 0) $ do + forM_ relations $ \(ix, r) -> when (ix >= 0) $ let i = fromIntegral ix - b <- peekByteOff ptr i - let b' = (b .&. complement relationMask) .|. toRelationInt r - pokeByteOff ptr i b' + in pokeByteOff ptr i . updateByte r =<< peekByteOff ptr i -relationMask :: Word8 -relationMask = 0x07 -- reserving 3 bits +statusMask :: Word8 +statusMask = 0x07 -- bits 0-2 + +directionMask :: Word8 +directionMask = 0x08 -- bit 3 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c16f76148f..f4a995adfe 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -24,7 +24,6 @@ import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, sortOn) import Data.List.NonEmpty (NonEmpty (..)) -import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4c5efa26b6..42562d8e16 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,6 +18,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) +import Data.Maybe (fromMaybe) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.List (intercalate, isInfixOf) @@ -30,10 +31,12 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport @@ -1769,6 +1772,7 @@ testGroupDelayedModeration ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -1815,6 +1819,7 @@ testGroupDelayedModerationFullDelete ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -5018,7 +5023,7 @@ testGroupMsgForwardReport = setupGroupForwarding :: TestCC -> TestCC -> TestCC -> IO () setupGroupForwarding host invitee1 invitee2 = do - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected invitee1Name <- userName invitee1 invitee2Name <- userName invitee2 @@ -5050,15 +5055,60 @@ setupGroupForwarding host invitee1 invitee2 = do |] (invitee1Name, invitee2Name) + setupGroupForwardingVectors host invitee1 invitee2 + +setupGroupForwardingVectors :: TestCC -> TestCC -> TestCC -> IO () +setupGroupForwardingVectors host invitee1 invitee2 = do + invitee1Name <- userName invitee1 + invitee2Name <- userName invitee2 + updateGroupForwardingVectors host invitee1Name invitee2Name MRIntroduced + +updateGroupForwardingVectors :: TestCC -> String -> String -> MemberRelation -> IO () +updateGroupForwardingVectors host invitee1Name invitee2Name relation = do + void $ withCCTransaction host $ \db -> do + [(invitee1Index, invitee1Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee1Name) + [(invitee2Index, invitee2Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee2Name) + + let invitee1Vec' = setRelation invitee2Index relation (fromMaybe B.empty invitee1Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee1Vec', invitee1Name) + + let invitee2Vec' = setRelation invitee1Index relation (fromMaybe B.empty invitee2Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee2Vec', invitee2Name) + testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + setupGroupForwardingVectors alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs index 968dcbec43..c1afbd7f6a 100644 --- a/tests/MemberRelationsTests.hs +++ b/tests/MemberRelationsTests.hs @@ -29,21 +29,23 @@ memberRelationsTests = do getRelation 0 vec `shouldBe` MRIntroduced it "reads multiple relations" $ do - let vec = B.pack [0, 0, 1, 2] + let vec = B.pack [0, 0, 1, 2, 3, 4] getRelation 0 vec `shouldBe` MRNew getRelation 1 vec `shouldBe` MRNew getRelation 2 vec `shouldBe` MRIntroduced - getRelation 3 vec `shouldBe` MRConnected + getRelation 3 vec `shouldBe` MRSubjectConnected + getRelation 4 vec `shouldBe` MRReferencedConnected + getRelation 5 vec `shouldBe` MRConnected it "reads multiple relations 2" $ do let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0] getRelation 0 vec `shouldBe` MRIntroduced getRelation 1 vec `shouldBe` MRIntroduced - getRelation 4 vec `shouldBe` MRConnected - getRelation 5 vec `shouldBe` MRConnected + getRelation 4 vec `shouldBe` MRSubjectConnected + getRelation 5 vec `shouldBe` MRSubjectConnected it "ignore reserved bits" $ do - let vec = B.pack [0xF9] -- 11111001 + let vec = B.pack [0xF1] -- reserved=1111, direction=0, status=001 getRelation 0 vec `shouldBe` MRIntroduced describe "setRelation" $ do @@ -56,9 +58,9 @@ memberRelationsTests = do vec `shouldBe` B.empty it "expands vector to required length" $ do - let vec = setRelation 5 MRConnected B.empty + let vec = setRelation 5 MRSubjectConnected B.empty B.length vec `shouldBe` 6 - getRelation 5 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected -- Other positions should be MRNew (0) getRelation 0 vec `shouldBe` MRNew getRelation 10 vec `shouldBe` MRNew @@ -69,21 +71,21 @@ memberRelationsTests = do let vec1 = setRelation 0 MRIntroduced B.empty let vec2 = setRelation 1 MRIntroduced vec1 -- Update: [01][10][00][00] - let vec3 = setRelation 1 MRConnected vec2 + let vec3 = setRelation 1 MRSubjectConnected vec2 getRelation 0 vec3 `shouldBe` MRIntroduced - getRelation 1 vec3 `shouldBe` MRConnected + getRelation 1 vec3 `shouldBe` MRSubjectConnected it "updates relation in specific byte of multi-byte vector" $ do let vec1 = setRelation 0 MRIntroduced B.empty - let vec2 = setRelation 10 MRConnected vec1 + let vec2 = setRelation 10 MRSubjectConnected vec1 B.length vec2 `shouldBe` 11 getRelation 0 vec2 `shouldBe` MRIntroduced - getRelation 10 vec2 `shouldBe` MRConnected + getRelation 10 vec2 `shouldBe` MRSubjectConnected forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew it "handles setting relation at last position in byte" $ do - let vec = setRelation 3 MRConnected B.empty - getRelation 3 vec `shouldBe` MRConnected + let vec = setRelation 3 MRSubjectConnected B.empty + getRelation 3 vec `shouldBe` MRSubjectConnected it "preserves vector when setting same value" $ do let vec1 = setRelation 0 MRIntroduced B.empty @@ -91,12 +93,24 @@ memberRelationsTests = do vec2 `shouldBe` vec1 getRelation 0 vec2 `shouldBe` MRIntroduced - it "preserves reserved bits" $ do - let v = B.pack [0xF8] -- 11111000 + it "preserves reserved bits and direction" $ do + let v = B.pack [0xF8] -- reserved=1111, direction=1, status=000 getRelation 0 v `shouldBe` MRNew - let v' = setRelation 0 MRIntroduced v - getRelation 0 v' `shouldBe` MRIntroduced - B.unpack v' `shouldBe` [0xF9] -- 11111001 + let v' = setRelation 0 MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 + + describe "setNewRelation" $ do + it "sets new relation with direction" $ do + let vec = setNewRelation 0 IDReferencedIntroduced MRSubjectConnected B.empty + getRelation' 0 vec `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) + B.unpack vec `shouldBe` [0x0A] -- direction=1, status=010 + + it "preserves reserved bits" $ do + let v = B.pack [0xF0] -- reserved=1111, direction=0, status=000 + let v' = setNewRelation 0 IDReferencedIntroduced MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 describe "setRelations" $ do it "returns same vector for empty list" $ do @@ -104,106 +118,183 @@ memberRelationsTests = do setRelations [] vec `shouldBe` vec it "sets multiple relations in empty vector" $ do - let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRIntroduced)] + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRReferencedConnected), (3, MRConnected)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced - getRelation 1 vec `shouldBe` MRConnected - getRelation 2 vec `shouldBe` MRIntroduced - getRelation 3 vec `shouldBe` MRNew -- Unset position + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRReferencedConnected + getRelation 3 vec `shouldBe` MRConnected + getRelation 4 vec `shouldBe` MRNew -- Unset position it "sets multiple relations 1" $ do - let updates = [(0, MRIntroduced), (1, MRConnected), (2, MRConnected), (3, MRIntroduced)] + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRSubjectConnected), (3, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 4 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 1 vec `shouldBe` MRConnected - getRelation 2 vec `shouldBe` MRConnected + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRSubjectConnected getRelation 3 vec `shouldBe` MRIntroduced it "sets multiple relations 2" $ do - let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 11 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 5 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected getRelation 10 vec `shouldBe` MRIntroduced getRelation 7 vec `shouldBe` MRNew -- Unset position between it "handles sparse updates (few indices in large range)" $ do -- Sparse: 3 updates in large group - let updates = [(0, MRIntroduced), (100, MRConnected), (5000, MRIntroduced)] + let updates = [(0, MRIntroduced), (100, MRSubjectConnected), (5000, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced - getRelation 100 vec `shouldBe` MRConnected + getRelation 100 vec `shouldBe` MRSubjectConnected getRelation 5000 vec `shouldBe` MRIntroduced getRelation 50 vec `shouldBe` MRNew -- Untouched position it "handles dense updates (many consecutive indices)" $ do -- Dense: many consecutive updates - let updates = [(i, if even i then MRIntroduced else MRConnected) | i <- [0 .. 99]] + let updates = [(i, if even i then MRIntroduced else MRSubjectConnected) | i <- [0 .. 99]] let vec = setRelations updates B.empty - all (\i -> getRelation i vec == (if even i then MRIntroduced else MRConnected)) [0 .. 99] `shouldBe` True + all (\i -> getRelation i vec == (if even i then MRIntroduced else MRSubjectConnected)) [0 .. 99] `shouldBe` True it "handles unsorted input correctly" $ do - let updates = [(10, MRConnected), (2, MRIntroduced), (5, MRConnected), (0, MRIntroduced)] + let updates = [(10, MRSubjectConnected), (2, MRIntroduced), (5, MRSubjectConnected), (0, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced getRelation 2 vec `shouldBe` MRIntroduced - getRelation 5 vec `shouldBe` MRConnected - getRelation 10 vec `shouldBe` MRConnected + getRelation 5 vec `shouldBe` MRSubjectConnected + getRelation 10 vec `shouldBe` MRSubjectConnected it "handles duplicate indices (last one wins)" $ do - let updates = [(0, MRIntroduced), (0, MRConnected), (0, MRIntroduced)] + let updates = [(0, MRIntroduced), (0, MRSubjectConnected), (0, MRIntroduced)] let vec = setRelations updates B.empty getRelation 0 vec `shouldBe` MRIntroduced it "preserves existing relations not in update list" $ do - let vec1 = setRelation 0 MRConnected B.empty + let vec1 = setRelation 0 MRSubjectConnected B.empty let vec2 = setRelation 5 MRIntroduced vec1 - let updates = [(10, MRConnected)] + let updates = [(10, MRSubjectConnected)] let vec3 = setRelations updates vec2 - getRelation 0 vec3 `shouldBe` MRConnected + getRelation 0 vec3 `shouldBe` MRSubjectConnected getRelation 5 vec3 `shouldBe` MRIntroduced - getRelation 10 vec3 `shouldBe` MRConnected + getRelation 10 vec3 `shouldBe` MRSubjectConnected + + describe "setNewRelations" $ do + it "sets multiple new relations with direction" $ do + let updates = [(0, (IDSubjectIntroduced, MRIntroduced)), (1, (IDReferencedIntroduced, MRSubjectConnected))] + let vec = setNewRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRSubjectConnected + B.unpack vec `shouldBe` [0x01, 0x0A] -- [dir=0,status=001], [dir=1,status=010] describe "edge cases and invariants" $ do it "round-trip: set then get returns same value" $ do - let vec1 = setRelation 42 MRConnected B.empty - getRelation 42 vec1 `shouldBe` MRConnected + let vec1 = setRelation 42 MRSubjectConnected B.empty + getRelation 42 vec1 `shouldBe` MRSubjectConnected it "multiple round-trips preserve values" $ do let vec1 = setRelation 0 MRIntroduced B.empty - let vec2 = setRelation 1 MRConnected vec1 - let vec3 = setRelation 2 MRIntroduced vec2 - getRelation 0 vec3 `shouldBe` MRIntroduced - getRelation 1 vec3 `shouldBe` MRConnected - getRelation 2 vec3 `shouldBe` MRIntroduced + let vec2 = setRelation 1 MRSubjectConnected vec1 + let vec3 = setRelation 2 MRReferencedConnected vec2 + let vec4 = setRelation 3 MRConnected vec3 + getRelation 0 vec4 `shouldBe` MRIntroduced + getRelation 1 vec4 `shouldBe` MRSubjectConnected + getRelation 2 vec4 `shouldBe` MRReferencedConnected + getRelation 3 vec4 `shouldBe` MRConnected it "setRelations equivalent to multiple setRelation calls" $ do - let updates = [(0, MRIntroduced), (5, MRConnected), (10, MRIntroduced)] + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRConnected)] let vecBatch = setRelations updates B.empty - let vecSeq = setRelation 10 MRIntroduced $ setRelation 5 MRConnected $ setRelation 0 MRIntroduced B.empty + let vecSeq = setRelation 10 MRConnected $ setRelation 5 MRSubjectConnected $ setRelation 0 MRIntroduced B.empty vecBatch `shouldBe` vecSeq getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq it "handles large group size (10000 members)" $ do - let updates = [(0, MRIntroduced), (5000, MRConnected), (9999, MRIntroduced)] + let updates = [(0, MRIntroduced), (5000, MRSubjectConnected), (9999, MRIntroduced)] let vec = setRelations updates B.empty B.length vec `shouldBe` 10000 getRelation 0 vec `shouldBe` MRIntroduced - getRelation 5000 vec `shouldBe` MRConnected + getRelation 5000 vec `shouldBe` MRSubjectConnected getRelation 9999 vec `shouldBe` MRIntroduced it "all status values can be stored and retrieved" $ do let vec1 = setRelation 0 MRNew B.empty let vec2 = setRelation 1 MRIntroduced vec1 - let vec3 = setRelation 2 MRConnected vec2 - getRelation 0 vec3 `shouldBe` MRNew - getRelation 1 vec3 `shouldBe` MRIntroduced - getRelation 2 vec3 `shouldBe` MRConnected + let vec3 = setRelation 2 MRSubjectConnected vec2 + let vec4 = setRelation 3 MRReferencedConnected vec3 + let vec5 = setRelation 4 MRConnected vec4 + getRelation 0 vec5 `shouldBe` MRNew + getRelation 1 vec5 `shouldBe` MRIntroduced + getRelation 2 vec5 `shouldBe` MRSubjectConnected + getRelation 3 vec5 `shouldBe` MRReferencedConnected + getRelation 4 vec5 `shouldBe` MRConnected it "vector length is minimal (lazy expansion)" $ do - let vec = setRelation 3 MRConnected B.empty + let vec = setRelation 3 MRSubjectConnected B.empty B.length vec `shouldBe` 4 + + it "setRelation preserves existing direction" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelation 0 MRConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + B.unpack vec2 `shouldBe` [0x0C] -- direction=1 preserved, status=100 + + describe "setRelationConnected" $ do + it "MRSubjectConnected on MRIntroduced -> MRSubjectConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRSubjectConnected + + it "MRReferencedConnected on MRIntroduced -> MRReferencedConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRReferencedConnected + + it "MRSubjectConnected on MRReferencedConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRReferencedConnected on MRSubjectConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRSubjectConnected on MRSubjectConnected -> no change" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRReferencedConnected -> no change" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "MRSubjectConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRConnected) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRNew) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRNew vec1 + vec2 `shouldBe` vec1 + + it "setRelationConnected preserves direction when updating" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation' 0 vec2 `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index b1b7dbc264..8a716ddf11 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -18,6 +18,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T import Database.SQLite.Simple (Query (..)) +import Simplex.Chat.Options.SQLite (chatDBFunctions) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) @@ -63,7 +64,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -71,14 +72,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) + Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -152,7 +153,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appChatQueryPlans chatQueryStats - (createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing)) (\db -> do DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" @@ -161,7 +162,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appAgentQueryPlans agentQueryStats - (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createAgentStore (DBOpts testAgentDB [] "" False True TQOff) (MigrationConfig MCError Nothing)) (const $ pure ()) chatSavedPlans' == chatSavedPlans `shouldBe` True agentSavedPlans' == agentSavedPlans `shouldBe` True From c5a69a49500d60bc66a0de30dad65c36857418fa Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:30:53 +0000 Subject: [PATCH 14/48] scripts/build-android: make build reproducible (#6493) * scripts/build-android: attempt to make it reproducible * scripts/build-android: set epoch to 1764547200 --- scripts/android/build-android.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 97190ec103..90d6092385 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -138,10 +138,15 @@ build() { mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" + # Determenistic build + find "$android_tmp_folder" -type f -exec chmod 644 {} + + find "$android_tmp_folder" -type d -exec chmod 755 {} + + find "$android_tmp_folder" -exec touch -h -d '@1764547200' {} + + ( cd "$android_tmp_folder" && \ - zip -rq5 "$tmp/$android_apk_output_final" . && \ - zip -rq0 "$tmp/$android_apk_output_final" resources.arsc res + find . -type f -print0 | sort -z | xargs -0 zip -X -rq5 "$tmp/$android_apk_output_final" && \ + find res resources.arsc -type f -print0 | sort -z | xargs -0 zip -X -rq0 "$tmp/$android_apk_output_final" ) zipalign -p -f 4 "$tmp/$android_apk_output_final" "$PWD/$android_apk_output_final" From 267e680698abb01469a4e581ed38ccfd38891fe7 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:50:26 +0000 Subject: [PATCH 15/48] nix: make android library reproducible (#6486) * nix: make android aarch64 library reproducible * src/Operators: embed PRIVACY.md without full paths * nix: make android library reproducible * Update src/Simplex/Chat/Operators.hs Co-authored-by: Evgeny * nix: remove unnecessary flags and apply changes to armv7a * nix: strip libraries from debug symbols * nix: strip before timestamp normalization * nix: set epoch to 1764547200 --------- Co-authored-by: Evgeny --- flake.nix | 50 ++++++++++++++++++++++++++++------- src/Simplex/Chat/Operators.hs | 2 +- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/flake.nix b/flake.nix index a68b42e2f1..c130e1a1fd 100644 --- a/flake.nix +++ b/flake.nix @@ -41,14 +41,24 @@ }; sha256map = import ./scripts/nix/sha256map.nix; modules = [ - ({ pkgs, lib, ...}: lib.mkIf (!pkgs.stdenv.hostPlatform.isWindows) { - # This patch adds `dl` as an extra-library to direct-sqlciper, which is needed - # on pretty much all unix platforms, but then blows up on windows m( - packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; - }) - ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { - packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; - })] ++ extra-modules; + ({ pkgs, lib, config, ... }: + { + # Override ghcOptions for ALL packages + ghcOptions = lib.mkDefault [ + "-j1" + ]; + } + ) + + ({ pkgs, lib, ...}: lib.mkIf (!pkgs.stdenv.hostPlatform.isWindows) { + # This patch adds `dl` as an extra-library to direct-sqlciper, which is needed + # on pretty much all unix platforms, but then blows up on windows m( + packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; + }) + + ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { + packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; + })] ++ extra-modules; }; in # by defualt we don't need to pass extra-modules. let drv = pkgs': drv' { extra-modules = []; inherit pkgs'; }; in @@ -368,6 +378,7 @@ "-threaded" # "-debug" "-optl-lffi" + "-j1" ] # This is fairly idiotic. LLD will strip out foreign exported # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` @@ -433,7 +444,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ @@ -477,6 +497,7 @@ # "-debug" "-optl-lffi" "-optl-Wl,-z,max-page-size=16384" + "-j1" ] # This is fairly idiotic. LLD will strip out foreign exported # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` @@ -542,7 +563,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-aarch64-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${androidPkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-aarch64-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 24baa37e4e..08c7b84087 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -63,7 +63,7 @@ previousConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" usageConditionsText :: Text usageConditionsText = - $( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md") + $( let s = $(embedFile "PRIVACY.md") in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] ) From 8089a8c7ef9843cd303820b0ae1d5b5d70543608 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Wed, 10 Dec 2025 09:51:03 +0000 Subject: [PATCH 16/48] desktop: fix linking mobile and connecting after onboarding. Also fixes other cases when core controller changes in android/desktop. (#6489) --- .../chat/simplex/common/model/CryptoFile.kt | 4 +- .../chat/simplex/common/model/SimpleXAPI.kt | 37 ++++++++++++++----- .../chat/simplex/common/platform/Core.kt | 5 ++- .../chat/simplex/common/ui/theme/Theme.kt | 3 +- .../common/views/chatlist/UserPicker.kt | 2 +- .../common/views/database/DatabaseView.kt | 3 +- .../common/views/localauth/LocalAuthView.kt | 4 +- .../common/views/migration/MigrateToDevice.kt | 2 +- 8 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 28b46f592d..6ef56a9124 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -21,7 +21,7 @@ sealed class WriteFileResult { * */ fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { - val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) buffer.put(data) buffer.rewind() @@ -44,7 +44,7 @@ fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { } fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { - val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val str = chatEncryptFile(ctrl, fromPath, toPath) val d = json.decodeFromString(WriteFileResult.serializer(), str) return when (d) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 09aae9a7d3..6a80e50285 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -482,17 +482,26 @@ class AppPreferences { private const val MESSAGE_TIMEOUT: Int = 300_000_000 object ChatController { - var ctrl: ChatCtrl? = -1 + private var chatCtrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } val messagesChannel: Channel = Channel() val chatModel = ChatModel - private var receiverStarted = false + private var receiverJob: Job? = null var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() private set - fun hasChatCtrl() = ctrl != -1L && ctrl != null + fun hasChatCtrl() = chatCtrl != -1L && chatCtrl != null + + fun getChatCtrl(): ChatCtrl? = chatCtrl + + fun setChatCtrl(ctrl: ChatCtrl?) { + val wasRunning = receiverJob != null + stopReceiver() + chatCtrl = ctrl + if (wasRunning && ctrl != null) startReceiver() + } suspend fun getAgentSubsTotal(rh: Long?): Pair? { val userId = currentUserId("getAgentSubsTotal") @@ -639,17 +648,16 @@ object ChatController { private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") - if (receiverStarted) return - receiverStarted = true - CoroutineScope(Dispatchers.IO).launch { + if (receiverJob != null || chatCtrl == null) return + receiverJob = CoroutineScope(Dispatchers.IO).launch { var releaseLock: (() -> Unit) = {} - while (true) { + while (isActive) { /** Global [ctrl] can be null. It's needed for having the same [ChatModel] that already made in [ChatController] without the need * to change it everywhere in code after changing a database. * Since it can be changed in background thread, making this check to prevent NullPointerException */ - val ctrl = ctrl + val ctrl = chatCtrl if (ctrl == null) { - receiverStarted = false + stopReceiver() break } try { @@ -689,6 +697,15 @@ object ChatController { } } + private fun stopReceiver() { + Log.d(TAG, "ChatController stopReceiver") + val job = receiverJob + if (job != null) { + receiverJob = null + job.cancel() + } + } + private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0): API? { val r = sendCmd(rhId, cmd, retryNum = retryNum) val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null @@ -773,7 +790,7 @@ object ChatController { } suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { - val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") + val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 959e4749dc..d0ce703033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -56,6 +56,7 @@ fun initChatControllerOnStart() { } suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred = { CompletableDeferred(true) }) { + Log.d(TAG, "initChatController") try { if (chatModel.ctrlInitInProgress.value) return chatModel.ctrlInitInProgress.value = true @@ -92,7 +93,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat val ctrl = if (res is DBMigrationResult.OK) { migrated[1] as Long } else null - chatController.ctrl = ctrl + chatController.setChatCtrl(ctrl) chatModel.chatDbEncrypted.value = dbKey != "" chatModel.chatDbStatus.value = res if (res != DBMigrationResult.OK) { @@ -206,7 +207,7 @@ fun chatInitControllerRemovingDatabases() { }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } val ctrl = migrated[1] as Long - chatController.ctrl = ctrl + chatController.setChatCtrl(ctrl) // We need only controller, not databases File(dbPath + "_chat.db").delete() File(dbPath + "_agent.db").delete() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 01e19ea478..df9af7fbf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -804,7 +804,8 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { LocalAppColors provides rememberedAppColors, LocalAppWallpaper provides rememberedWallpaper, LocalDensity provides density, - content = content) + content = content + ) } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 13351a2111..ed74e083e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -131,7 +131,7 @@ fun UserPicker( } LaunchedEffect(Unit) { // Controller.ctrl can be null when self-destructing activates - if (controller.ctrl != null && controller.ctrl != -1L) { + if (controller.hasChatCtrl()) { withBGApi { controller.reloadRemoteHosts() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 4a911fa6f0..d55d89f26b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -366,6 +366,7 @@ fun startChat( chatDbChanged: MutableState, progressIndicator: MutableState? = null ) { + Log.d(TAG, "startChat") withLongRunningApi { try { progressIndicator?.value = true @@ -532,7 +533,7 @@ fun deleteChatDatabaseFilesAndState() { appPrefs.newDatabaseInitialized.set(false) chatModel.desktopOnboardingRandomPassword.value = false controller.appPrefs.storeDBPassphrase.set(true) - controller.ctrl = null + controller.setChatCtrl(null) // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 8021a605db..f86edb0388 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -33,7 +33,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) { } } else { val r: LAResult = if (passcode.value == authRequest.password) { - if (authRequest.selfDestruct && sdPassword != null && controller.ctrl == -1L) { + if (authRequest.selfDestruct && sdPassword != null && controller.getChatCtrl() == -1L) { initChatControllerOnStart() } LAResult.Success @@ -58,7 +58,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (m.chatRunning.value == true) { stopChatAsync(m) } - val ctrl = m.controller.ctrl + val ctrl = m.controller.getChatCtrl() if (ctrl != null && ctrl != -1L) { /** * The following sequence can bring a user here: diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index d74846f8a3..6199621c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -633,7 +633,7 @@ private fun MutableState.startDownloading( private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { withLongRunningApi { try { - if (ChatController.ctrl == null || ChatController.ctrl == -1L) { + if (!ChatController.hasChatCtrl()) { chatInitControllerRemovingDatabases() } controller.apiDeleteStorage() From 5bb52c1e6ebaf8fa95a688ea44f1fcee64e41d61 Mon Sep 17 00:00:00 2001 From: Vitaly Kanevsky <127240146+rkswqt@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:40:18 +0200 Subject: [PATCH 17/48] website: fix typo (#5933) --- docs/FAQ.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index a24f9f9c56..401f025d9c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -79,7 +79,7 @@ When "Incognito Mode” is turned on, your currently chosen profile name and ima ### How do invitations work? -It is quite a complex process, but fortunately all of this happens in the background, so it's simply to use. +It is quite a complex process, but fortunately all of this happens in the background, so it's simple to use. Whenever somebody connects to you via your address, they basically ask your client whether they want to establish connection. After that, you can either agree or disagree. If interested, please read more: [Addresses and invitations](./guide/making-connections.md). @@ -126,7 +126,7 @@ You can also revoke the files you send. If the recipients did not yet receive th This is different from most other messengers that allow deleting messages from the recipients' devices without any agreement with the recipients. We believe that allowing deleting information from your device to your contacts is a very wrong design decision for several reasons: -1) it violates your data sovereignty as the device owner - once your are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. +1) it violates your data sovereignty as the device owner - once you are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. 2) it may be a business communication, and either your organization policy or a compliance requirement is that every message you receive must be preserved for some time. 3) the message can contain a legally binding promise, effectively a contract between you and your contact, in which case you both need to keep it. 4) the messages may contain threat or abuse and you may want to keep them as a proof. From f29a50f6fe310eb578b7236daca3c02dc5d28d0b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 10 Dec 2025 17:45:55 +0000 Subject: [PATCH 18/48] core: 6.4.8.0 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 75ddceac5e..c66c7b51ee 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.4.7.1 +version: 6.4.8.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 8cd33f77fc28c93d55813b3197eabff2210e9961 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 10 Dec 2025 18:32:05 +0000 Subject: [PATCH 19/48] ios: update core library --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index ce4785c7ab..99b9d2fa56 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.7.1-A5UtRanLEGiKe0gro2y2nW.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */, ); path = Libraries; sourceTree = ""; From 90851143c40440844b13fa32d641a233eae11b8f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:43:21 +0000 Subject: [PATCH 20/48] ios: 6.4.8 (build 314) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 40 +++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 99b9d2fa56..654eaf783c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2028,7 +2028,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2078,7 +2078,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2095,11 +2095,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2115,11 +2115,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2155,7 +2155,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2192,7 +2192,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2240,7 +2240,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2291,7 +2291,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2331,7 +2331,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 311; + CURRENT_PROJECT_VERSION = 314; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2365,7 +2365,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.7; + MARKETING_VERSION = 6.4.8; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 453d4fe227737a0b76faa21433dd1a168c3d97ea Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 10 Dec 2025 20:11:47 +0000 Subject: [PATCH 21/48] v6.4.8: android 328, desktop 125 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 36bcd4f02a..49060b7ad5 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4.7 -android.version_code=325 +android.version_name=6.4.8 +android.version_code=328 android.bundle=false -desktop.version_name=6.4.7 -desktop.version_code=123 +desktop.version_name=6.4.8 +desktop.version_code=125 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From 86a053729b9396f0f85fd822993042a3bfa73631 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:04:24 +0000 Subject: [PATCH 22/48] flatpak: update metainfo (#6501) --- .../flatpak/chat.simplex.simplex.metainfo.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 950cb42ea2..4f5f5d395c 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.8:

+
    +
  • fix stuck message reception and other events after passphrase change (e.g., during desktop app initial start)
  • +
+

New in v6.4-6.4.7:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html From caef0120031677144dcdd70aff1530d7022ccf05 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:29:31 +0000 Subject: [PATCH 23/48] core: fix support chat deletion (#6271) --- src/Simplex/Chat/Library/Commands.hs | 8 +- src/Simplex/Chat/Library/Internal.hs | 32 +++-- src/Simplex/Chat/Library/Subscriber.hs | 30 ++--- src/Simplex/Chat/Store/Groups.hs | 11 +- src/Simplex/Chat/Store/Messages.hs | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 2 +- tests/ChatClient.hs | 3 + tests/ChatTests/Groups.hs | 125 +++++++++++++++++- 8 files changed, 177 insertions(+), 36 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7f4ee238b1..f7891cae3a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2347,7 +2347,13 @@ processChatCommand vr nm = \case (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" when (memberPending m) $ throwCmdError "member is pending" - (gInfo', m') <- withFastStore' $ \db -> deleteGroupMemberSupportChat db user gInfo m + (gInfo', m') <- withFastStore' $ \db -> do + gInfo' <- + if gmRequiresAttention m + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo + m' <- deleteGroupMemberSupportChat db m + pure (gInfo', m') pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 523224c76d..9ee00b0dc5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1677,19 +1677,35 @@ deleteMemberConnection' GroupMember {activeConn} waitDelivery = do withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo -deleteOrUpdateMemberRecord user gInfo member = - withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo member +deleteOrUpdateMemberRecord user gInfo m = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo m deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo -deleteOrUpdateMemberRecordIO db user@User {userId} gInfo member = do +deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + checkGroupMemberHasItems db user m' >>= \case + Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved + Nothing -> deleteGroupMember db user m' + pure gInfo' + +updateMemberRecordDeleted :: User -> GroupInfo -> GroupMember -> GroupMemberStatus -> CM GroupInfo +updateMemberRecordDeleted user@User {userId} gInfo m newStatus = + withStore' $ \db -> do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + updateGroupMemberStatus db userId m' newStatus + pure gInfo' + +deleteSupportChatIfExists :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) +deleteSupportChatIfExists db user gInfo m = do gInfo' <- - if gmRequiresAttention member + if gmRequiresAttention m then decreaseGroupMembersRequireAttention db user gInfo else pure gInfo - checkGroupMemberHasItems db user member >>= \case - Just _ -> updateGroupMemberStatus db userId member GSMemRemoved - Nothing -> deleteGroupMember db user member - pure gInfo' + m' <- + if isJust (supportChat m) + then deleteGroupMemberSupportChat db m + else pure m + pure (gInfo', m') sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages user ct events = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 27f3a4f61c..c1fc1e917f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2735,7 +2735,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd - deleteMemberItem RGEUserDeleted + deleteMemberItem gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else @@ -2746,29 +2746,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right deletedMember@GroupMember {groupMemberId, memberProfile, memberStatus} -> checkRole deletedMember $ do -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - if isUserGrpFwdRelay gInfo && not forwarded + let shouldForward = isUserGrpFwdRelay gInfo && not forwarded + if shouldForward then do -- Special case: forward before deleting connection. - -- It allows us to avoid adding logic in forwardMsgs to circumvent member filtering. forwardToMember deletedMember deleteMemberConnection' deletedMember True else deleteMemberConnection deletedMember - -- undeleted "member connected" chat item will prevent deletion of member record - gInfo' <- deleteOrUpdateMemberRecord user gInfo deletedMember + let deliveryScope = memberEventDeliveryScope deletedMember + gInfo' <- case deliveryScope of + -- Keep member record if it's support scope - it will be required for forwarding inside that scope. + Just (DJSMemberSupport _) | shouldForward -> updateMemberRecordDeleted user gInfo deletedMember GSMemRemoved + -- Undeleted "member connected" chat item will prevent deletion of member record. + _ -> deleteOrUpdateMemberRecord user gInfo deletedMember let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft deletedMember' = deletedMember {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo' deletedMember' SMDRcv - unless wasDeleted $ deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + unless wasDeleted $ deleteMemberItem gInfo' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) toView $ CEvtDeletedMember user gInfo' m deletedMember' withMessages - pure $ memberEventDeliveryScope deletedMember + pure deliveryScope where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = messageError "x.grp.mem.del with insufficient member permissions" $> Nothing | otherwise = a - deleteMemberItem gEvent = do - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + deleteMemberItem gi gEvent = do + (gi', m', scopeInfo) <- mkGroupChatScope gi m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gi' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci deleteMessages :: MsgDirectionI d => GroupInfo -> GroupMember -> SMsgDirection d -> CM () deleteMessages gInfo' delMem msgDir @@ -2791,11 +2795,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item - gInfo' <- withStore' $ \db -> do - updateGroupMemberStatus db userId m GSMemLeft - if gmRequiresAttention m - then decreaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 44dcd3c7f9..9219094cbc 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1415,9 +1415,8 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} -deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) -deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do - let requiredAttention = gmRequiresAttention m +deleteGroupMemberSupportChat :: DB.Connection -> GroupMember -> IO GroupMember +deleteGroupMemberSupportChat db m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime DB.execute db @@ -1439,11 +1438,7 @@ deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do WHERE group_member_id = ? |] (currentTs, groupMemberId) - let m' = m {supportChat = Nothing, updatedAt = currentTs} - g' <- if requiredAttention - then decreaseGroupMembersRequireAttention db user g - else pure g - pure (g', m') + pure m {supportChat = Nothing, updatedAt = currentTs} updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo updateGroupMembersRequireAttention db user g member member' diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d7dd0fde01..72101c37e3 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1645,7 +1645,7 @@ getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatSco getGroupUnreadCount_ db user g scopeInfo_ contentFilter = head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL " + baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index c46f6a5e7d..91f2ea3899 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -6113,7 +6113,7 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 8fd8a5976d..d0b186dd94 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -479,6 +479,9 @@ testChat3 = testChatCfgOpts3 testCfg testOpts testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts +testChatOpts3 :: HasCallStack => ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatOpts3 = testChatCfgOpts3 testCfg + testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 42562d8e16..b0f4fc99dc 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -224,11 +224,15 @@ chatGroupTests = do it "should forward file inside support scope" testScopedSupportForwardFile it "should forward member removal in support scope in review (x.grp.mem.del)" testScopedSupportForwardMemberRemoval it "should forward admin removal in support scope in review (x.grp.mem.del, relay forwards it was removed)" testScopedSupportForwardAdminRemoval + it "should forward pending member leaving in support scope in review (x.grp.leave)" testScopedSupportForwardLeave it "should forward group deletion in support scope in review (x.grp.del)" testScopedSupportForwardGroupDeletion it "should send messages to admins and members" testSupportCLISendCommand it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen + it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved + it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember + it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves -- TODO [channels fwd] enable tests (requires communicating useRelays to members) -- TODO [channels fwd] add tests for channels -- TODO - tests with multiple relays (all relays should deliver messages, members should deduplicate) @@ -7864,9 +7868,9 @@ testScopedSupportForwardMemberRemoval = alice ##> "#team (support: eve) hi" alice <## "bad chat command: support member not current or pending" bob ##> "#team (support: eve) hi" - bob <## "bad chat command: support member not current or pending" + bob <##. "chat db error: SEGroupMemberNameNotFound" dan ##> "#team (support: eve) hi" - dan <## "bad chat command: support member not current or pending" + dan <##. "chat db error: SEGroupMemberNameNotFound" eve ##> "/groups" eve <## "#team (you are removed, delete local copy: /d #team)" @@ -7972,6 +7976,30 @@ testScopedSupportForwardAdminRemoval = alice ##> "/groups" alice <## "#team (you are removed, delete local copy: /d #team)" +testScopedSupportForwardLeave :: HasCallStack => TestParams -> IO () +testScopedSupportForwardLeave = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRAdmin) (cath, GRMember) (dan, GRModerator) + setupReviewForward alice bob cath dan eve + + -- eve leaves group, bob and dan receive member leave message + eve ##> "/leave #team" + eve <## "#team: you left the group" + eve <## "use /d #team to delete the group" + alice <## "#team: eve left the group" + bob <## "#team: eve left the group" + dan <## "#team: eve left the group" + + alice ##> "#team (support: eve) hi" + alice <## "bad chat command: support member not current or pending" + bob ##> "#team (support: eve) hi" + bob <##. "bad chat command: support member not current or pending" + dan ##> "#team (support: eve) hi" + dan <##. "bad chat command: support member not current or pending" + eve ##> "/groups" + eve <## "#team (you left, delete local copy: /d #team)" + testScopedSupportForwardGroupDeletion :: HasCallStack => TestParams -> IO () testScopedSupportForwardGroupDeletion = testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ @@ -8300,6 +8328,99 @@ testScopedSupportUnreadStatsCorrectOnOpen = { markRead = False } +testScopedSupportMemberRemoved :: HasCallStack => TestParams -> IO () +testScopedSupportMemberRemoved = + testChatOpts3 opts aliceProfile bobProfile cathProfile $ \alice bob cath -> do + createGroup3' "team" alice (bob, GRMember) (cath, GRAdmin) + + bob #> "#team (support) 1" + [alice, cath] *<# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + [alice, cath] *<# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + cath ##> "/rm team bob" + concurrentlyN_ + [ cath <## "#team: you removed bob from the group", + do + bob <## "#team: cath removed you from the group" + bob <## "use /d #team to delete the group", + alice <## "#team: cath removed bob from the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportUserRemovesMember :: HasCallStack => TestParams -> IO () +testScopedSupportUserRemovesMember = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRMember) True + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + alice ##> "/rm team bob" + concurrentlyN_ + [ alice <## "#team: you removed bob from the group", + do + bob <## "#team: alice removed you from the group" + bob <## "use /d #team to delete the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportMemberLeaves :: HasCallStack => TestParams -> IO () +testScopedSupportMemberLeaves = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRMember) True + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + bob ##> "/l team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () testChannelsRelayDeliver = testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do From 13bce0821c9bc9c0a76dec131925b0e6daabbe36 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 11 Dec 2025 17:50:05 +0000 Subject: [PATCH 24/48] core: 6.5.0.5 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 9f8d821a2e..1f331bdf74 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 92a9579e6958026e42a45e32c0e62fca243b4b0f + tag: 2ca440dd2dfd494ff2bb40cc0409d08069d02e04 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 406ef76cc5..61ede61996 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."92a9579e6958026e42a45e32c0e62fca243b4b0f" = "0hacyi4hsbca55l4n2n4pcsmsfvh2jaw681j6106y5mkillmb3fv"; + "https://github.com/simplex-chat/simplexmq.git"."2ca440dd2dfd494ff2bb40cc0409d08069d02e04" = "1jc1a9vh59l0l5hxlin1spv03afrgmmiml5xnakhbi4rk67n0wwr"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index bc49fc0b45..b630c2ef65 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.0.4 +version: 6.5.0.5 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From afccda1b692e34879700770df4d4d32852238309 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:00:59 +0000 Subject: [PATCH 25/48] android: determinstic build in Android Studio (#6502) * apps/android: pass and adjust PATH to shell script * scripts/compress-and-sign: attempt to make determenistic build * android: strip app-lib from project paths * scripts/compress-and-sign-apk: compatability with MacOS * apps/android: remove redundant cmake flag from app-lib * scripts/compress-and-sign-apk: fix permissions + timestamp normalization * scripts/compress-and-sign-apk: fix file ordering some weird mac issues, i dunno * apps/android: strip comment sections and do not embed build-id in libapp * scripts/compress-and-sign-apk: disable verbose logging --------- Co-authored-by: Evgeny Poberezkin --- apps/multiplatform/android/build.gradle.kts | 5 ++- .../src/commonMain/cpp/android/CMakeLists.txt | 17 ++++++++- scripts/android/compress-and-sign-apk.sh | 37 +++++++++++++++---- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 9b4e7ad58c..5255319194 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -192,7 +192,10 @@ tasks { } exec { workingDir("../../scripts/android") - environment = mapOf("JAVA_HOME" to "$javaHome") + environment = mapOf( + "JAVA_HOME" to "$javaHome", + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" + ) commandLine = listOf( "./compress-and-sign-apk.sh", "${rootProject.extra["compression.level"]}", diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt index 49794a8ab5..fcba574974 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt @@ -53,12 +53,19 @@ add_library( support SHARED IMPORTED ) set_target_properties( support PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so) +target_compile_options(app-lib PRIVATE + -g0 +) + # Specifies libraries CMake should link to your target library. You # can link multiple libraries, such as libraries you define in this # build script, prebuilt third-party libraries, or system libraries. # https://developer.android.com/guide/practices/page-sizes#cmake -target_link_options(app-lib PRIVATE "-Wl,-z,max-page-size=16384") +target_link_options(app-lib PRIVATE + "-Wl,-z,max-page-size=16384" + "-Wl,--build-id=none" +) target_link_libraries( # Specifies the target library. app-lib @@ -67,4 +74,10 @@ target_link_libraries( # Specifies the target library. # Links the target library to the log library # included in the NDK. - ${log-lib}) + ${log-lib} +) + +add_custom_command(TARGET app-lib POST_BUILD + COMMAND ${CMAKE_STRIP} --remove-section=.comment $ + COMMENT "Stripping .comment section from app-lib" +) diff --git a/scripts/android/compress-and-sign-apk.sh b/scripts/android/compress-and-sign-apk.sh index 74d59203c4..d9e46a015f 100755 --- a/scripts/android/compress-and-sign-apk.sh +++ b/scripts/android/compress-and-sign-apk.sh @@ -29,20 +29,43 @@ for ORIG_NAME in "${ORIG_NAMES[@]}"; do ORIG_NAME_COPY=$ORIG_NAME-copy mv "$ORIG_NAME" "$ORIG_NAME_COPY" - (cd apk && zip -r -q -"$level" ../"$ORIG_NAME" .) - # Shouldn't be compressed because of Android requirement - (cd apk && zip -r -q -0 ../"$ORIG_NAME" resources.arsc) + # Determenistic build + find apk -type f -exec chmod 644 {} + + find apk -type d -exec chmod 755 {} + + find apk -exec touch -h -d '2025-12-01T00:00:00' {} + + + ( + cd apk + find . -not -path './res/*' -not -name 'resources.arsc' -type f -print0 | sort -z | xargs -0 zip -X -r -q -"$level" ../"$ORIG_NAME" + ) if [ $case_insensitive -eq 1 ]; then # For case-insensitive file systems - list_of_files=$(unzip -l "$ORIG_NAME_COPY" | grep res/ | sed -e "s|.*res/|res/|") - for file in $list_of_files; do unzip -o -q -d apk "$ORIG_NAME_COPY" "$file" && (cd apk && zip -r -q -0 ../"$ORIG_NAME" "$file"); done + list_of_files=$(unzip -l "$ORIG_NAME_COPY" | grep res/ | sed -e "s|.*res/|res/|" | sort -z) + for file in $list_of_files; do + unzip -o -q -d apk "$ORIG_NAME_COPY" "$file" + ( + cd apk + chmod 644 "$file" + touch -h -d '2025-12-01T00:00:00' "$file" + zip -X -r -q -0 ../"$ORIG_NAME" "$file" + ) + done else # This method is not working correctly on case-insensitive file systems since Android AAPT produce the same names of files # but with different case like xX.png, Xx.png, xx.png, etc - (cd apk && zip -r -q -0 ../"$ORIG_NAME" res) + ( + cd apk + find res -type f -print0 | sort -z | xargs -0 zip -X -r -q -0 ../"$ORIG_NAME" + ) fi + # Shouldn't be compressed because of Android requirement + ( + cd apk + find resources.arsc -type f -print0 | sort -z | xargs -0 zip -X -r -q -0 ../"$ORIG_NAME" + ) + #(cd apk && 7z a -r -mx=$level -tzip -x!resources.arsc ../$ORIG_NAME .) #(cd apk && 7z a -r -mx=0 -tzip ../$ORIG_NAME resources.arsc) @@ -61,4 +84,4 @@ for ORIG_NAME in "${ORIG_NAMES[@]}"; do rm "$ORIG_NAME_COPY" 2> /dev/null || true rm -rf apk || true rm "${ORIG_NAME}".idsig 2> /dev/null || true -done \ No newline at end of file +done From e6c00da9e1477c2f8de2bf16e871b35507ff888a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:52:11 +0000 Subject: [PATCH 26/48] ios: 6.5-beta.2 (build 315) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 28763511b8..2d08d1159b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */, ); path = Libraries; sourceTree = ""; @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2095,7 +2095,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2115,7 +2115,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 312; + CURRENT_PROJECT_VERSION = 315; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From c19a7c6fb599d34b2be96e204800aed423bd7ee9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Fri, 12 Dec 2025 18:32:50 +0000 Subject: [PATCH 27/48] 6.5-beta.2: android 330, desktop 127 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index e02424822d..1501cb43dc 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5-beta.1 -android.version_code=327 +android.version_name=6.5-beta.2 +android.version_code=330 android.bundle=false -desktop.version_name=6.5-beta.1 -desktop.version_code=124 +desktop.version_name=6.5-beta.2 +desktop.version_code=127 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From 14043da7481d79054a1b00db84389845e562b8e7 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 14 Oct 2025 08:14:52 +0000 Subject: [PATCH 28/48] github/workflows: macos-13 -> macos-15-intel (#6362) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04c70546fb..a86d4790a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -373,7 +373,7 @@ jobs: cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" - - os: macos-13 + - os: macos-15-intel ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg From 889b5ac2f5f0feb0f780da45a7655d1561df3af6 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 18 Dec 2025 08:58:27 +0000 Subject: [PATCH 29/48] website: update community vouchers page (#6481) * website: update community vouchers page * update doc * old FAQ --- .../rfcs/2025-12-17-community-vouchers-faq.md | 144 ++++++++++++++ website/src/token.md | 176 +++++++----------- 2 files changed, 208 insertions(+), 112 deletions(-) create mode 100644 docs/rfcs/2025-12-17-community-vouchers-faq.md diff --git a/docs/rfcs/2025-12-17-community-vouchers-faq.md b/docs/rfcs/2025-12-17-community-vouchers-faq.md new file mode 100644 index 0000000000..06a6afd169 --- /dev/null +++ b/docs/rfcs/2025-12-17-community-vouchers-faq.md @@ -0,0 +1,144 @@ +# Community Vouchers FAQ + +This is an unabridged version of FAQ previously published at https://simplex.chat/vouchers. + +- [Why Community Vouchers?](#why-community-vouchers) +- [Free Tier?](#free-tier) +- [How Will Vouchers Work?](#how-will-vouchers-work) +- [Will self-hosted servers still be supported by SimpleX network?](#will-self-hosted-servers-still-be-supported-by-simplex-network) +- [What problems Community Vouchers solve that other payment methods can't?](#what-problems-community-vouchers-solve-that-other-payment-methods-cant) +- [How is it possible to provide privacy on public blockchain?](#how-is-it-possible-to-provide-privacy-on-public-blockchain) +- [Will Community Vouchers be pre-sold via private or public sale?](#will-community-vouchers-be-pre-sold-via-private-or-public-sale) +- [Who will sell vouchers?](#who-will-sell-vouchers) +- [How the server operator revenue share is determined?](#how-the-server-operator-revenue-share-is-determined) +- [Who will control and upgrade smart contracts?](#who-will-control-and-upgrade-smart-contracts) +- [Will I be able to sell or transfer Community Vouchers to other people?](#will-i-be-able-to-sell-or-transfer-community-vouchers-to-other-people) +- [Why Not Existing Crypto?](#why-not-existing-crypto) +- [Why build on Ethereum blockchain?](#why-build-on-ethereum-blockchain) +- [Have you considered other blockchains?](#have-you-considered-other-blockchains) +- [Which token specification do you plan to use?](#which-token-specification-do-you-plan-to-use) +- [If you build on another blockchain, how the NFT will be used to provide access?](#if-you-build-on-another-blockchain-how-the-nft-will-be-used-to-provide-access) + +### Why Community Vouchers? + + + + + +To cover server costs securely and privately. + +With "free" centralized platforms: +- you lose security and privacy, because your data is used for advertising and sold. +- they de-platform inconvenient users, often based on frivolous complaints. +- you don't own all rights to your content. + +Paying for server capacity may be cheaper than "free" platforms. Our estimates based on the current costs are $5-10/month for 5,000 active message receivers (could be up to 50,000 listed community members) with 5-10 GB of files/media archive. These estimates are preliminary and may change. + +### Free Tier? + +It will be determined after testing. Preliminarily, we expect up to 1,000 active message receivers (can be up to 10,000 listed members) and 500 MB storage to be available for free groups. + +"Active message recipient" in this model is a group member who periodically connects to the network, and receives group messages. Members who are listed but don't open the group for some time, for example two weeks, will stop receiving all group messages even when they are connected to the network. This is an evolving design that will balance security for group members and owners, to avoid inflated expenses, and to present realistic membership statistics to the group owners and to prospective members. + +Private messaging with contacts and in private groups within "fair use" limits that we apply today will remain free: +- there can be up to 128 undelivered messages per destination, +- the undelivered messages are stored up to 21 days, +- files up to 1 GB can be sent for free, +- files are available for download for up to 2 days. + +Larger limits may be offered in paid tier, but it is not planned initially — the focus of Community Vouchers is to create a commercial model for communities. + +### How Will Vouchers Work? + +Buy via app (like phone top-ups), with unused capacity shown in the app. An important design goal is to make Community Vouchers available to people who don't use any cryptocurrencies. + +Testnet is likely to use hashed IDs for privacy and on-chain payments, to validate the pricing and economic model. Zero-knowledge proofs and in-app payments will be added by the time production network is launched. + +### Will self-hosted servers still be supported by SimpleX network? + +Yes, absolutely. Not only will the apps continue to support self-hosted servers, but we will improve it. We see network decentralization and server portability as very important, and while we need to develop a robust commercial model for the servers, we still need community-hosted servers to function, with all people using a single network: +- users who use self-hosted servers will be able to join groups that use pre-configured servers or Community Vouchers. +- users who use pre-configured servers will be able to join groups that are run on free community-hosted servers, same as today. +- you will be able to create new server operators (collections of messaging and file servers operated by one entity), so that the features currently available only for preset servers will be available to all servers very soon, and all servers that you and your community members want to use can be added to the app by scanning a QR code. + +### What problems Community Vouchers solve that other payment methods can't? + +Community Vouchers implemented via smart contracts on blockchain solve these problems: +- unlinkability of the voucher purchase and usage - to confirm ownership smart contracts will use zero-knowledge proofs, rather than visible transfers between blockchain addresses. While any blockchain observers may see that a given address purchased a voucher, they will not see how vouchers are used. +- server operators cannot fail to provide infrastructure — the funds will be locked in a smart contract until it is provided. +- codify the agreement about how revenue is shared between server operator and the network, so that it depends not on trust, but on cryptography. + +### How is it possible to provide privacy on public blockchain? + +In the same way it is possible to provide private communications on the public Internet, as SimpleX network does. + +Our commitment to users' privacy and security remains as strong as ever, and we plan to bring the practical expertise of building private communication protocols over the last 5 years to how we develop the technology for the blockchain. + +While specific designs are in early stages, here are some of the principles that we will follow to ensure privacy: +- each voucher purchase will be associated with a new blockchain address. There will be no per-user addresses, as wallets use. So the cornerstone of SimpleX network design - no user profile IDs - will be followed for blockchain development as well. +- all operations on blockchain will be supported by network servers that will run full blockchain nodes. For important requests, such as name resolution, the clients will use 2 or 3 independent servers, to ensure protection from MITM attacks. +- blockchain operations will be proxied, in the same way as it happens with private message routing. + +We will be publishing the whitepaper about this design. It will provide an unprecedented level of security and privacy for blockchain applications, irrespective of which chain we choose to use. + +### Will Community Vouchers be pre-sold via private or public sale? + +There will be no Community Vouchers pre-sold or in any other way made available to the team, or to investors or to the public. + +Any blockchain token that is pre-sold to raise funds to develop technology is not a utility token, regardless of how it's named — it becomes an investment contract that passes [Howey test](https://www.investopedia.com/terms/h/howey-test.asp). + +This is not what we are doing. Community Vouchers are restricted utility tokens, not an investment contract. They will be only issued on demand to people who want to pay for network servers, at a fixed price. + +### Who will sell vouchers? + +Initially, Community Vouchers will be sold via a smart contract in exchange for some other tradeable tokens, most likely stablecoins. We don't plan any token emission, or any public or private pre-sales. And we won't have access to the funds from voucher sales — they will be locked in a smart contract, and only released once servers have provided capacity to the users, with the funds shared between server operators and the SimpleX network, with operators receiving up to 60%, depending on trust evaluation. The SimpleX network funds will be managed by smart contracts, and will be used for governance and development as defined by the contracts. Their price will be fixed based on server costs, with the exact economic model developed during the testing phase. + +### How the server operator revenue share is determined? + +It will be based on the goal that servers must provide both reliability and security to the users. Security in any multi-node network depends on users' ability to choose independent servers that are provided by different entities, and the apps are already programmed not just to use different servers for the message delivery path, but to use servers of different operators. Even though currently there are only 2 preset operators — SimpleX Chat and Flux — and all servers added to the app by the user are considered a third operator, it substantially improves privacy and security. + +In the future, the operators that confirmed their identity to the network will receive a much higher revenue share than anonymous ones. We believe that for users to be private and secure, operators must be known, and must accept legally binding terms of operation, same as preset operators do today. + +The other two factors that will affect "trust evaluation" will be how long the operator was available on the network and servers' availability uptime. Similar to how we monitor the uptime of our servers, the network will monitor the uptime of all servers, and it will affect the revenue share. + +We don't have an exact model for revenue sharing yet; it will be determined during testing and will evolve based on feedback from users and server operators. + +### Who will control and upgrade smart contracts? + +Community Vouchers will require several smart contracts for their functioning. During testing and development, SimpleX Chat will maintain and update all contracts. Once the network is ready for production, some critical contracts (e.g., those that control the funds) will be immutable, requiring a lot of testing and a security audit, and some less critical contracts will still be upgradeable based on a consensus model (e.g., multisig or voting). + +It is always a journey from knowing that something is possible to knowing how exactly it will be done, and we are at the early stage of knowing it is possible. Specific designs would evolve, based on the input from legal and blockchain experts, and from the community — as everything else we develop for SimpleX network. + +### Will I be able to sell or transfer Community Vouchers to other people? + +Possibly, but with limits on the number of transactions and the time of holding. + +Community Vouchers are designed with a single purpose — to facilitate payments for servers' capacity in a way that protects users' security. Smart contracts implementing them will restrict or completely prohibit trading. The specific parameters will be determined during design evolution and testing. + +### Why Not Existing Crypto? + +Existing cryptocurrencies do not allow the implementation of the required model for Community Vouchers. The price of cryptocurrencies is determined speculatively, and not based on costs. The fact that they can be freely traded and transferred exposes existing cryptocurrencies and tokens to financial regulations. + +The existing cryptocurrencies such as XMR, BTC and some others will be accepted as payment for Community Vouchers, via bridges, but they cannot be used in the foundation of the system, because they are not as flexible as smart contracts, and cannot directly support the model we are developing. + +### Why build on Ethereum blockchain? + +Many people dislike Ethereum for its high energy usage and high transaction costs in the past. Also, blockchain transactions cannot provide privacy, can they? Why not use Monero (XMR) instead? + +This was our assessment as well in the past. But the last three years changed it, addressing energy usage and transaction costs, and we've seen the growth of several L2 Ethereum blockchains. What made us decide that EVM-based blockchain is the best choice for the current stage is the planned rollout of zkEVM in 2025 with native support for zero-knowledge proofs. + +[Our early ideas about Community Vouchers](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2024-04-26-commercial-model.md) and [the most recent design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-10-23-vouchers.md) rely on zero-knowledge proofs, and as it will be natively supported, EVM blockchains provide a much better foundation to build Community Vouchers than building them from scratch — there is no need to re-invent solutions to problems that are already solved. + +### Have you considered other blockchains? + +We are actively considering which blockchain to build on. Ethereum ecosystem is the most widely adopted, and has very mature systems and tools, and it appears sufficient, but it has its downsides, as does everything. So we are not yet committed to Ethereum. + +### Which token specification do you plan to use? + +Even though these are not freely tradable tokens, we will likely make them compatible with [ERC20 token specification](https://eips.ethereum.org/EIPS/eip-20). It is very simple, one of the earliest, and the most adopted standard on EVM blockchain. It defines tokens, but they don't have to be freely tradeable — the specification allows any extensions and restrictions implemented on top of it. + +Using this specification would make Community Vouchers partially compatible with wallets and chain explorers, making testing, development, and early adoption easier. + +### If you build on another blockchain, how the NFT will be used to provide access? + +We can take into account the list of addresses that hold NFTs and provide access to testnet on any blockchain via a cryptographic signature. That is the reason the NFT is deployed on Ethereum mainnet and not on some of L2 chains. We don't yet know at this stage which L2 testnet will be used. diff --git a/website/src/token.md b/website/src/token.md index 9a7fde2361..b77e67d59b 100644 --- a/website/src/token.md +++ b/website/src/token.md @@ -4,174 +4,126 @@ title: "SimpleX Community Vouchers" permalink: "/vouchers/index.html" --- -# SimpleX Community Vouchers - -Since we started developing the SimpleX network, the app has been downloaded over 1.5 million times. - -We are not just building a messenger. Unlike today's web dominated by big tech platforms, SimpleX is a network where you fully control your online identity, contacts, groups, and content – without ads tracking you or companies mining your data. - -The SimpleX network is designed to be private, secure, decentralized (meaning no single company owns it), and truly owned by its users. That's why we develop it as fully open-source code (anyone can review it or build their own apps and servers). - -To make this vision sustainable, we're introducing Community Vouchers in 2026. Think of them as simple prepaid cards for phone calls, but for funding the servers that power your channels and groups. - -**How it will work in simple terms**: -- Community Vouchers can be purchased using the usual in-app payments (like via Apple or Google Play) or using cryptocurrency, such as Bitcoin or Monero. -- Community Vouchers are secure and private: their usage can't be linked to their purchase, however you paid for them, thanks to advanced cryptography (zero-knowledge proofs). +# SimpleX Community Vouchers: Strategy & Vision -Under the hood, Community Vouchers are utility tokens on a blockchain (a secure, shared ledger that no single company controls). But you won't have to use blockchain if you don't want to: we are making vouchers as easy to use as gift cards. +SimpleX is a private and secure messaging network where you own your identity, contacts, groups, and content — there are no ads, no tracking, and no central authority. It relies on open protocols and open-source code, enabling anyone to audit the code and to create alternative apps and servers. -Community Vouchers are not tradable, and there will be no "pre-mine" or public sale. +To scale for large groups and channels, without relying on any single entity, SimpleX network needs a sustainable way to fund servers. -If you have a cryptowallet, and want to test Community Vouchers early, before full release, you can get a free access pass to the test version — a free non-transferrable NFT on Ethereum mainnet, you only need to pay for gas. +Community Vouchers offer the solution - they are prepaid infrastructure credits for servers used by groups and channels. -To receive updates, connect to us [via SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). +These vouchers are not tradable tokens or speculative assets — there will be no pre-sale or emission. It's a method to pay directly for the network infrastructure while maintaining privacy. -## Community Vouchers FAQ — send your feedback +For early access to test vouchers, if you're familiar with cryptocurrencies, get a free access pass to the test version — a free non-transferrable NFT on Ethereum mainnet, you only need to pay for gas. -These are early insights into how Community Vouchers can work — some of these ideas are still vague; they will evolve based on your feedback and testing. +## Why Community Vouchers? -- [Why Community Vouchers?](#why-community-vouchers) -- [Free Tier?](#free-tier) -- [How Will Vouchers Work?](#how-will-vouchers-work) -- [Will self-hosted servers still be supported by SimpleX network?](#will-self-hosted-servers-still-be-supported-by-simplex-network) -- [What problems Community Vouchers solve that other payment methods can't?](#what-problems-community-vouchers-solve-that-other-payment-methods-cant) -- [How is it possible to provide privacy on public blockchain?](#how-is-it-possible-to-provide-privacy-on-public-blockchain) -- [Will Community Vouchers be pre-sold via private or public sale?](#will-community-vouchers-be-pre-sold-via-private-or-public-sale) -- [Who will sell vouchers?](#who-will-sell-vouchers) -- [How the server operator revenue share is determined?](#how-the-server-operator-revenue-share-is-determined) -- [Who will control and upgrade smart contracts?](#who-will-control-and-upgrade-smart-contracts) -- [Will I be able to sell or transfer Community Vouchers to other people?](#will-i-be-able-to-sell-or-transfer-community-vouchers-to-other-people) -- [Why Not Existing Crypto?](#why-not-existing-crypto) -- [Why build on Ethereum blockchain?](#why-build-on-ethereum-blockchain) -- [Have you considered other blockchains?](#have-you-considered-other-blockchains) -- [Which token specification do you plan to use?](#which-token-specification-do-you-plan-to-use) -- [If you build on another blockchain, how the NFT will be used to provide access?](#if-you-build-on-another-blockchain-how-the-nft-will-be-used-to-provide-access) - -### Why Community Vouchers? - - - - - -To cover server costs securely and privately. +To pay for network infrastructure securely and privately. With "free" centralized platforms: - you lose security and privacy, because your data is used for advertising and sold. - they de-platform inconvenient users, often based on frivolous complaints. - you don't own all rights to your content. -Paying for server capacity may be cheaper than "free" platforms. Our estimates based on the current costs are $5-10/month for 5,000 active message receivers (could be up to 50,000 listed community members) with 5-10 GB of files/media archive. These estimates are preliminary and may change. +Paying for server capacity may be cheaper than "free" platforms. -### Free Tier? +## How Will It Work? -It will be determined after testing. Preliminarily, we expect up to 1,000 active message receivers (can be up to 10,000 listed members) and 500 MB storage to be available for free groups. +In short: -"Active message recipient" in this model is a group member who periodically connects to the network, and receives group messages. Members who are listed but don't open the group for some time, for example two weeks, will stop receiving all group messages even when they are connected to the network. This is an evolving design that will balance security for group members and owners, to avoid inflated expenses, and to present realistic membership statistics to the group owners and to prospective members. + -Private messaging with contacts and in private groups within "fair use" limits that we apply today will remain free: -- there can be up to 128 undelivered messages per destination, -- the undelivered messages are stored up to 21 days, -- files up to 1 GB can be sent for free, -- files are available for download for up to 2 days. + -Larger limits may be offered in paid tier, but it is not planned initially — the focus of Community Vouchers is to create a commercial model for communities. +- Buy Community Vouchers. Initially you would pay with a stablecoin (USDT/USDC). The goal to allow using other popular cryptocurrencies (BTC/ETH/XMR) and also in-app payments - to make direct usage of blockchain optional for the end users. -### How Will Vouchers Work? +- Funds are locked in an autonomous smart contract not controlled by SimpleX Chat company or by anybody else. -Buy via app (like phone top-ups), with unused capacity shown in the app. An important design goal is to make Community Vouchers available to people who don't use any cryptocurrencies. +- Assign the Community Voucher to a group or channel you want, using it's public address. This assignment is private, and group owners or server operators won't be able to link it to the purchase, thanks to zero-knowledge proofs. -Testnet is likely to use hashed IDs for privacy and on-chain payments, to validate the pricing and economic model. Zero-knowledge proofs and in-app payments will be added by the time production network is launched. +- Group or channel owners redeem the Vouchers to the server operators they use. The redemption is also private, and not linkable to the assignment or purchase. -### Will self-hosted servers still be supported by SimpleX network? +- Server operators receive up to 70% of the unlocked funds, with the rest being allocated to network development and governance. -Yes, absolutely. Not only will the apps continue to support self-hosted servers, but we will improve it. We see network decentralization and server portability as very important, and while we need to develop a robust commercial model for the servers, we still need community-hosted servers to function, with all people using a single network: -- users who use self-hosted servers will be able to join groups that use pre-configured servers or Community Vouchers. -- users who use pre-configured servers will be able to join groups that are run on free community-hosted servers, same as today. -- you will be able to create new server operators (collections of messaging and file servers operated by one entity), so that the features currently available only for preset servers will be available to all servers very soon, and all servers that you and your community members want to use can be added to the app by scanning a QR code. +## Why Blockchain? -### What problems Community Vouchers solve that other payment methods can't? +It's the only way to make SimpleX network truly decentralized and secure: -Community Vouchers implemented via smart contracts on blockchain solve these problems: -- unlinkability of the voucher purchase and usage - to confirm ownership smart contracts will use zero-knowledge proofs, rather than visible transfers between blockchain addresses. While any blockchain observers may see that a given address purchased a voucher, they will not see how vouchers are used. -- server operators cannot fail to provide infrastructure — the funds will be locked in a smart contract until it is provided. -- codify the agreement about how revenue is shared between server operator and the network, so that it depends not on trust, but on cryptography. +- Group and user names resistant to man-in-the-middle attacks. -### How is it possible to provide privacy on public blockchain? +- Public registry of network operators, with their trust scores. -In the same way it is possible to provide private communications on the public Internet, as SimpleX network does. +- Private and secure payments based on zero-knowledge proofs in smart-contracts. -Our commitment to users' privacy and security remains as strong as ever, and we plan to bring the practical expertise of building private communication protocols over the last 5 years to how we develop the technology for the blockchain. +We are currently evaluating several popular blockchains that have strong support for zero-knowledge proofs - technology that support private operations on public blockchains. -While specific designs are in early stages, here are some of the principles that we will follow to ensure privacy: -- each voucher purchase will be associated with a new blockchain address. There will be no per-user addresses, as wallets use. So the cornerstone of SimpleX network design - no user profile IDs - will be followed for blockchain development as well. -- all operations on blockchain will be supported by network servers that will run full blockchain nodes. For important requests, such as name resolution, the clients will use 2 or 3 independent servers, to ensure protection from MITM attacks. -- blockchain operations will be proxied, in the same way as it happens with private message routing. +## Timeline & How to Get Involved -We will be publishing the whitepaper about this design. It will provide an unprecedented level of security and privacy for blockchain applications, irrespective of which chain we choose to use. +**2025**: +- evaluating blockchains, +- drafting Community Vouchers whitepaper about system and cryptography design for Community Vouchers. +- development of large group and communities. -### Will Community Vouchers be pre-sold via private or public sale? +We welcome your feedback on this proposal and any in-progress design documents. -There will be no Community Vouchers pre-sold or in any other way made available to the team, or to investors or to the public. +**2026**: +- launch support for large groups and channels. +- test version of Community Vouchers. +- SimpleX network namespace v1. -Any blockchain token that is pre-sold to raise funds to develop technology is not a utility token, regardless of how it's named — it becomes an investment contract that passes [Howey test](https://www.investopedia.com/terms/h/howey-test.asp). +**Join in**: +- Get a free NFT for early testing. +- Create a small group or channel using today's tech, and get it added to our experimental directory of groups. +- Talk to us if you want to be a server operator to earn revenue and about any partnerships. -This is not what we are doing. Community Vouchers are restricted utility tokens, not an investment contract. They will be only issued on demand to people who want to pay for network servers, at a fixed price. +## Community Vouchers FAQ -### Who will sell vouchers? +**Will self-hosted servers still work?** -Initially, Community Vouchers will be sold via a smart contract in exchange for some other tradeable tokens, most likely stablecoins. We don't plan any token emission, or any public or private pre-sales. And we won't have access to the funds from voucher sales — they will be locked in a smart contract, and only released once servers have provided capacity to the users, with the funds shared between server operators and the SimpleX network, with operators receiving up to 60%, depending on trust evaluation. The SimpleX network funds will be managed by smart contracts, and will be used for governance and development as defined by the contracts. Their price will be fixed based on server costs, with the exact economic model developed during the testing phase. +Yes! Support for self-hosted servers will be improved, and they can be used together with paid servers, for better reliability and censorship-resistance. -### How the server operator revenue share is determined? +**Why not just use existing cryptocurrency?** -It will be based on the goal that servers must provide both reliability and security to the users. Security in any multi-node network depends on users' ability to choose independent servers that are provided by different entities, and the apps are already programmed not just to use different servers for the message delivery path, but to use servers of different operators. Even though currently there are only 2 preset operators — SimpleX Chat and Flux — and all servers added to the app by the user are considered a third operator, it substantially improves privacy and security. +Cryptocurrencies are: +- Speculative and volatile. +- Regulated as financial transactions. +- BTC and XMR blockchains do not support smart contract logic, e.g. for locked funds. -In the future, the operators that confirmed their identity to the network will receive a much higher revenue share than anonymous ones. We believe that for users to be private and secure, operators must be known, and must accept legally binding terms of operation, same as preset operators do today. +**How can it be private on a public blockchain?** -The other two factors that will affect "trust evaluation" will be how long the operator was available on the network and servers' availability uptime. Similar to how we monitor the uptime of our servers, the network will monitor the uptime of all servers, and it will affect the revenue share. +High level of privacy is achieved by new address per purchase, proxied access to blockchain, and zero-knowledge proofs that make payment and usage unlinkable. -We don't have an exact model for revenue sharing yet; it will be determined during testing and will evolve based on feedback from users and server operators. +**Can I sell or transfer vouchers?** -### Who will control and upgrade smart contracts? +No, Community Vouchers cannot be sold or transferred. Once purchased and assigned to a group or channel, they can only be redeemed to server operators. They will expire in 12 months if not redeemed, with the funds released to network development and governance. -Community Vouchers will require several smart contracts for their functioning. During testing and development, SimpleX Chat will maintain and update all contracts. Once the network is ready for production, some critical contracts (e.g., those that control the funds) will be immutable, requiring a lot of testing and a security audit, and some less critical contracts will still be upgradeable based on a consensus model (e.g., multisig or voting). +**Free messaging limits?** -It is always a journey from knowing that something is possible to knowing how exactly it will be done, and we are at the early stage of knowing it is possible. Specific designs would evolve, based on the input from legal and blockchain experts, and from the community — as everything else we develop for SimpleX network. +Private chats and small groups remain free within fair use (up to 128 undelivered messages per contact, with up to 21 days storage, up to 1GB files stored for 2 days). Community Vouchers will be used to pay for large groups infrastructure and for memorable public names. -### Will I be able to sell or transfer Community Vouchers to other people? +**Who controls the smart contracts?** -Possibly, but with limits on the number of transactions and the time of holding. +We will control them during testing. Once released for general access, the contracts that accept and hold funds will be autonomous and immutable. -Community Vouchers are designed with a single purpose — to facilitate payments for servers' capacity in a way that protects users' security. Smart contracts implementing them will restrict or completely prohibit trading. The specific parameters will be determined during design evolution and testing. +**How is revenue share of server operators determined?** -### Why Not Existing Crypto? +Server operators will receive up to 70% of the infrastructure payments. A higher share will be allocated for: +- identified operators, because knowing who runs the servers achieves better user privacy and security, +- more reliable servers, +- servers that operated for a longer time. -Existing cryptocurrencies do not allow the implementation of the required model for Community Vouchers. The price of cryptocurrencies is determined speculatively, and not based on costs. The fact that they can be freely traded and transferred exposes existing cryptocurrencies and tokens to financial regulations. +**What is technology design?** -The existing cryptocurrencies such as XMR, BTC and some others will be accepted as payment for Community Vouchers, via bridges, but they cannot be used in the foundation of the system, because they are not as flexible as smart contracts, and cannot directly support the model we are developing. +[Early ideas about Community Vouchers](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2024-04-26-commercial-model.md). -### Why build on Ethereum blockchain? +[The most recent design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-10-23-vouchers.md) based on zero-knowledge proofs. -Many people dislike Ethereum for its high energy usage and high transaction costs in the past. Also, blockchain transactions cannot provide privacy, can they? Why not use Monero (XMR) instead? +[Previosly shared FAQ](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-12-17-community-vouchers-faq.md). -This was our assessment as well in the past. But the last three years changed it, addressing energy usage and transaction costs, and we've seen the growth of several L2 Ethereum blockchains. What made us decide that EVM-based blockchain is the best choice for the current stage is the planned rollout of zkEVM in 2025 with native support for zero-knowledge proofs. +A whitepaper will be published in February 2026. -[Our early ideas about Community Vouchers](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2024-04-26-commercial-model.md) and [the most recent design](https://github.com/simplex-chat/simplex-chat/blob/master/docs/rfcs/2025-10-23-vouchers.md) rely on zero-knowledge proofs, and as it will be natively supported, EVM blockchains provide a much better foundation to build Community Vouchers than building them from scratch — there is no need to re-invent solutions to problems that are already solved. - -### Have you considered other blockchains? - -We are actively considering which blockchain to build on. Ethereum ecosystem is the most widely adopted, and has very mature systems and tools, and it appears sufficient, but it has its downsides, as does everything. So we are not yet committed to Ethereum. - -### Which token specification do you plan to use? - -Even though these are not freely tradable tokens, we will likely make them compatible with [ERC20 token specification](https://eips.ethereum.org/EIPS/eip-20). It is very simple, one of the earliest, and the most adopted standard on EVM blockchain. It defines tokens, but they don't have to be freely tradeable — the specification allows any extensions and restrictions implemented on top of it. - -Using this specification would make Community Vouchers partially compatible with wallets and chain explorers, making testing, development, and early adoption easier. - -### If you build on another blockchain, how the NFT will be used to provide access? - -We can take into account the list of addresses that hold NFTs and provide access to testnet on any blockchain via a cryptographic signature. That is the reason the NFT is deployed on Ethereum mainnet and not on some of L2 chains. We don't yet know at this stage which L2 testnet will be used. ## Disclaimer From 6c750eb5df450edeed93e993352d9a5540cea81b Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:03:44 +0000 Subject: [PATCH 30/48] core: fix remote desktop failing to receive new events after agent error (#6512) --- src/Simplex/Chat/Remote.hs | 10 +++++++--- src/Simplex/Chat/Types.hs | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index a706bfc0de..255af5f318 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -233,9 +233,13 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do pollEvents :: RemoteHostId -> RemoteHostClient -> CM () pollEvents rhId rhClient = do oq <- asks outputQ - forever $ do - r_ <- liftRH rhId $ remoteRecv rhClient 10000000 - forM r_ $ \r -> atomically $ writeTBQueue oq (Just rhId, r) + forever $ + handlePollError oq $ do + r_ <- liftRH rhId $ remoteRecv rhClient 10000000 + forM_ r_ $ \r -> atomically $ writeTBQueue oq (Just rhId, r) + where + handlePollError oq a = a `catchAllErrors` \e -> + atomically $ writeTBQueue oq (Just rhId, Left e) httpError :: RemoteHostId -> HTTP2ClientError -> ChatError httpError rhId = ChatErrorRemoteHost (RHId rhId) . RHEProtocolError . RPEHTTP2 . tshow diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 667660c97b..c08c0827ae 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -36,6 +36,7 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString, pack, unpack) +import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) @@ -1411,7 +1412,7 @@ instance ToField AgentConnId where toField (AgentConnId m) = toField $ Binary m instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId strDecode s = AgentConnId <$> strDecode s - strP = AgentConnId <$> strP + strP = AgentConnId <$> (strP <|> pure B.empty) instance FromJSON AgentConnId where parseJSON = strParseJSON "AgentConnId" From dc3fc21be4fb1d66948bb0adb8daac48d4a56429 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:07:13 +0000 Subject: [PATCH 31/48] core: fix opening new contact request on remote desktop (#6514) --- .../commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index fb61cb5100..a244293edb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2616,7 +2616,7 @@ object ChatController { if (chatModel.chatsContext.hasChat(rhId, r.chat_.id)) { chatModel.chatsContext.updateChatInfo(rhId, r.chat_.chatInfo) } else { - chatModel.chatsContext.addChat(r.chat_) + chatModel.chatsContext.addChat(r.chat_.copy(remoteHostId = rhId)) } } else { val cInfo = ChatInfo.ContactRequest(contactRequest) From febb79d7e1a5a47c3cc23fd6adca92b78c7d4cc3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 18 Dec 2025 23:16:22 +0000 Subject: [PATCH 32/48] website: update image --- website/src/img/design_3/simplex_nft_smpx.jpg | Bin 101634 -> 103096 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/website/src/img/design_3/simplex_nft_smpx.jpg b/website/src/img/design_3/simplex_nft_smpx.jpg index 91260056a2fcd7d0eedc800c57cae8d018784c8c..1d47715ec23426a3d35543e69f652188de9dc4b8 100644 GIT binary patch literal 103096 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 literal 101634 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�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 Date: Sat, 20 Dec 2025 17:19:00 +0000 Subject: [PATCH 33/48] scripts/simplex-chat-reproduce-builds-android: add script (#6497) * script/simplex-chat-reproduce-builds-android: initial structure * Dockerfile.build: add dependencies * scripts/build-android: allow setting custom vercode * scripts/simplex-chat-reproduce-builds-android: populate functions * Dockerfile.build: setup regular user * Dockerfile.build: fix env * Dockerfile.build: switch user to ubuntu * Dockerfile.build: set USER variable * Dockerfile.build: create ubuntu user (aarch64 doesn't have it) * ci/build: remove permissions workaround * Dockerfile.build: fix groupadd * ci/build: adjust permissions before build * Dockerfile.build: allow to dynamically set user/group * ci/build: set uid and gid in Docker * ci/build: remove unneeded step * Dockerfile.build: also create /out * sync changes, add debugging * add verification function, fixes * Dockerfile: add android scripts * fixes, remove debugging * more fixes * fix download apk and add message at the end * scripts/simplex-chat-reproduce-builds.sh: add user uid and gid * fix vercode * add logging * refactor and make vars saner * fixes --- .github/workflows/build.yml | 9 +- Dockerfile.build | 37 ++- scripts/android/build-android.sh | 7 +- .../simplex-chat-reproduce-builds-android.sh | 253 ++++++++++++++++++ scripts/simplex-chat-reproduce-builds.sh | 2 + 5 files changed, 298 insertions(+), 10 deletions(-) create mode 100755 scripts/simplex-chat-reproduce-builds-android.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a86d4790a8..f73bfa7927 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,6 +147,12 @@ jobs: with: swap-size-gb: 30 + - name: Get UID and GID + id: ids + run: | + echo "uid=$(id -u)" >> $GITHUB_OUTPUT + echo "gid=$(id -g)" >> $GITHUB_OUTPUT + # Otherwise we run out of disk space with Docker build - name: Free disk space if: matrix.should_run == true @@ -177,6 +183,8 @@ jobs: build-args: | TAG=${{ matrix.os }} GHC=${{ matrix.ghc }} + USER_UID=${{ steps.ids.outputs.uid }} + USER_GID=${{ steps.ids.outputs.gid }} # Docker needs these flags for AppImage build: # --device /dev/fuse @@ -209,7 +217,6 @@ jobs: if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | - chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*' cabal clean cabal update cabal build -j --enable-tests diff --git a/Dockerfile.build b/Dockerfile.build index fddc96b6c2..3ddff59d12 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -13,6 +13,10 @@ ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514 ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive +ARG USER_UID=1000 +ARG USER_GID=1000 +ARG USER_NAME=builder + # Install curl, git and and simplex-chat dependencies RUN apt-get update && \ apt-get install -y curl \ @@ -38,6 +42,11 @@ RUN apt-get update && \ file \ appstream \ gpg \ + zipalign \ + apksigner \ + python3 \ + python3-venv \ + xz-utils \ unzip &&\ ln -s /bin/fusermount /bin/fusermount3 || : @@ -67,6 +76,12 @@ RUN export JAVA_FILENAME='java-corretto.deb' \ echo "Checksum mismatch" && exit 1; \ fi +RUN userdel -r ubuntu || : +RUN groupadd -g ${USER_GID} ${USER_NAME} || :; useradd -u ${USER_UID} -g ${USER_GID} --create-home --shell /bin/bash ${USER_NAME} || : +RUN mkdir /nix /out && chown ${USER_NAME}:${USER_NAME} /nix /out +USER ${USER_NAME} +WORKDIR /home/${USER_NAME} + # Specify bootstrap Haskell versions ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} @@ -78,8 +93,10 @@ ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true # Install ghcup RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh +# Setup basic env variables (required) +ENV HOME="/home/${USER_NAME}" USER="${USER_NAME}" # Adjust PATH -ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH" +ENV PATH="$HOME/.cabal/bin:$HOME/.ghcup/bin:$PATH" # Set both as default RUN ghcup set ghc "${GHC}" && \ @@ -90,8 +107,8 @@ RUN ghcup set ghc "${GHC}" && \ #===================== ARG SDK_VERSION=13114758 -ENV SDK_VERSION=$SDK_VERSION \ - ANDROID_HOME=/root +ENV SDK_VERSION="$SDK_VERSION" \ + ANDROID_HOME="$HOME" RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \ unzip tools.zip && rm tools.zip && \ @@ -101,11 +118,17 @@ RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlineto ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin" # https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded -RUN mkdir -p ~/.android ~/.gradle && \ - touch ~/.android/repositories.cfg && \ - echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\ +RUN mkdir -p "$HOME/.android" "$HOME/.gradle" && \ + touch "$HOME/.android/repositories.cfg" && \ + echo 'org.gradle.console=plain' > "$HOME/.gradle/gradle.properties" &&\ yes | sdkmanager --licenses >/dev/null -ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools +ENV PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools" + +# Android reproducibility scripts +RUN python3 -m venv "$HOME/.venv" +RUN "$HOME/.venv/bin/pip" install apksigcopier repro-apk + +ENV PATH="$HOME/.venv/bin:$PATH" WORKDIR /project diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 90d6092385..afd13011c9 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -102,6 +102,7 @@ build() { sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts" + sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do if [ "$arch" = "armv7a" ]; then @@ -169,8 +170,10 @@ pre() { done shift $(( $OPTIND - 1 )) - - commit="${1:-HEAD}" + + vercode="${1}" + + commit="${2:-HEAD}" } main() { diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh new file mode 100755 index 0000000000..ac026f95cf --- /dev/null +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env sh +set -eu + +SIMPLEX_KEY='3C:52:C4:FD:3C:AD:1C:07:C9:B0:0A:70:80:E3:58:FA:B9:FE:FC:B8:AF:5A:EC:14:77:65:F1:6D:0F:21:AD:85' + +REPO_NAME="simplex-chat" +REPO="https://github.com/simplex-chat/${REPO_NAME}" + +IMAGE_NAME='sx-local-android' +CONTAINER_NAME='sx-builder-android' +DOCKER_PATH_PROJECT='/project' +DOCKER_PATH_VERIFY='/verify' + +export DOCKER_BUILDKIT=1 + +SIMPLEX_REPO='simplex-chat/simplex-chat' +CMDS="curl git docker" + +INIT_DIR="$PWD" +TEMPDIR="$(mktemp -d)" + +ARCHES="${ARCHES:-aarch64 armv7a}" + +COLOR_CYAN="\033[36m" +COLOR_RESET="\033[0m" + +SUFFIX_BUILT='built' +SUFFIX_DOWNLOADED='downloaded' +SUFFIX_BUILT_WITH_SIGNATURE='built-with-downloaded-signature' + +cleanup() { + rm -rf -- "${TEMPDIR}" + docker rm --force "${CONTAINER_NAME}" 2>/dev/null || : + docker image rm "${IMAGE_NAME}" 2>/dev/null || : +} +trap 'cleanup' EXIT INT + +check() { + set +u + + for i in $commands; do + case $i in + *) + if ! command -v "$i" > /dev/null 2>&1; then + commands_failed="$i $commands_failed" + fi + ;; + esac + done + + if [ -n "$commands_failed" ]; then + commands_failed=${commands_failed% *} + printf "%s is not found in your \$PATH. Please install them and re-run the script.\n" "$commands_failed" + exit 1 + fi + + set -u +} + +download_apk() { + tag="$1" + filename="$2" + file_out="$3" + + curl -L "${REPO}/releases/download/${tag}/${filename}" -o "$file_out" +} + +setup_git() { + workdir="$1" + name="$2" + + git -C "$workdir" clone "${REPO}.git" "$name" +} + +checkout_git() { + git_dir="$1" + tag="$2" + + git -C "$git_dir" reset --hard + git -C "$git_dir" clean -dfx + git -C "$git_dir" checkout "$tag" +} + +check_apk() { + apk_name="$1" + expected="$2" + + actual=$(docker exec "${CONTAINER_NAME}" apksigner verify --print-certs "${DOCKER_PATH_VERIFY}/${apk_name}" | grep 'SHA-256' | awk '{print $NF}' | fold -w2 | paste -sd: | tr '[:lower:]' '[:upper:]') + + if [ "$expected" = "$actual" ]; then + return 0 + else + return 1 + fi +} + +verify_apk() { + apk_name="$1" + + # https://github.com/obfusk/apksigcopier?tab=readme-ov-file#what-about-signatures-made-by-apksigner-from-build-tools--3500-rc1 + docker exec "${CONTAINER_NAME}" repro-apk zipalign --page-size 16 --pad-like-apksigner --replace "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_BUILT}" \ + "${DOCKER_PATH_VERIFY}/${apk_name}.aligned" + docker exec "${CONTAINER_NAME}" mv "${DOCKER_PATH_VERIFY}/${apk_name}.aligned" \ + "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_BUILT}" + + docker exec "${CONTAINER_NAME}" apksigcopier copy "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_DOWNLOADED}" \ + "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_BUILT}" \ + "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_BUILT_WITH_SIGNATURE}" + + downloaded_apk_hash=$(docker exec "${CONTAINER_NAME}" sha256sum "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_DOWNLOADED}" | awk '{print $1}') + built_apk_hash=$(docker exec "${CONTAINER_NAME}" sha256sum "${DOCKER_PATH_VERIFY}/${apk_name}.${SUFFIX_BUILT_WITH_SIGNATURE}" | awk '{print $1}') + + if [ "$downloaded_apk_hash" = "$built_apk_hash" ]; then + return 0 + else + return 1 + fi +} + +print_vercode() { + build_dir="$1" + awk -F'=' '/android.version_code=/ {print $2}' "${build_dir}/apps/multiplatform/gradle.properties" +} + +setup_container() { + dir_git="$1" + dir_apk="$2" + + docker build \ + --no-cache \ + -f "${dir_git}/Dockerfile.build" \ + -t "${IMAGE_NAME}" \ + --build-arg=USER_UID="$(id -u)" \ + --build-arg=USER_GID="$(id -g)" \ + . + + # Run container in background + docker run -t -d \ + --name "${CONTAINER_NAME}" \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --security-opt apparmor:unconfined \ + --security-opt seccomp:unconfined \ + -v "${dir_git}:${DOCKER_PATH_PROJECT}" \ + -v "${dir_apk}:${DOCKER_PATH_VERIFY}" \ + "${IMAGE_NAME}" +} + +build_apk() { + arch="$1" + vercode="$2" + + apk_out="simplex-${arch}.apk.${SUFFIX_BUILT}" + + # Gradle setup + docker exec -i "${CONTAINER_NAME}" sh << EOF +cd $DOCKER_PATH_PROJECT/apps/multiplatform +./gradlew +EOF + + docker exec -i "${CONTAINER_NAME}" sh << EOF +GRADLE_BIN=\$(find \$HOME/.gradle/wrapper/dists -name "gradle" -type f -executable 2>/dev/null | head -1) +GRADLE_DIR=\$(dirname "\$GRADLE_BIN") +export PATH="\$GRADLE_DIR:\$PATH" + +ARCHES="$arch" ./scripts/android/build-android.sh -gs "$vercode" || ARCHES="$arch" ./scripts/android/build-android.sh -gs "$vercode" + +APK_FILE=\$(find . -maxdepth 1 -type f -name '*.apk') + +mv "\$APK_FILE" $DOCKER_PATH_VERIFY/$apk_out +EOF +} + +main() { + tag="$1" + + build_directory="${TEMPDIR}/${REPO_NAME}" + final_directory="$INIT_DIR/${tag}-${REPO_NAME}" + apk_directory="${final_directory}/android" + + printf 'This script will: +1) build docker container. +2) download APK from GitHub and validate signatures. +3) build core library with nix (12-24 hours). +4) build APK and compare with downloaded one + +Continue?' + + read _ + + check + + mkdir -p "${apk_directory}" + + # Setup initial git for Dockerfile.build + setup_git "$TEMPDIR" "$REPO_NAME" + checkout_git "$build_directory" "$tag" + + printf "${COLOR_CYAN}Building Docker container...${COLOR_RESET}\n" + setup_container "$build_directory" "$apk_directory" + + # Check phase + for arch in $ARCHES; do + filename="simplex-${arch}.apk" + + download_apk "$tag" "$filename" "${apk_directory}/${filename}.${SUFFIX_DOWNLOADED}" + + if check_apk "${filename}.${SUFFIX_DOWNLOADED}" "$SIMPLEX_KEY"; then + printf "${COLOR_CYAN}APK for %s is signed by valid key.${COLOR_RESET}\n" "$arch" + else + printf "${COLOR_CYAN}Signature of APK for %s is invalid., aborting the script.${COLOR_RESET}\n" "$arch" + exit 1 + fi + done + + # Build phase + for arch in $ARCHES; do + case "$arch" in + armv7a) + build_tag="${tag}-armv7a" + ;; + aarch64) + build_tag="${tag}" + ;; + *) + printf "${COLOR_CYAN}Unknown architecture: %s! Skipping the build...${COLOR_RESET}\n" "$arch" + continue + esac + + # Setup the code + checkout_git "$build_directory" "$build_tag" + vercode=$(print_vercode "$build_directory") + + printf "${COLOR_CYAN}Building APK for for %s...${COLOR_RESET}\n" "$arch" + build_apk "$arch" "$vercode" + done + + # Verification phase + for arch in $ARCHES; do + filename="simplex-${arch}.apk" + + if ! verify_apk "$filename"; then + printf "${COLOR_CYAN}Failed to verify %s! Aborting.\n${COLOR_RESET}" "$filename" + exit 1 + fi + done + + printf "${COLOR_CYAN}%s is reproducible.${COLOR_RESET}\n" "$tag" + + cleanup +} + +main "$@" diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index e1a62dc73a..0ca2522fa0 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -50,6 +50,8 @@ for os in '22.04' '24.04'; do --no-cache \ --build-arg TAG="${os}" \ --build-arg GHC="${ghc}" \ + --build-arg=USER_UID="$(id -u)" \ + --build-arg=USER_GID="$(id -g)" \ -f "${tempdir}/${repo_name}/Dockerfile.build" \ -t "${image_name}" \ . From 4f65763d59cda9f6d7ffc4deecbe83fc1f428a91 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:01:27 +0000 Subject: [PATCH 34/48] scripts/simplex-chat-reproduce-builds-android: fixes (#6523) * scripts/simplex-chat-reproduce-builds-android: fix check * drop the case --- scripts/simplex-chat-reproduce-builds-android.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index ac026f95cf..0cef17c091 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -36,16 +36,14 @@ cleanup() { trap 'cleanup' EXIT INT check() { + commands="$1" + set +u for i in $commands; do - case $i in - *) - if ! command -v "$i" > /dev/null 2>&1; then - commands_failed="$i $commands_failed" - fi - ;; - esac + if ! command -v "$i" > /dev/null 2>&1; then + commands_failed="$i $commands_failed" + fi done if [ -n "$commands_failed" ]; then @@ -188,7 +186,7 @@ Continue?' read _ - check + check "$CMDS" mkdir -p "${apk_directory}" From 5096acc9e776139931dda651424868c495f04d1d Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:01:56 +0000 Subject: [PATCH 35/48] scripts/android/compress-and-sign-apk: fix sorting (remove -z) (#6524) --- scripts/android/compress-and-sign-apk.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/android/compress-and-sign-apk.sh b/scripts/android/compress-and-sign-apk.sh index d9e46a015f..b1c9b6b5e1 100755 --- a/scripts/android/compress-and-sign-apk.sh +++ b/scripts/android/compress-and-sign-apk.sh @@ -41,7 +41,7 @@ for ORIG_NAME in "${ORIG_NAMES[@]}"; do if [ $case_insensitive -eq 1 ]; then # For case-insensitive file systems - list_of_files=$(unzip -l "$ORIG_NAME_COPY" | grep res/ | sed -e "s|.*res/|res/|" | sort -z) + list_of_files=$(unzip -l "$ORIG_NAME_COPY" | grep res/ | sed -e "s|.*res/|res/|" | sort) for file in $list_of_files; do unzip -o -q -d apk "$ORIG_NAME_COPY" "$file" ( From 5066c5cccaa2d020f48705bfe67dc92213f96924 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 22 Dec 2025 21:53:49 +0000 Subject: [PATCH 36/48] ios: fix new chat sheet closing on new message in iOS 26 (#6525) --- apps/ios/Shared/Views/ChatList/ChatHelp.swift | 3 ++- apps/ios/Shared/Views/ChatList/ChatListView.swift | 7 ++++++- apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift | 8 ++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 7abab33177..3047572236 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -10,6 +10,7 @@ import SwiftUI struct ChatHelp: View { @EnvironmentObject var chatModel: ChatModel + @State private var showNewChatSheet = false let dismissSettingsSheet: DismissAction var body: some View { @@ -38,7 +39,7 @@ struct ChatHelp: View { HStack(spacing: 8) { Text("Tap button ") - NewChatMenuButton() + NewChatMenuButton(showNewChatSheet: $showNewChatSheet) Text("above, then choose:") } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 0450bd439c..efaba518a9 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -140,6 +140,7 @@ struct ChatListView: View { @StateObject private var connectProgressManager = ConnectProgressManager.shared @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? + @State private var showNewChatSheet = false @State private var searchMode = false @FocusState private var searchFocussed @State private var searchText = "" @@ -189,6 +190,10 @@ struct ChatListView: View { onDismiss: { chatModel.laRequest = nil }, content: { UserPickerSheetView(sheet: $0) } ) + .appSheet(isPresented: $showNewChatSheet) { + NewChatSheet() + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -331,7 +336,7 @@ struct ChatListView: View { @ViewBuilder var trailingToolbarItem: some View { switch chatModel.chatRunning { - case .some(true): NewChatMenuButton() + case .some(true): NewChatMenuButton(showNewChatSheet: $showNewChatSheet) case .some(false): chatStoppedIcon() case .none: EmptyView() } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 2e3119a8b8..7adb04cb7e 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -12,7 +12,7 @@ import SimpleXChat struct NewChatMenuButton: View { // do not use chatModel here because it prevents showing AddGroupMembersView after group creation and QR code after link creation on iOS 16 // @EnvironmentObject var chatModel: ChatModel - @State private var showNewChatSheet = false + @Binding var showNewChatSheet: Bool @State private var alert: SomeAlert? = nil var body: some View { @@ -25,10 +25,6 @@ struct NewChatMenuButton: View { .scaledToFit() .frame(width: 24, height: 24) } - .appSheet(isPresented: $showNewChatSheet) { - NewChatSheet() - .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) - } .alert(item: $alert) { a in return a.alert } @@ -471,5 +467,5 @@ struct DeletedChats: View { } #Preview { - NewChatMenuButton() + NewChatMenuButton(showNewChatSheet: Binding.constant(false)) } From 81d31e6c9e36d28abf35e69eeaeca02c176ed783 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 22 Dec 2025 21:58:44 +0000 Subject: [PATCH 37/48] core: 6.5.0.6 --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 1f331bdf74..c61c19a8af 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 2ca440dd2dfd494ff2bb40cc0409d08069d02e04 + tag: 77ac4521908da6bddf0529e28de494d090cd1807 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 61ede61996..74875d4179 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."2ca440dd2dfd494ff2bb40cc0409d08069d02e04" = "1jc1a9vh59l0l5hxlin1spv03afrgmmiml5xnakhbi4rk67n0wwr"; + "https://github.com/simplex-chat/simplexmq.git"."77ac4521908da6bddf0529e28de494d090cd1807" = "1sbl5si9pns9zxn3fip43icfjh1kgv35axwwba2paaiigk669wyx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b630c2ef65..ee3b2c551d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.0.5 +version: 6.5.0.6 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 6530c9840220ded6a7ed02c36a5a0217041877bd Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:24:30 +0000 Subject: [PATCH 38/48] ios: 6.5-beta.3 (build 316) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2d08d1159b..88670f2513 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.5-C9YeXjshpBqGb2o75TqxUY.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */, ); path = Libraries; sourceTree = ""; @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2095,7 +2095,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2115,7 +2115,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 315; + CURRENT_PROJECT_VERSION = 316; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From bf0750735fa593040d5cd592dfda48ea0fab144b Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 23 Dec 2025 08:38:12 +0000 Subject: [PATCH 39/48] core: update query plans --- .../Chat/Store/SQLite/Migrations/agent_query_plans.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 3c15a4303c..efbadf7c53 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -882,7 +882,7 @@ Query: FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id - WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? Plan: SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH q USING PRIMARY KEY (host=? AND port=?) @@ -894,7 +894,7 @@ Query: FROM rcv_queues q JOIN servers s ON q.host = s.host AND q.port = s.port JOIN connections c ON q.conn_id = c.conn_id - WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? AND COALESCE(q.server_key_hash, s.key_hash) = ? Plan: SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=? AND host=? AND port=?) SEARCH c USING PRIMARY KEY (conn_id=?) From 67fbe62ae19281ba3a32b7ba0f5ff22db1ea8c55 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 23 Dec 2025 14:16:05 +0000 Subject: [PATCH 40/48] core: update simplexmq (support any 127.x.x.x address as loopback), show other addresses in CLI during remote connection, update tests (#6526) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/View.hs | 9 ++++----- tests/RemoteTests.hs | 20 ++++++++++++++++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/cabal.project b/cabal.project index 642b252b44..aee9490aef 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 538dcb6a4c53e115655dc07266c4ff386d45511d + tag: 2ea98db9d8ad0dc43820227a89c744822dfadb5d source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9bbc469869..9eed446658 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."538dcb6a4c53e115655dc07266c4ff386d45511d" = "01g6dicws9cdgk4slmlqyvl3i84nw3gyqwmqwr2rwbq0yx7fcmmq"; + "https://github.com/simplex-chat/simplexmq.git"."2ea98db9d8ad0dc43820227a89c744822dfadb5d" = "1vh9s66yywqi33df0la340jws9z3hag7ym7q03rc9x8cjcg6s8sw"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 89bc58171c..1b022f2a1f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -257,11 +257,10 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte rhi_ ] CRRemoteHostList hs -> viewRemoteHosts hs - CRRemoteHostStarted {remoteHost_, invitation, localAddrs = RCCtrlAddress {address} :| _, ctrlPort} -> - [ plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_, - "Remote session invitation:", - plain invitation - ] + CRRemoteHostStarted {remoteHost_, invitation, localAddrs = RCCtrlAddress {address} :| addrs, ctrlPort} -> + [plain $ maybe ("new remote host" <> started) (\RemoteHostInfo {remoteHostId = rhId} -> "remote host " <> show rhId <> started) remoteHost_] + <> [plain $ "other addresses: " <> intercalate " " (map (\RCCtrlAddress {address = a} -> B.unpack (strEncode a)) addrs) | not (null addrs)] + <> ["Remote session invitation:", plain invitation] where started = " started on " <> B.unpack (strEncode address) <> ":" <> ctrlPort CRRemoteFileStored rhId (CryptoFile filePath cfArgs_) -> diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index 9790681f3b..6f215a5dc8 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -9,9 +9,11 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Logger.Simple +import Control.Monad import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB +import Data.List (find, isPrefixOf) import qualified Data.Map.Strict as M import Simplex.Chat.Controller (versionNumber) import qualified Simplex.Chat.Controller as Controller @@ -116,6 +118,7 @@ remoteHandshakeRejectTest = testChat3 aliceProfile aliceDesktopProfile bobProfil mobileBob <## "ok" desktop ##> "/start remote host 1" desktop <##. "remote host 1 started on " + desktop <##. "other addresses: " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobileBob ##> ("/connect remote ctrl " <> inv) @@ -143,8 +146,18 @@ storedBindingsTest = testChat2 aliceProfile aliceDesktopProfile $ \mobile deskto mobile ##> "/set device name Mobile" mobile <## "ok" - desktop ##> "/start remote host new addr=127.0.0.1 iface=\"lo\" port=52230" - desktop <##. "new remote host started on 127.0.0.1:52230" -- TODO: show ip? + desktop ##> "/start remote host new" + desktop <##. "new remote host started on " + addrs <- words . dropStrPrefix "other addresses: " <$> getTermLine desktop + Just localAddress <- pure $ find ("127." `isPrefixOf`) addrs + desktop <## "Remote session invitation:" + void $ getTermLine desktop + desktop ##> "/stop remote host new" + desktop <## "ok" + + desktop ##> ("/start remote host new addr=" <> localAddress <> " iface=\"lo\" port=52230") + desktop <## ("new remote host started on " <> localAddress <> ":52230") + desktop <##. "other addresses: " desktop <## "Remote session invitation:" inv <- getTermLine desktop @@ -497,6 +510,7 @@ startRemote mobile desktop = do mobile <## "ok" desktop ##> "/start remote host new" desktop <##. "new remote host started on " + desktop <##. "other addresses: " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -512,6 +526,7 @@ startRemoteStored :: TestCC -> TestCC -> IO () startRemoteStored mobile desktop = do desktop ##> "/start remote host 1" desktop <##. "remote host 1 started on " + desktop <##. "other addresses: " desktop <## "Remote session invitation:" inv <- getTermLine desktop mobile ##> ("/connect remote ctrl " <> inv) @@ -526,6 +541,7 @@ startRemoteDiscover :: TestCC -> TestCC -> IO () startRemoteDiscover mobile desktop = do desktop ##> "/start remote host 1 multicast=on" desktop <##. "remote host 1 started on " + desktop <##. "other addresses: " desktop <## "Remote session invitation:" _inv <- getTermLine desktop -- will use multicast instead mobile ##> "/find remote ctrl" From cf11d4587ef6a415dafd2668019cc168d86194d3 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 23 Dec 2025 14:42:00 +0000 Subject: [PATCH 41/48] core: 6.4.9.0 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c66c7b51ee..986562a5e9 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.4.8.0 +version: 6.4.9.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 113e5a032b8112670c056f2fa572213b01eec9e9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:44:52 +0000 Subject: [PATCH 42/48] ios: 6.4.9 (build 317) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 56 +++++++++++----------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 654eaf783c..a03b893eb0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.8.0-EN6xWtYjnbH1vySqqRtbFW.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.9.0-LCSYjdvysGrAMYKjn8SrSb.a */, ); path = Libraries; sourceTree = ""; @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2028,7 +2028,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2078,7 +2078,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2095,11 +2095,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2115,11 +2115,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2155,7 +2155,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2192,7 +2192,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2240,7 +2240,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2291,7 +2291,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2331,7 +2331,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 314; + CURRENT_PROJECT_VERSION = 317; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2365,7 +2365,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.8; + MARKETING_VERSION = 6.4.9; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 89b72effaf4ab68e3aaa3971f5f8f8f23cce8d10 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 23 Dec 2025 18:48:09 +0000 Subject: [PATCH 43/48] core: 6.5.0.7 (simplexmq 6.5.0.6) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cabal.project b/cabal.project index 38b1c15f03..e9842b1138 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 70d1b99fb4f55ab52060b40a85e16b43fb1717b1 + tag: 5f73d1e629a8807f1b9d94f8b411d6480a0a59fb source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 58a9dff6a1..3cfc2a05af 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."70d1b99fb4f55ab52060b40a85e16b43fb1717b1" = "18waigy1k29j4ijyx4nj4yq397nx49mdp7kvqh9ab9fzcwap588h"; + "https://github.com/simplex-chat/simplexmq.git"."5f73d1e629a8807f1b9d94f8b411d6480a0a59fb" = "1w5mxw9rwiiiqphbg2rdyp4cvv9hz2l64f7fpfhncw6gncfx7ggw"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ee3b2c551d..155fc0d4d2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.0.6 +version: 6.5.0.7 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat From 5141339fa377d0e66ba098081f6fb26735819532 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:42:06 +0000 Subject: [PATCH 44/48] ios: 6.5-beta.3 (build 318) --- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 +++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 88670f2513..fa9a4efdf7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.6-3CrJZmckwcAqjBkuX4uMa.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.7-CDRaHJn7uof5tglscSjQL5.a */, ); path = Libraries; sourceTree = ""; @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2095,7 +2095,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2115,7 +2115,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 316; + CURRENT_PROJECT_VERSION = 318; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; From 44227bcee8ffa90cafe6b0ad577d06115a12512f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Tue, 23 Dec 2025 21:36:56 +0000 Subject: [PATCH 45/48] 6.5-beta.3: android 331, desktop 128 --- apps/multiplatform/gradle.properties | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 1501cb43dc..38ef04daaa 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5-beta.2 -android.version_code=330 +android.version_name=6.5-beta.3 +android.version_code=331 android.bundle=false -desktop.version_name=6.5-beta.2 -desktop.version_code=127 +desktop.version_name=6.5-beta.3 +desktop.version_code=128 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 From fceb987906c19a0516510f2b0f91104a0174630b Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 24 Dec 2025 20:31:46 +0000 Subject: [PATCH 46/48] docs: verify and reproduce builds (#6515) * docs/REPRODUCIBLE: add new * docs/REPRODUCIBLE: clarify Android requirements * rename to REPRODUCE * expand and fix sections * website * docs, site: change links to simplex.apk to simplex-aarch64.apk --------- Co-authored-by: Evgeny Poberezkin --- README.md | 6 +- docs/DOWNLOADS.md | 6 +- docs/REPRODUCE.md | 203 ++++++++++++++++++ docs/TRANSLATIONS.md | 2 +- docs/WEBRTC.md | 3 +- docs/lang/cs/README.md | 6 +- docs/lang/fr/README.md | 6 +- docs/lang/pl/README.md | 10 +- website/langs/en.json | 1 + website/src/_data/docs_dropdown.json | 4 + website/src/_data/docs_sidebar.json | 1 + website/src/_includes/contact_page.html | 30 +-- website/src/_includes/hero.html | 2 +- website/src/_includes/navbar.html | 2 +- .../src/_includes/sections/join_simplex.html | 2 +- website/src/css/doc.css | 2 +- website/src/index.html | 2 +- 17 files changed, 250 insertions(+), 38 deletions(-) create mode 100644 docs/REPRODUCE.md diff --git a/README.md b/README.md index b1d2556072..f8abf8adfb 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Protects your messages and metadata - who you talk to and when. - 🔐 Double ratchet end-to-end encryption, with additional encryption layer. -- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! - 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. @@ -435,4 +435,4 @@ Graphic designs, artworks and layouts are not licensed for re-use. If you want t   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index a23a920bbb..b9889fe7a7 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -24,6 +24,8 @@ You can link your mobile device with desktop to use the same profile remotely, b - Ubuntu 22.04 and Debian-based distros ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-aarch64.deb)). - Ubuntu 24.04 ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-aarch64.deb)). +You can [verify and reproduce](./REPRODUCE.md) Linux builds. + **Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi). @@ -32,7 +34,9 @@ You can link your mobile device with desktop to use the same profile remotely, b **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-armv7a.apk). + +You can [verify and reproduce](./REPRODUCE.md) Android APKs. ## Terminal (console) app diff --git a/docs/REPRODUCE.md b/docs/REPRODUCE.md new file mode 100644 index 0000000000..03fb6b4336 --- /dev/null +++ b/docs/REPRODUCE.md @@ -0,0 +1,203 @@ +--- +title: Verify and reproduce builds +permalink: /reproduce/index.html +revision: 19.12.2025 +--- + +# Verifying and reproducing release builds + +- [Obtain release signing key](#obtain-release-signing-key) +- [Verify release signature](#verify-release-signature) +- [How to reproduce builds](#how-to-reproduce-builds) + - [Server binaries](#server-binaries) + - [Linux desktop apps and CLI](#linux-desktop-apps-and-cli) + - [Android apps](#android-apps) + +## Obtain release signing key + +To verify the signature of `_sha256sums` or apks you need to obtain the signing key. You can do it from keyservers: + +```sh +gpg --keyserver hkps://keys.openpgp.org --search build@simplex.chat +gpg --keyserver hkps://keyserver.ubuntu.com --search build@simplex.chat +``` + +```sh +gpg --list-keys build@simplex.chat +``` + +Once you obtain the signing key, verify that its fingerprint is: + +``` +BBDF 7BDA D154 8B16 836A F5B9 D53B DFD1 53C3 66BA +``` + +Additionally, compare the key fingerprint with: + +- [simplexchat.eth](https://app.ens.domains/simplexchat.eth) (release key record) +- [Mastodon](https://mastodon.social/@simplex) (profile) +- [Reddit](https://www.reddit.com/r/SimpleXChat/) (side panel) + +You can set the imported key as "ultimately trusted": + +```sh +echo -e "trust\n5\ny\nquit" | gpg --command-fd 0 --edit-key build@simplex.chat +``` + +## Verify release signature + +**Linux dekstop apps and CLI**: + +Download the file with executable hashes and the signature. For example, to verify the `v6.5.0-beta.3` release: + +```sh +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_sha256sums.asc' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_sha256sums' +``` + +Verify the signature: + +```sh +gpg --verify _sha256sums.asc _sha256sums +``` + +**Android APKs**: + +Download the APK files and signatures. For example, to verify the `v6.5.0-beta.3` release: + +```sh +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/simplex-aarch64.apk' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_simplex-aarch64.apk.asc' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/simplex-armv7a.apk' +curl -LO 'https://github.com/simplex-chat/simplex-chat/releases/download/v6.5.0-beta.3/_simplex-armv7a.apk.asc' +``` + +Verify the signatures: + +```sh +gpg --verify _simplex-armv7a.apk.asc simplex-armv7a.apk +gpg --verify _simplex-aarch64.apk.asc simplex-aarch64.apk +``` + +## How to reproduce builds + +To reproduce the build you must have: + +- Linux machine +- `x86-64` architecture +- Installed `docker`, `curl` and `git` + +### Server binaries + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplexmq/refs/heads/master/scripts/simplexmq-reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplexmq-reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplexmq-reproduce-builds.sh 'v6.3.1' + ``` + + The script executes these steps (please review the script to confirm): + + 1) builds all server binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + + This will take a while. + +4. After compilation, you should see the folder named as the tag and repository name (e.g., `v6.3.1-simplexmq`) with two subfolders: + + ```sh + ls v6.3.1-simplexmq + ``` + + ```sh + from-source prebuilt _sha256sums + ``` + + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. + +### Linux desktop apps and CLI + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplex-chat/refs/heads/master/scripts/simplex-chat-reproduce-builds.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplex-chat-reproduce-builds.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplex-chat-reproduce-builds.sh 'v6.4.8' + ``` + + The script executes these steps (please review the script to confirm): + + 1) builds all Linux CLI and Dekstop binaries for the release in docker container. + 2) downloads binaries from the same GitHub release and compares them with the built binaries. + 3) if they all match, generates _sha256sums file with their checksums. + + This will take a while. + +4. After compilation, you should see the folder named as the tag and reprository name (e.g., `v6.4.8-simplex-chat`) with two subfolders: + + ```sh + ls v6.4.8-simplex-chat + ``` + + ```sh + from-source prebuilt _sha256sums + ``` + + The file _sha256sums contains the hashes of all builds - you can compare it with the same file in GitHub release. + +### Android apps + +In addition to basic requirments, Android build will: + +- Take ~150gb of disc space +- Take ~20h to build all the architectures (depends on core count) +- Require at least 16gb of RAM + +1. Download script: + + ```sh + curl -LO 'https://raw.githubusercontent.com/simplex-chat/simplex-chat/refs/heads/master/scripts/simplex-chat-reproduce-builds-android.sh' + ``` + +2. Make it executable: + + ```sh + chmod +x simplex-chat-reproduce-builds-android.sh + ``` + +3. Execute the script with the required tag: + + ```sh + ./simplex-chat-reproduce-builds-android.sh 'v6.5.0-beta.3' + ``` + + The script executes these steps (please review the script to confirm): + + 1) Downloads and checks that APKs from GitHub are signed with valid key. + 2) Builds Android APKs in a docker container. + 3) Compares the releases by copying the signature from downloaded APKs to locally built APKs. + 4) If the resulting build is bit-by-bit identical, prints the message that this tag was reproduced. + + This will take a while. diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md index d5c1cdef0b..2b1febb6f2 100644 --- a/docs/TRANSLATIONS.md +++ b/docs/TRANSLATIONS.md @@ -1,5 +1,5 @@ --- -title: Contributing translations to SimpleX Chat +title: Contributing SimpleX app translations revision: 19.03.2023 --- diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index 8ce31bf959..a48cd12b00 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -1,5 +1,5 @@ --- -title: Using custom WebRTC ICE servers in SimpleX Chat +title: Using custom WebRTC ICE servers revision: 31.01.2023 --- @@ -155,4 +155,3 @@ This is it - you now can make audio and video calls via your own server, without If results show `srflx` and `relay` candidates, everything is set up correctly! - diff --git a/docs/lang/cs/README.md b/docs/lang/cs/README.md index 7eab61395e..d1ff8a0eca 100644 --- a/docs/lang/cs/README.md +++ b/docs/lang/cs/README.md @@ -18,11 +18,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Chrání vaše zprávy a metadata - s kým a kdy mluvíte. - 🔐 Koncové šifrování s další vrstvou šifrování. -- 📱 Mobilní aplikace pro Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) a [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Mobilní aplikace pro Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) a [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) s novými funkcemi o 1-2 týdny dříve - **omezeno na 10 000 uživatelů**! - 🖥 K dispozici jako terminálová (konzolová) [aplikace / CLI](#zap-quick-installation-of-a-terminal-app) v systémech Linux, MacOS, Windows. @@ -324,4 +324,4 @@ Jakákoli zjištění možných útoků korelace provozu umožňujících korelo   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/lang/fr/README.md b/docs/lang/fr/README.md index 69401da5a2..d6bf517446 100644 --- a/docs/lang/fr/README.md +++ b/docs/lang/fr/README.md @@ -32,11 +32,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Protégez vos messages et vos métadonnées - avec qui vous parlez et quand. - 🔐 Chiffrement de bout en bout à double ratchet, avec couche de chiffrement supplémentaire. -- 📱 Apps mobiles pour Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) et [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Apps mobiles pour Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) et [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [Bêta TestFlight pour iOS](https://testflight.apple.com/join/DWuT2LQu) avec les nouvelles fonctionnalités 1 à 2 semaines plus tôt - **limitée à 10 000 utilisateurs** ! - 🖥 Disponible en tant que [terminal (console) / CLI](#⚡-installation-rapide-dune-application-pour-terminal) sur Linux, MacOS, Windows. @@ -351,4 +351,4 @@ Veuillez traiter toute découverte d'une éventuelle attaque par corrélation de   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/docs/lang/pl/README.md b/docs/lang/pl/README.md index 23ca00c3e6..9f107dc825 100644 --- a/docs/lang/pl/README.md +++ b/docs/lang/pl/README.md @@ -32,11 +32,11 @@   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) - 🖲 Chroni Twoje wiadomości i metadane - z kim rozmawiasz i kiedy. - 🔐 Szyfrowanie end-to-end double ratchet, z dodatkową warstwą szyfrowania. -- 📱 Aplikacje mobilne dla Androida ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) oraz [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 📱 Aplikacje mobilne dla Androida ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk)) oraz [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - 🚀 [TestFlight dla iOS](https://testflight.apple.com/join/DWuT2LQu) z nowymi funkcjami na tydzień-dwa wcześniej - **limitowane do 10,000 użytkowników**! - 🖥 Dostępny jako terminalowa (konsolowa) [aplikacja / CLI](#zap-quick-installation-of-a-terminal-app) na Linuxa, MacOSa, Windowsa. @@ -198,9 +198,9 @@ Twórca SimpleX Chat. ## Dlaczego prywatność ma znaczenie -Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji - nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia. +Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji - nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia. -Jedną z najbardziej wstrząsających historii jest doświadczenie [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi). opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech. +Jedną z najbardziej wstrząsających historii jest doświadczenie [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi). opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech. Używanie szyfrowanego komunikatora end-to-end nie jest wystarczające. Powinniśmy używać komunikatorów, które zapewniają prywatność naszym powiązaniom, czyli tym z kim jesteśmy jakkolwiek połączeni. @@ -432,4 +432,4 @@ Prosimy o traktowanie wszelkich ustaleń dotyczących możliwych ataków korelac   [iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-aarch64.apk) diff --git a/website/langs/en.json b/website/langs/en.json index dc97d9ab66..eca5f5beb8 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -241,6 +241,7 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Security", "docs-dropdown-14": "SimpleX for business", + "docs-dropdown-15": "Verify & reproduce builds", "newer-version-of-eng-msg": "There is a newer version of this page in English.", "click-to-see": "Click to see", "menu": "Menu", diff --git a/website/src/_data/docs_dropdown.json b/website/src/_data/docs_dropdown.json index 94bc69f8f3..172145d86a 100644 --- a/website/src/_data/docs_dropdown.json +++ b/website/src/_data/docs_dropdown.json @@ -32,6 +32,10 @@ "title": "docs-dropdown-9", "url": "/downloads/" }, + { + "title": "docs-dropdown-15", + "url": "/reproduce/" + }, { "title": "docs-dropdown-10", "url": "/transparency/" diff --git a/website/src/_data/docs_sidebar.json b/website/src/_data/docs_sidebar.json index e370ccc078..f9b4d15b54 100644 --- a/website/src/_data/docs_sidebar.json +++ b/website/src/_data/docs_sidebar.json @@ -28,6 +28,7 @@ "WEBRTC.md", "XFTP-SERVER.md", "DOWNLOADS.md", + "REPRODUCE.md", "TRANSPARENCY.md", "SECURITY.md", "FAQ.md" diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index b5f7442a75..f628180bba 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -21,7 +21,7 @@ - + @@ -30,7 +30,7 @@
- + @@ -92,7 +92,7 @@ diff --git a/website/src/css/doc.css b/website/src/css/doc.css index 937268b166..20599f0afb 100644 --- a/website/src/css/doc.css +++ b/website/src/css/doc.css @@ -268,7 +268,7 @@ header { } .dark #doc main aside ul li a.active { - color: #70F0F9; + color: white; } #doc main aside p { diff --git a/website/src/index.html b/website/src/index.html index 443b4a3302..c044d5d17a 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -88,7 +88,7 @@ active_home: true - +
From 83cef2d52bb02aeaddff462f7c928a6103accc87 Mon Sep 17 00:00:00 2001 From: Oleksandr Kryvytskyi Date: Thu, 25 Dec 2025 14:53:52 +0200 Subject: [PATCH 47/48] docs: list available bot SDKs (#6516) --- bots/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bots/README.md b/bots/README.md index 9449e9d847..409f404dbd 100644 --- a/bots/README.md +++ b/bots/README.md @@ -192,6 +192,13 @@ It is usually simpler to run your bot process on the same machine where you run If you have to run your bot on another machine, you need to secure access to bot CLI via any web proxy that supports WebSockets, e.g. Caddy or Nginx. You must configure TLS termination in the proxy and connect CLI process from bot via a secure TLS connection. If you connect to bot via a public network, you also must configure HTTP basic auth to prevent unauthorized access. You can validate TLS security of your proxy via a free test at [SSLLabs.com](https://www.ssllabs.com/ssltest/). You can also configure firewall on the machine where you run SimpleX CLI to only allow connections from the IP address of your bot. +## Available libraries + +#### Libraries with full bot API support + +- [The official TypeScript SDK](https://www.npmjs.com/package/simplex-chat) +- [Unofficial Rust SDK](https://crates.io/crates/simploxide-client) + ## Useful bots - [Broadcast bot](../apps/simplex-broadcast-bot/) (Haskell) - we use it to send [status and release updates](https://status.simplex.chat/status/public). From 0c0342550c3296f6576fef78ab216792bc390e66 Mon Sep 17 00:00:00 2001 From: Alex <4998865+alexjohnyoung@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:54:41 +0000 Subject: [PATCH 48/48] docs: add Nodify to bots list (#6527) --- bots/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/bots/README.md b/bots/README.md index 409f404dbd..80c6689dce 100644 --- a/bots/README.md +++ b/bots/README.md @@ -204,3 +204,4 @@ If you have to run your bot on another machine, you need to secure access to bot - [Broadcast bot](../apps/simplex-broadcast-bot/) (Haskell) - we use it to send [status and release updates](https://status.simplex.chat/status/public). - [Moderation bot](https://github.com/NCalex42/simplex-bot) (Java) - [Matterbridge bot](https://github.com/UnkwUsr/matterbridge-simplex) (JavaScript) +- [Nodify](https://nodify.ie) (Low-Code) \ No newline at end of file