Fixed a QA issue, a couple edge-cases and cleaned up some logic

• Added the "expired group" banner for when the first poll of an updated group doesn't retrieve config messages
• Removed a redundant base64 encode/decode
• Removed messy extra message validation function
• Fixed an edge-case where a member granted supplemental access to the group could end up incorrectly kicked under the right conditions
• Fixed an issue where the copy for deleting a deprecated legacy group wasn't accurate
pull/894/head
Morgan Pretty 3 months ago
parent 3f3d4dde26
commit c99ee90ca6

@ -939,6 +939,8 @@
FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; };
FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; };
FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE125222A837E4E002DA685 /* MainAppContext.swift */; };
FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; };
FDE33BBE2D5C3AF100E56F42 /* _023_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */; };
FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; };
FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F82AB802BB00450C53 /* Message+Origin.swift */; };
FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; };
@ -2117,6 +2119,8 @@
FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = "<group>"; };
FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = "<group>"; };
FDE125222A837E4E002DA685 /* MainAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppContext.swift; sourceTree = "<group>"; };
FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = "<group>"; };
FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _023_GroupsExpiredFlag.swift; sourceTree = "<group>"; };
FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = "<group>"; };
FDE519F82AB802BB00450C53 /* Message+Origin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Origin.swift"; sourceTree = "<group>"; };
FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = "<group>"; };
@ -3731,6 +3735,7 @@
FD7443482D07CA9F00862443 /* CGSize+Utilities.swift */,
FD7443492D07CA9F00862443 /* Codable+Utilities.swift */,
FDC1BD692CFE7B67002CDC71 /* DirectoryArchiver.swift */,
FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */,
FD559DF42A7368CB00C7C62A /* DispatchQueue+Utilities.swift */,
FD09796A27F6C67500936362 /* Failable.swift */,
FD47E0AE2AA692F400A55E41 /* JSONDecoder+Utilities.swift */,
@ -3805,6 +3810,7 @@
FD3559452CC1FF140088F2A9 /* _020_AddMissingWhisperFlag.swift */,
FD4C53AE2CC1D61E003B10F4 /* _021_ReworkRecipientState.swift */,
FDB5DAC62A9447E7002C8721 /* _022_GroupsRebuildChanges.swift */,
FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -6027,6 +6033,7 @@
FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */,
94C58AC92D2E037200609195 /* Permissions.swift in Sources */,
FD09796B27F6C67500936362 /* Failable.swift in Sources */,
FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */,
FD7115FA28C8153400B47552 /* UIBarButtonItem+Combine.swift in Sources */,
FD705A92278D051200F16121 /* ReusableView.swift in Sources */,
FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */,
@ -6121,6 +6128,7 @@
FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */,
FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */,
FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */,
FDE33BBE2D5C3AF100E56F42 /* _023_GroupsExpiredFlag.swift in Sources */,
FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */,
C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */,
FD2272FD2C352D8E004D8A6C /* LibSession+ConvoInfoVolatile.swift in Sources */,
@ -7927,7 +7935,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
CURRENT_PROJECT_VERSION = 538;
CURRENT_PROJECT_VERSION = 539;
ENABLE_BITCODE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@ -8003,7 +8011,7 @@
CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
CURRENT_PROJECT_VERSION = 538;
CURRENT_PROJECT_VERSION = 539;
ENABLE_BITCODE = NO;
ENABLE_MODULE_VERIFIER = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;

@ -206,6 +206,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
let result: UIStackView = UIStackView(arrangedSubviews: [
outdatedClientBanner,
legacyGroupsBanner,
expiredGroupBanner,
emptyStatePaddingView,
emptyStateLabelContainer
])
@ -258,6 +259,27 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
return result
}()
lazy var expiredGroupBanner: InfoBanner = {
let result: InfoBanner = InfoBanner(
info: InfoBanner.Info(
font: .systemFont(ofSize: Values.miniFontSize),
message: "groupNotUpdatedWarning"
.localizedFormatted(baseFont: .systemFont(ofSize: Values.miniFontSize)),
icon: .none,
tintColor: .black,
backgroundColor: .explicitPrimary(.orange),
accessibility: Accessibility(label: "Expired group banner"),
height: nil
)
)
result.isHidden = (
viewModel.threadData.threadVariant != .group ||
viewModel.threadData.closedGroupExpired != true
)
return result
}()
private lazy var emptyStatePaddingView: UIView = {
let result: UIView = UIView()
result.set(.height, to: Values.largeSpacing)
@ -853,6 +875,17 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa
)
}
if
initialLoad ||
viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
viewModel.threadData.closedGroupExpired != updatedThreadData.closedGroupExpired
{
expiredGroupBanner.isHidden = (
updatedThreadData.threadVariant != .group ||
updatedThreadData.closedGroupExpired != true
)
}
if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount {
updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount)
}

@ -611,18 +611,23 @@ public extension UIContextualAction {
}
}
guard threadViewModel.currentUserIsClosedGroupAdmin == false else {
return "groupDeleteDescription"
.put(key: "group_name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: .boldSystemFont(ofSize: Values.smallFontSize))
}
let threadInfo: (SessionThread.Variant, Bool, Bool) = (
threadViewModel.threadVariant,
threadViewModel.currentUserIsClosedGroupAdmin == true,
dependencies[feature: .legacyGroupsDeprecated]
)
switch threadViewModel.threadVariant {
case .contact:
switch threadInfo {
case (.contact, _, _):
return "conversationsDeleteDescription"
.put(key: "name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: .boldSystemFont(ofSize: Values.smallFontSize))
case (.group, true, _), (.legacyGroup, true, false):
return "groupDeleteDescription"
.put(key: "group_name", value: threadViewModel.displayName)
.localizedFormatted(baseFont: .boldSystemFont(ofSize: Values.smallFontSize))
default:
return "groupDeleteDescriptionMember"
.put(key: "group_name", value: threadViewModel.displayName)

@ -39,7 +39,8 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API
_019_ScheduleAppUpdateCheckJob.self,
_020_AddMissingWhisperFlag.self,
_021_ReworkRecipientState.self,
_022_GroupsRebuildChanges.self
_022_GroupsRebuildChanges.self,
_023_GroupsExpiredFlag.self
]
]
)

@ -0,0 +1,37 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit.UIImage
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
enum _023_GroupsExpiredFlag: Migration {
static let target: TargetMigrations.Identifier = .messagingKit
static let identifier: String = "GroupsExpiredFlag"
static let minExpectedRunDuration: TimeInterval = 0.1
static var requirements: [MigrationRequirement] = [.sessionIdCached, .libSessionStateLoaded]
static var fetchedTables: [(FetchableRecord & TableRecord).Type] = []
static var createdOrAlteredTables: [(FetchableRecord & TableRecord).Type] = [ClosedGroup.self]
static let droppedTables: [(TableRecord & FetchableRecord).Type] = []
static func migrate(_ db: Database, using dependencies: Dependencies) throws {
try db.alter(table: ClosedGroup.self) { t in
t.add(.expired, .boolean).defaults(to: false)
}
Storage.update(progress: 1, for: self, in: target, using: dependencies)
}
struct OpenGroupImageInfo: FetchableRecord, Decodable, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case threadId
case data = "imageData"
}
let threadId: String
let data: Data
}
}

@ -34,6 +34,7 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable
case groupIdentityPrivateKey
case authData
case invited
case expired
}
public var id: String { threadId } // Identifiable
@ -73,6 +74,9 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable
/// A flag indicating whether this group is in the "invite" state
public let invited: Bool?
/// A flag indicating whether this group is in the "expired" state (ie. it's config messages no longer exist)
public let expired: Bool?
// MARK: - Relationships
public var thread: QueryInterfaceRequest<SessionThread> {
@ -121,7 +125,8 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable
shouldPoll: Bool?,
groupIdentityPrivateKey: Data? = nil,
authData: Data? = nil,
invited: Bool?
invited: Bool?,
expired: Bool? = false
) {
self.threadId = threadId
self.name = name
@ -135,6 +140,7 @@ public struct ClosedGroup: Codable, Equatable, Hashable, Identifiable, Fetchable
self.groupIdentityPrivateKey = groupIdentityPrivateKey
self.authData = authData
self.invited = invited
self.expired = expired
}
}

@ -33,6 +33,25 @@ internal extension LibSessionCacheType {
throw LibSessionError.invalidConfigObject
}
/// If the group had been flagged as "expired" (because it got no config messages when initially polling) then receiving a config
/// message means the group is no longer expired, so update it's state
let groupFlaggedAsExpired: Bool = (try? ClosedGroup
.filter(id: groupSessionId.hexString)
.select(.expired)
.asRequest(of: Bool.self)
.fetchOne(db))
.defaulting(to: false)
if groupFlaggedAsExpired {
try ClosedGroup
.filter(id: groupSessionId.hexString)
.updateAllAndConfig(
db,
ClosedGroup.Columns.expired.set(to: false),
using: dependencies
)
}
/// If two admins rekeyed for different member changes at the same time then there is a "key collision" and the "needs rekey" function
/// will return true to indicate that a 3rd `rekey` needs to be made to have a final set of keys which includes all members
///

@ -36,21 +36,13 @@ public final class VisibleMessage: Message {
public override func isValid(using dependencies: Dependencies) -> Bool {
guard super.isValid(using: dependencies) else { return false }
if !attachmentIds.isEmpty { return true }
if !attachmentIds.isEmpty || dataMessageHasAttachments == true { return true }
if openGroupInvitation != nil { return true }
if reaction != nil { return true }
if let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return true }
return false
}
public func isValidWithDataMessageAttachments(using dependencies: Dependencies) -> Bool {
// If the message is valid using the default method, or it has attachmentIds then just use the
// default logic, otherwise we want to check
guard !isValid(using: dependencies) || attachmentIds.isEmpty else { return isValid(using: dependencies) }
return (dataMessageHasAttachments == true)
}
// MARK: - Initialization
public init(

@ -590,16 +590,10 @@ extension MessageSender {
using: dependencies
)
/// We need to update the group keys when adding new members, this should be done by either supplementing the
/// current keys (which allows access to existing messages) or by doing a full `rekey` which means new messages
/// will be encrypted using new keys
///
/// **Note:** This **MUST** be called _after_ the new members have been added to the group, otherwise the
/// keys may not be generated correctly for the newly added members
/// If we want to grant access to historic messages then we need to generate a supplemental keys message,
/// since our state doesn't care about the `GROUP_KEYS` needed for other members triggering a `keySupplement`
/// change won't result in the `GROUP_KEYS` config changing so we need to push the change directly
if allowAccessToHistoricMessages {
/// Since our state doesn't care about the `GROUP_KEYS` needed for other members triggering a `keySupplement`
/// change won't result in the `GROUP_KEYS` config changing or the `ConfigurationSyncJob` getting triggered
/// we need to push the change directly
let supplementData: Data = try LibSession.keySupplement(
db,
groupSessionId: sessionId,
@ -623,13 +617,23 @@ extension MessageSender {
)
.map { _, _ in () }
}
else {
/// Since we have added new members we need to perform a `rekey` so that all new messages get
/// encrypted using new keys and the `GROUP_KEYS` `seqNo` is increased
///
/// **Note:** This **MUST** be called _after_ the new members have been added to the group, otherwise the
/// keys may not be generated correctly for the newly added members
///
/// **Note 2:** This **MUST** be done even when peforming a `keySupplement` because if the member
/// with supplemental access was kicked from the group during the current key rotation then the kicked message
/// would still be valid due to the `seqNo` and the member's device would consider the member kicked (we also
/// do this after doing the `keySupplement` as otherwise the new key would be needlessly included in the
/// `keySupplement` message)
try LibSession.rekey(
db,
groupSessionId: sessionId,
using: dependencies
)
}
/// Since we have added them to `GROUP_MEMBERS` we may as well insert them into the database (even if the request
/// fails the local state will have already been updated anyway)

@ -269,10 +269,7 @@ public enum MessageReceiver {
}
// Validate
guard
message.isValid(using: dependencies) ||
(message as? VisibleMessage)?.isValidWithDataMessageAttachments(using: dependencies) == true
else {
guard message.isValid(using: dependencies) else {
throw MessageReceiverError.invalidMessage
}

@ -171,7 +171,7 @@ public final class MessageSender {
MessageWrapper.wrap(
type: .closedGroupMessage,
timestampMs: sentTimestampMs,
base64EncodedContent: plaintext.base64EncodedString(),
content: plaintext,
wrapInWebSocketMessage: false
)
)
@ -221,7 +221,7 @@ public final class MessageSender {
default: return "" // Empty for all other cases
}
}(),
base64EncodedContent: ciphertext.base64EncodedString()
content: ciphertext
)
)
.mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) }

@ -239,6 +239,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType {
// MARK: - Polling
public func pollerDidStart() {}
/// Polls based on it's configuration and processes any messages, returning an array of messages that were
/// successfully processed
///

@ -37,6 +37,49 @@ public final class GroupPoller: SwarmPoller {
]
}
public override func pollerDidStart() {
guard
let sessionId: SessionId = try? SessionId(from: pollerDestination.target),
sessionId.prefix == .group
else { return }
let keysGeneration: Int = (try? LibSession
.currentGeneration(groupSessionId: sessionId, using: dependencies))
.defaulting(to: 0)
/// If the keys generation is greated than `0` then it means we have a valid config so shouldn't continue
guard keysGeneration == 0 else { return }
dependencies[singleton: .storage]
.readPublisher { [pollerDestination] db in
try ClosedGroup
.filter(id: pollerDestination.target)
.select(.expired)
.asRequest(of: Bool.self)
.fetchOne(db)
}
.filter { ($0 != true) }
.flatMap { [receivedPollResponse] _ in receivedPollResponse }
.first()
.map { $0.filter { $0.isConfigMessage } }
.filter { !$0.contains(where: { $0.namespace == SnodeAPI.Namespace.configGroupKeys }) }
.sinkUntilComplete(
receiveValue: { [pollerDestination, pollerName, dependencies] configMessages in
Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.")
dependencies[singleton: .storage].writeAsync { db in
try ClosedGroup
.filter(id: pollerDestination.target)
.updateAllAndConfig(
db,
ClosedGroup.Columns.expired.set(to: true),
using: dependencies
)
}
}
)
}
// MARK: - Abstract Methods
override public func nextPollDelay() -> TimeInterval {

@ -75,6 +75,7 @@ public protocol PollerType: AnyObject {
func startIfNeeded()
func stop()
func pollerDidStart()
func poll(forceSynchronousProcessing: Bool) -> AnyPublisher<PollResult, Error>
func nextPollDelay() -> TimeInterval
func handlePollError(_ error: Error, _ lastError: Error?) -> PollerErrorResponse
@ -96,6 +97,8 @@ public extension PollerType {
if self?.logStartAndStopCalls == true {
Log.info(.poller, "Started \(pollerName).")
}
self?.pollerDidStart()
}
}

@ -85,7 +85,9 @@ public class SwarmPoller: SwarmPollerType & PollerType {
_pollerDrainBehaviour.set(to: behaviour)
}
// MARK: - Private API
// MARK: - Polling
public func pollerDidStart() {}
/// Polls based on it's configuration and processes any messages, returning an array of messages that were
/// successfully processed

@ -58,6 +58,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
case closedGroupName
case closedGroupDescription
case closedGroupUserCount
case closedGroupExpired
case currentUserIsClosedGroupMember
case currentUserIsClosedGroupAdmin
case openGroupName
@ -160,6 +161,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public let closedGroupName: String?
private let closedGroupDescription: String?
private let closedGroupUserCount: Int?
public let closedGroupExpired: Bool?
public let currentUserIsClosedGroupMember: Bool?
public let currentUserIsClosedGroupAdmin: Bool?
public let openGroupName: String?
@ -466,6 +468,7 @@ public extension SessionThreadViewModel {
threadIsBlocked: Bool? = nil,
contactProfile: Profile? = nil,
closedGroupAdminProfile: Profile? = nil,
closedGroupExpired: Bool? = nil,
currentUserIsClosedGroupMember: Bool? = nil,
currentUserIsClosedGroupAdmin: Bool? = nil,
openGroupPermissions: OpenGroup.Permissions? = nil,
@ -515,6 +518,7 @@ public extension SessionThreadViewModel {
self.closedGroupName = nil
self.closedGroupDescription = nil
self.closedGroupUserCount = nil
self.closedGroupExpired = closedGroupExpired
self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember
self.currentUserIsClosedGroupAdmin = currentUserIsClosedGroupAdmin
self.openGroupName = nil
@ -589,6 +593,7 @@ public extension SessionThreadViewModel {
closedGroupName: self.closedGroupName,
closedGroupDescription: self.closedGroupDescription,
closedGroupUserCount: self.closedGroupUserCount,
closedGroupExpired: self.closedGroupExpired,
currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember,
currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin,
openGroupName: self.openGroupName,
@ -662,6 +667,7 @@ public extension SessionThreadViewModel {
closedGroupName: self.closedGroupName,
closedGroupDescription: self.closedGroupDescription,
closedGroupUserCount: self.closedGroupUserCount,
closedGroupExpired: self.closedGroupExpired,
currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember,
currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin,
openGroupName: self.openGroupName,
@ -795,7 +801,7 @@ public extension SessionThreadViewModel {
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 15
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined
let numColumnsBetweenProfilesAndAttachmentInfo: Int = 13 // The attachment info columns will be combined
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.rowId]) AS \(ViewModel.Columns.rowId),
@ -828,6 +834,7 @@ public extension SessionThreadViewModel {
\(closedGroupProfileBackFallback.allColumns),
\(closedGroupAdminProfile.allColumns),
\(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName),
\(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired),
EXISTS (
SELECT 1
@ -1153,6 +1160,7 @@ public extension SessionThreadViewModel {
\(contact[.lastKnownClientVersion]) AS \(ViewModel.Columns.contactLastKnownClientVersion),
\(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName),
\(closedGroupUserCount[.closedGroupUserCount]),
\(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired),
EXISTS (
SELECT 1
@ -1283,6 +1291,7 @@ public extension SessionThreadViewModel {
\(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName),
\(closedGroup[.groupDescription]) AS \(ViewModel.Columns.closedGroupDescription),
\(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired),
EXISTS (
SELECT 1
@ -2187,6 +2196,7 @@ public extension SessionThreadViewModel {
\(closedGroupProfileBackFallback.allColumns),
\(closedGroupAdminProfile.allColumns),
\(closedGroup[.name]) AS \(ViewModel.Columns.closedGroupName),
\(closedGroup[.expired]) AS \(ViewModel.Columns.closedGroupExpired),
EXISTS (
SELECT 1

@ -27,7 +27,7 @@ public enum MessageWrapper {
type: SNProtoEnvelope.SNProtoEnvelopeType,
timestampMs: UInt64,
senderPublicKey: String = "", // FIXME: Remove once legacy groups are deprecated
base64EncodedContent: String,
content: Data,
wrapInWebSocketMessage: Bool = true
) throws -> Data {
do {
@ -35,7 +35,7 @@ public enum MessageWrapper {
type: type,
timestamp: timestampMs,
senderPublicKey: senderPublicKey,
base64EncodedContent: base64EncodedContent
content: content
)
// If we don't want to wrap the message within the `WebSocketProtoWebSocketMessage` type
@ -50,16 +50,12 @@ public enum MessageWrapper {
}
}
private static func createEnvelope(type: SNProtoEnvelope.SNProtoEnvelopeType, timestamp: UInt64, senderPublicKey: String, base64EncodedContent: String) throws -> SNProtoEnvelope {
private static func createEnvelope(type: SNProtoEnvelope.SNProtoEnvelopeType, timestamp: UInt64, senderPublicKey: String, content: Data) throws -> SNProtoEnvelope {
do {
let builder = SNProtoEnvelope.builder(type: type, timestamp: timestamp)
builder.setSource(senderPublicKey)
builder.setSourceDevice(1)
if let content = Data(base64Encoded: base64EncodedContent, options: .ignoreUnknownCharacters) {
builder.setContent(content)
} else {
throw Error.failedToWrapMessageInEnvelope
}
return try builder.build()
} catch let error {
SNLog("Failed to wrap message in envelope: \(error).")

@ -757,14 +757,17 @@ open class Storage {
}
else if !info.isAsync, let semaphore: DispatchSemaphore = semaphore {
let pollQueue: DispatchQueue = DispatchQueue.global(qos: .userInitiated)
let standardPollInterval: DispatchTimeInterval = .milliseconds(100)
var iterations: Int = 0
let maxIterations: Int = 50
let maxIterations: Int = ((Storage.transactionDeadlockTimeoutSeconds * 1000) / standardPollInterval.milliseconds)
let pollCompletionSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
/// Stagger the size of the `pollIntervals` to avoid holding up the thread in case the query resolves very quickly
/// Stagger the size of the `pollIntervals` to avoid holding up the thread in case the query resolves very quickly (this
/// means the timeout will occur ~500ms early but helps prevent false main thread lag appearing when debugging that wouldn't
/// affect production)
let pollIntervals: [DispatchTimeInterval] = [
.milliseconds(5), .milliseconds(5), .milliseconds(10), .milliseconds(10), .milliseconds(10),
.milliseconds(100)
standardPollInterval
]
func pollSemaphore() {
@ -798,6 +801,10 @@ open class Storage {
completeOperation(with: .failure(StorageError.transactionDeadlockTimeout))
}
/// Before returning we need to wait for any pending updates on `syncQueue` to be completed to ensure that objects
/// don't get incorrectly released while they are still being used
syncQueue.sync { }
return finalResult
}

@ -0,0 +1,16 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
import Foundation
extension DispatchTimeInterval {
var milliseconds: Int {
switch self {
case .seconds(let s): return s * 1_000
case .milliseconds(let ms): return ms
case .microseconds(let us): return us / 1_000 // integer division truncates any remainder
case .nanoseconds(let ns): return ns / 1_000_000
case .never: return -1
@unknown default: return -1
}
}
}
Loading…
Cancel
Save