Fixed a number of bugs with the config handling

Added a number of feature flag checks to config updates
Added legacy group disappearing message timer handling
Updated the string linter to clean up the build logs a little
Split the initial config dump generation into it's own migration so it can run the launch after the feature flag is toggled
Fixed a few issues with the initial config dump creation
Fixed an issue where "shadow" conversations would be left in the database by opening a thread and never sending a message
Fixed a bug where duplicate members could be added to legacy groups
Fixed a bug with using animated images for the avatar
Fixed a bug where avatar images which were already on disk could be re-downloaded
This commit is contained in:
Morgan Pretty 2023-03-06 15:20:15 +11:00
parent 7ee84fe0d3
commit e28b4b4531
36 changed files with 1518 additions and 965 deletions

View File

@ -1,11 +1,6 @@
#!/usr/bin/xcrun --sdk macosx swift
//
// ListLocalizableStrings.swift
// Archa
//
// Created by Morgan Pretty on 18/5/20.
// Copyright © 2020 Archa. All rights reserved.
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
// is canges to the localized usage regex
@ -56,7 +51,6 @@ var executableFiles: [String] = {
/// - Parameter path: path of file
/// - Returns: content in file
func contents(atPath path: String) -> String {
print("Path: \(path)")
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
fatalError("Could not read from path: \(path)")
}
@ -109,8 +103,6 @@ func localizedStringsInCode() -> [LocalizationCodeFile] {
///
/// - Parameter files: list of localizable files to validate
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
print("------------ Validating keys match in all localizable files ------------")
guard let base = files.first, files.count > 1 else { return }
let files = Array(files.dropFirst())
@ -128,8 +120,6 @@ func validateMatchKeys(_ files: [LocalizationStringsFile]) {
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for missing keys -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
@ -150,8 +140,6 @@ func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles:
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for any dead keys in localizable file -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
@ -174,14 +162,18 @@ protocol Pathable {
struct LocalizationStringsFile: Pathable {
let path: String
let kv: [String: String]
let duplicates: [(key: String, path: String)]
var keys: [String] {
return Array(kv.keys)
}
init(path: String) {
let result = ContentParser.parse(path)
self.path = path
self.kv = ContentParser.parse(path)
self.kv = result.kv
self.duplicates = result.duplicates
}
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
@ -204,9 +196,7 @@ struct ContentParser {
///
/// - Parameter path: Localizable file paths
/// - Returns: localizable key and value for content at path
static func parse(_ path: String) -> [String: String] {
print("------------ Checking for duplicate keys: \(path) ------------")
static func parse(_ path: String) -> (kv: [String: String], duplicates: [(key: String, path: String)]) {
let content = contents(atPath: path)
let trimmed = content
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
@ -218,13 +208,18 @@ struct ContentParser {
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
}
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
if results[keyValue.0] != nil {
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
abort()
var duplicates: [(key: String, path: String)] = []
let kv: [String: String] = zip(keys, values)
.reduce(into: [:]) { results, keyValue in
guard results[keyValue.0] == nil else {
duplicates.append((keyValue.0, path))
return
}
results[keyValue.0] = keyValue.1
}
results[keyValue.0] = keyValue.1
}
return (kv, duplicates)
}
}
@ -232,20 +227,27 @@ func printPretty(_ string: String) {
print(string.replacingOccurrences(of: "\\", with: ""))
}
let stringFiles = create()
// MARK: - Processing
let stringFiles: [LocalizationStringsFile] = create()
if !stringFiles.isEmpty {
print("------------ Found \(stringFiles.count) file(s) ------------")
print("------------ Found \(stringFiles.count) file(s) - checking for duplicate, extra, missing and dead keys ------------")
stringFiles.forEach { file in
file.duplicates.forEach { key, path in
printPretty("error: Found duplicate key: \(key) in file: \(path)")
}
}
stringFiles.forEach { print($0.path) }
validateMatchKeys(stringFiles)
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
// stringFiles.forEach { $0.cleanWrite() }
let codeFiles = localizedStringsInCode()
let codeFiles: [LocalizationCodeFile] = localizedStringsInCode()
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
}
print("------------ SUCCESS ------------")
print("------------ Complete ------------")

View File

@ -708,6 +708,7 @@
FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; };
FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; };
FD77289E284EF1C50018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD77289D284EF1C50018502F /* Sodium+Utilities.swift */; };
FD778B6429B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift */; };
FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; };
FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; };
FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; };
@ -1835,6 +1836,7 @@
FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = "<group>"; };
FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = "<group>"; };
FD77289D284EF1C50018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = "<group>"; };
FD778B6329B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _013_GenerateInitialUserConfigDumps.swift; sourceTree = "<group>"; };
FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SessionUtilitiesKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIdSpec.swift; sourceTree = "<group>"; };
FD83B9BD27CF2243005E1583 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
@ -3629,6 +3631,7 @@
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */,
FD778B6329B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -5791,6 +5794,7 @@
FD09796E27FA6D0000936362 /* Contact.swift in Sources */,
C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */,
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */,
FD778B6429B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift in Sources */,
C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */,
FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */,
FD8ECF7F2934298100C0D1BB /* SharedConfigDump.swift in Sources */,

View File

@ -454,10 +454,12 @@ extension ConversationVC:
// Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
// Update the thread to be visible (if it isn't already)
if !thread.shouldBeVisible {
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
let authorId: String = {
if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey {
@ -585,10 +587,12 @@ extension ConversationVC:
// Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
// Update the thread to be visible (if it isn't already)
if !thread.shouldBeVisible {
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
// Create the interaction
let interaction: Interaction = try Interaction(
@ -1301,7 +1305,7 @@ extension ConversationVC:
.suffix(19))
.appending(sentTimestamp)
}
// TODO: Need to test emoji reacts for both open groups and one-to-one to make sure this isn't broken
// Perform the sending logic
Storage.shared
.writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in
@ -1311,10 +1315,12 @@ extension ConversationVC:
.eraseToAnyPublisher()
}
// Update the thread to be visible
_ = try SessionThread
.filter(id: thread.id)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
// Update the thread to be visible (if it isn't already)
if !thread.shouldBeVisible {
_ = try SessionThread
.filter(id: thread.id)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
let pendingReaction: Reaction? = {
if remove {

View File

@ -528,6 +528,25 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
mediaCache.removeAllObjects()
hasReloadedThreadDataAfterDisappearance = false
viewIsDisappearing = false
// If the user just created this thread but didn't send a message then we want to delete the
// "shadow" thread since it's not actually in use (this is to prevent it from taking up database
// space or unintentionally getting synced via libSession in the future)
let threadId: String = viewModel.threadData.threadId
if
viewModel.threadData.threadShouldBeVisible == false &&
!SessionUtil.conversationExistsInConfig(
threadId: threadId,
threadVariant: viewModel.threadData.threadVariant
)
{
Storage.shared.writeAsync { db in
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
}
}
}
@objc func applicationDidBecomeActive(_ notification: Notification) {

View File

@ -30,6 +30,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let config: DisappearingMessagesConfiguration
private var storedSelection: TimeInterval
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
@ -39,10 +40,12 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
init(
dependencies: Dependencies = Dependencies(),
threadId: String,
threadVariant: SessionThread.Variant,
config: DisappearingMessagesConfiguration
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.config = config
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
self.currentSelection = CurrentValueSubject(self.storedSelection)
@ -134,6 +137,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
private func saveChanges() {
let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let currentSelection: TimeInterval = self.currentSelection.value
let updatedConfig: DisappearingMessagesConfiguration = self.config
.with(
@ -175,6 +179,19 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
interactionId: interaction.id,
in: thread
)
// Legacy closed groups
switch threadVariant {
case .legacyGroup:
try SessionUtil
.update(
db,
groupPublicKey: threadId,
disappearingConfig: updatedConfig
)
default: break
}
}
}
}

View File

@ -445,6 +445,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadId,
threadVariant: threadVariant,
config: disappearingMessagesConfig
)
)

View File

@ -731,7 +731,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { [weak self] _ in
self?.viewModel.delete(
self?.viewModel.deleteOrLeave(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)

View File

@ -4,6 +4,7 @@ import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionMessagingKit
import SessionUtilitiesKit
public class HomeViewModel {
@ -365,10 +366,27 @@ public class HomeViewModel {
threadViewModel.markAsUnread()
}
public func delete(threadId: String, threadVariant: SessionThread.Variant) {
public func deleteOrLeave(threadId: String, threadVariant: SessionThread.Variant) {
Storage.shared.writeAsync { db in
switch threadVariant {
case .contact:
// We need to custom handle the 'Note to Self' conversation (it should just be
// hidden rather than deleted
guard threadId != getUserHexEncodedPublicKey(db) else {
_ = try Interaction
.filter(Interaction.Columns.threadId == threadId)
.deleteAll(db)
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(
db,
SessionThread.Columns.shouldBeVisible.set(to: false)
)
return
}
try SessionUtil
.hide(db, contactIds: [threadId])

View File

@ -38,10 +38,8 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati
// Check if the user selected an animated image (if so then don't crop, just
// set the avatar directly
guard
let type: Any = try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])
.allValues
.first,
let typeString: String = type as? String,
let type: URLResourceValues = try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey]),
let typeString: String = type.typeIdentifier,
MIMETypeUtil.supportedAnimatedImageUTITypes().contains(typeString)
else {
let viewController: CropScaleImageViewController = CropScaleImageViewController(

View File

@ -610,8 +610,6 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
DispatchQueue.main.async {
button.isUserInteractionEnabled = false
UIView.transition(
with: button,
duration: 0.25,

View File

@ -259,33 +259,33 @@ enum MockDataGenerator {
.saved(db)
members.forEach { memberId in
_ = try! GroupMember(
try! GroupMember(
groupId: randomGroupPublicKey,
profileId: memberId,
role: .standard,
isHidden: false
)
.saved(db)
.save(db)
}
[members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId].forEach { adminId in
_ = try! GroupMember(
try! GroupMember(
groupId: randomGroupPublicKey,
profileId: adminId,
role: .admin,
isHidden: false
)
.saved(db)
.save(db)
}
// Add the group to the user's set of public keys to poll for and store the key pair
let encryptionKeyPair = Curve25519.generateKeyPair()
_ = try! ClosedGroupKeyPair(
try! ClosedGroupKeyPair(
threadId: randomGroupPublicKey,
publicKey: encryptionKeyPair.publicKey,
secretKey: encryptionKeyPair.privateKey,
receivedTimestamp: timestampNow
)
.saved(db)
.save(db)
// Generate the message history (Note: Unapproved message requests will only include incoming messages)
logProgress("Closed Group Thread \(threadIndex)", "Generate \(numMessages) Messages")

View File

@ -28,8 +28,14 @@ public enum SNMessagingKit { // Just to make the external API nice
], // Add job priorities
[
_011_AddPendingReadReceipts.self,
_012_SharedUtilChanges.self
]
_012_SharedUtilChanges.self,
// Wait until the feature is turned on before doing the migration that generates
// the config dump data
(Features.useSharedUtilForUserConfig ?
_013_GenerateInitialUserConfigDumps.self :
(nil as Migration.Type?)
)
].compactMap { $0 }
]
)
}

View File

@ -1,12 +1,12 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
import GRDB
import SessionUtil
import SessionUtilitiesKit
/// This migration recreates the interaction FTS table and adds the threadId so we can do a performant in-conversation
/// searh (currently it's much slower than the global search)
/// This migration makes the neccessary changes to support the updated user config syncing system
enum _012_SharedUtilChanges: Migration {
static let target: TargetMigrations.Identifier = .messagingKit
static let identifier: String = "SharedUtilChanges"
@ -20,10 +20,123 @@ enum _012_SharedUtilChanges: Migration {
t.add(.pinnedPriority, .integer)
}
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys
// SQLite doesn't support adding a new primary key after creation so we need to create a new table with
// the setup we want, copy data from the old table over, drop the old table and rename the new table
struct TmpGroupMember: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
static var databaseTableName: String { "tmpGroupMember" }
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case groupId
case profileId
case role
case isHidden
}
public let groupId: String
public let profileId: String
public let role: GroupMember.Role
public let isHidden: Bool
}
try db.create(table: TmpGroupMember.self) { t in
// Note: Since we don't know whether this will be stored against a 'ClosedGroup' or
// an 'OpenGroup' we add the foreign key constraint against the thread itself (which
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
t.column(.groupId, .text)
.notNull()
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
t.column(.profileId, .text)
.notNull()
.indexed() // Quicker querying
t.column(.role, .integer).notNull()
t.column(.isHidden, .boolean)
.notNull()
.defaults(to: false)
t.primaryKey([.groupId, .profileId, .role])
}
// Retrieve the non-duplicate group member entries from the old table
let nonDuplicateGroupMembers: [TmpGroupMember] = try GroupMember
.select(.groupId, .profileId, .role, .isHidden)
.group(GroupMember.Columns.groupId, GroupMember.Columns.profileId, GroupMember.Columns.role)
.asRequest(of: TmpGroupMember.self)
.fetchAll(db)
// Insert into the new table, drop the old table and rename the new table to be the old one
try nonDuplicateGroupMembers.forEach { try $0.save(db) }
try db.drop(table: GroupMember.self)
try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName)
// SQLite doesn't support removing unique constraints so we need to create a new table with
// the setup we want, copy data from the old table over, drop the old table and rename the new table
struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible {
static var databaseTableName: String { "tmpClosedGroupKeyPair" }
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case threadId
case publicKey
case secretKey
case receivedTimestamp
case threadKeyPairHash
}
public let threadId: String
public let publicKey: Data
public let secretKey: Data
public let receivedTimestamp: TimeInterval
public let threadKeyPairHash: String
}
try db.alter(table: ClosedGroupKeyPair.self) { t in
t.add(.threadKeyPairHash, .text).defaults(to: "")
}
try db.create(table: TmpClosedGroupKeyPair.self) { t in
t.column(.threadId, .text)
.notNull()
.indexed() // Quicker querying
.references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted
t.column(.publicKey, .blob).notNull()
t.column(.secretKey, .blob).notNull()
t.column(.receivedTimestamp, .double)
.notNull()
.indexed() // Quicker querying
t.column(.threadKeyPairHash, .integer)
.notNull()
.unique()
.indexed()
}
// Insert into the new table, drop the old table and rename the new table to be the old one
try ClosedGroupKeyPair
.fetchAll(db)
.map { keyPair in
ClosedGroupKeyPair(
threadId: keyPair.threadId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey,
receivedTimestamp: keyPair.receivedTimestamp
)
}
.map { keyPair in
TmpClosedGroupKeyPair(
threadId: keyPair.threadId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey,
receivedTimestamp: keyPair.receivedTimestamp,
threadKeyPairHash: keyPair.threadKeyPairHash
)
}
.forEach { try? $0.insert(db) } // Ignore duplicate values
try db.drop(table: ClosedGroupKeyPair.self)
try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName)
// Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily
try db.createIndex(
on: ClosedGroupKeyPair.self,
columns: [.threadId, .publicKey, .secretKey]
columns: [.threadId, .threadKeyPairHash]
)
// New table for storing the latest config dump for each type
@ -50,170 +163,11 @@ enum _012_SharedUtilChanges: Migration {
// If we don't have an ed25519 key then no need to create cached dump data
let userPublicKey: String = getUserHexEncodedPublicKey(db)
guard let secretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey else {
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
return
}
// MARK: - Shared Data
let allThreads: [String: SessionThread] = try SessionThread
.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.id] = next }
// MARK: - UserProfile Config Dump
let userProfileConf: UnsafeMutablePointer<config_object>? = try SessionUtil.loadState(
for: .userProfile,
secretKey: secretKey,
cachedData: nil
)
try SessionUtil.update(
profile: Profile.fetchOrCreateCurrentUser(db),
in: userProfileConf
)
if config_needs_dump(userProfileConf) {
try SessionUtil
.createDump(
conf: userProfileConf,
for: .userProfile,
publicKey: userPublicKey
)?
.save(db)
}
// MARK: - Contact Config Dump
let contactsData: [ContactInfo] = try Contact
.filter(
Contact.Columns.isBlocked == true ||
allThreads.keys.contains(Contact.Columns.id)
)
.including(optional: Contact.profile)
.asRequest(of: ContactInfo.self)
.fetchAll(db)
let contactsConf: UnsafeMutablePointer<config_object>? = try SessionUtil.loadState(
for: .contacts,
secretKey: secretKey,
cachedData: nil
)
try SessionUtil.upsert(
contactData: contactsData
.map { data in
SessionUtil.SyncedContactInfo(
id: data.contact.id,
contact: data.contact,
profile: data.profile,
priority: Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0),
hidden: (allThreads[data.contact.id]?.shouldBeVisible == true)
)
},
in: contactsConf
)
if config_needs_dump(contactsConf) {
try SessionUtil
.createDump(
conf: contactsConf,
for: .contacts,
publicKey: userPublicKey
)?
.save(db)
}
// MARK: - ConvoInfoVolatile Config Dump
let volatileThreadInfo: [SessionUtil.VolatileThreadInfo] = SessionUtil.VolatileThreadInfo.fetchAll(db)
let convoInfoVolatileConf: UnsafeMutablePointer<config_object>? = try SessionUtil.loadState(
for: .convoInfoVolatile,
secretKey: secretKey,
cachedData: nil
)
try SessionUtil.upsert(
convoInfoVolatileChanges: volatileThreadInfo,
in: convoInfoVolatileConf
)
if config_needs_dump(convoInfoVolatileConf) {
try SessionUtil
.createDump(
conf: convoInfoVolatileConf,
for: .convoInfoVolatile,
publicKey: userPublicKey
)?
.save(db)
}
// MARK: - UserGroups Config Dump
let legacyGroupData: [SessionUtil.LegacyGroupInfo] = try SessionUtil.LegacyGroupInfo.fetchAll(db)
let communityData: [SessionUtil.OpenGroupUrlInfo] = try SessionUtil.OpenGroupUrlInfo.fetchAll(db)
let userGroupsConf: UnsafeMutablePointer<config_object>? = try SessionUtil.loadState(
for: .userGroups,
secretKey: secretKey,
cachedData: nil
)
try SessionUtil.upsert(
legacyGroups: legacyGroupData,
in: userGroupsConf
)
try SessionUtil.upsert(
communities: communityData
.map { SessionUtil.CommunityInfo(urlInfo: $0) },
in: userGroupsConf
)
if config_needs_dump(userGroupsConf) {
try SessionUtil
.createDump(
conf: userGroupsConf,
for: .userGroups,
publicKey: userPublicKey
)?
.save(db)
}
// MARK: - Threads
try SessionUtil
.updatingThreads(db, Array(allThreads.values))
// MARK: - Syncing
// Enqueue a config sync job to ensure the generated configs get synced
db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in
ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey)
}
// There was previously a bug which allowed users to fully delete the 'Note to Self'
// conversation but we don't want that, so create it again if it doesn't exists
try SessionThread
.fetchOrCreate(db, id: userPublicKey, variant: .contact, shouldBeVisible: false)
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}
// MARK: Fetchable Types
struct ContactInfo: FetchableRecord, Decodable, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case contact
case profile
}
let contact: Contact
let profile: Profile?
}
struct GroupInfo: FetchableRecord, Decodable, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case closedGroup
case disappearingMessagesConfiguration
case groupMembers
}
let closedGroup: ClosedGroup
let disappearingMessagesConfiguration: DisappearingMessagesConfiguration?
let groupMembers: [GroupMember]
}
}

View File

@ -0,0 +1,188 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtil
import SessionUtilitiesKit
/// This migration goes through the current state of the database and generates config dumps for the user config types
///
/// **Note:** This migration won't be run until the `useSharedUtilForUserConfig` feature flag is enabled
enum _013_GenerateInitialUserConfigDumps: Migration {
static let target: TargetMigrations.Identifier = .messagingKit
static let identifier: String = "GenerateInitialUserConfigDumps"
static let needsConfigSync: Bool = true
static let minExpectedRunDuration: TimeInterval = 0.1 // TODO: Need to test this
static func migrate(_ db: Database) throws {
// If we have no ed25519 key then there is no need to create cached dump data
guard let secretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey else { return }
// Load the initial config state if needed
let userPublicKey: String = getUserHexEncodedPublicKey(db)
SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey)
// Retrieve all threads (we are going to base the config dump data on the active
// threads rather than anything else in the database)
let allThreads: [String: SessionThread] = try SessionThread
.fetchAll(db)
.reduce(into: [:]) { result, next in result[next.id] = next }
// MARK: - UserProfile Config Dump
try SessionUtil
.config(for: .userProfile, publicKey: userPublicKey)
.mutate { conf in
try SessionUtil.update(
profile: Profile.fetchOrCreateCurrentUser(db),
in: conf
)
if config_needs_dump(conf) {
try SessionUtil
.createDump(
conf: conf,
for: .userProfile,
publicKey: userPublicKey
)?
.save(db)
}
}
// MARK: - Contact Config Dump
try SessionUtil
.config(for: .contacts, publicKey: userPublicKey)
.mutate { conf in
let contactsData: [ContactInfo] = try Contact
.filter(
Contact.Columns.isBlocked == true ||
allThreads.keys.contains(Contact.Columns.id)
)
.including(optional: Contact.profile)
.asRequest(of: ContactInfo.self)
.fetchAll(db)
try SessionUtil.upsert(
contactData: contactsData
.map { data in
SessionUtil.SyncedContactInfo(
id: data.contact.id,
contact: data.contact,
profile: data.profile,
hidden: (allThreads[data.contact.id]?.shouldBeVisible == true),
priority: Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0)
)
},
in: conf
)
if config_needs_dump(conf) {
try SessionUtil
.createDump(
conf: conf,
for: .contacts,
publicKey: userPublicKey
)?
.save(db)
}
}
// MARK: - ConvoInfoVolatile Config Dump
try SessionUtil
.config(for: .convoInfoVolatile, publicKey: userPublicKey)
.mutate { conf in
let volatileThreadInfo: [SessionUtil.VolatileThreadInfo] = SessionUtil.VolatileThreadInfo
.fetchAll(db, ids: Array(allThreads.keys))
try SessionUtil.upsert(
convoInfoVolatileChanges: volatileThreadInfo,
in: conf
)
if config_needs_dump(conf) {
try SessionUtil
.createDump(
conf: conf,
for: .convoInfoVolatile,
publicKey: userPublicKey
)?
.save(db)
}
}
// MARK: - UserGroups Config Dump
try SessionUtil
.config(for: .userGroups, publicKey: userPublicKey)
.mutate { conf in
let legacyGroupData: [SessionUtil.LegacyGroupInfo] = try SessionUtil.LegacyGroupInfo.fetchAll(db)
let communityData: [SessionUtil.OpenGroupUrlInfo] = try SessionUtil.OpenGroupUrlInfo
.fetchAll(db, ids: Array(allThreads.keys))
try SessionUtil.upsert(
legacyGroups: legacyGroupData,
in: conf
)
try SessionUtil.upsert(
communities: communityData
.map { urlInfo in
SessionUtil.CommunityInfo(
urlInfo: urlInfo,
priority: Int32(allThreads[urlInfo.threadId]?.pinnedPriority ?? 0)
)
},
in: conf
)
if config_needs_dump(conf) {
try SessionUtil
.createDump(
conf: conf,
for: .userGroups,
publicKey: userPublicKey
)?
.save(db)
}
}
// MARK: - Threads
try SessionUtil.updatingThreads(db, Array(allThreads.values))
// MARK: - Syncing
// Enqueue a config sync job to ensure the generated configs get synced
db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in
ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey)
}
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
}
struct ContactInfo: FetchableRecord, Decodable, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case contact
case profile
}
let contact: Contact
let profile: Profile?
}
struct GroupInfo: FetchableRecord, Decodable, ColumnExpressible {
typealias Columns = CodingKeys
enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case closedGroup
case disappearingMessagesConfiguration
case groupMembers
}
let closedGroup: ClosedGroup
let disappearingMessagesConfiguration: DisappearingMessagesConfiguration?
let groupMembers: [GroupMember]
}
}

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
import GRDB
import SessionUtilitiesKit
@ -18,12 +19,14 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab
case publicKey
case secretKey
case receivedTimestamp
case threadKeyPairHash
}
public let threadId: String
public let publicKey: Data
public let secretKey: Data
public let receivedTimestamp: TimeInterval
public let threadKeyPairHash: String
// MARK: - Relationships
@ -43,6 +46,12 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab
self.publicKey = publicKey
self.secretKey = secretKey
self.receivedTimestamp = receivedTimestamp
// This value has a unique constraint and is used for key de-duping so the formula
// shouldn't be modified unless all existing keys have their values updated
self.threadKeyPairHash = Insecure.MD5
.hash(data: threadId.bytes + publicKey.bytes + secretKey.bytes)
.hexString
}
}

View File

@ -1,4 +1,4 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
@ -27,7 +27,8 @@ internal extension SessionUtil {
String: (
contact: Contact,
profile: Profile,
isHiddenConversation: Bool
shouldBeVisible: Bool,
priority: Int32
)
]
@ -66,7 +67,8 @@ internal extension SessionUtil {
contactData[contactId] = (
contactResult,
profileResult,
contact.hidden
(contact.hidden == false),
contact.priority
)
contacts_iterator_advance(contactIterator)
}
@ -148,33 +150,40 @@ internal extension SessionUtil {
/// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the
/// associated contact conversation accordingly
let threadExists: Bool = try SessionThread.exists(db, id: contact.id)
let threadIsVisible: Bool = try SessionThread
.filter(id: contact.id)
.select(.shouldBeVisible)
.asRequest(of: Bool.self)
let threadInfo: PriorityVisibilityInfo? = try? SessionThread
.select(.id, .variant, .pinnedPriority, .shouldBeVisible)
.asRequest(of: PriorityVisibilityInfo.self)
.fetchOne(db)
.defaulting(to: false)
let threadExists: Bool = (threadInfo != nil)
let threadIsVisible: Bool = (threadInfo?.shouldBeVisible ?? false)
switch (data.isHiddenConversation, threadExists, threadIsVisible) {
case (true, true, _):
switch (data.shouldBeVisible, threadExists, threadIsVisible) {
case (false, true, _):
try SessionThread
.filter(id: contact.id)
.deleteAll(db)
case (false, false, _):
case (true, false, _):
try SessionThread(
id: contact.id,
variant: .contact,
shouldBeVisible: true
shouldBeVisible: true,
pinnedPriority: data.priority
).save(db)
case (false, true, false):
case (true, true, false):
let changes: [ConfigColumnAssignment] = [
SessionThread.Columns.shouldBeVisible.set(to: data.shouldBeVisible),
(threadInfo?.pinnedPriority == data.priority ? nil :
SessionThread.Columns.pinnedPriority.set(to: data.priority)
)
].compactMap { $0 }
try SessionThread
.filter(id: contact.id)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
SessionThread.Columns.shouldBeVisible.set(to: !data.isHiddenConversation)
changes
)
default: break
@ -253,26 +262,7 @@ internal extension SessionUtil {
}
}
// MARK: - External Outgoing Changes
public extension SessionUtil {
static func hide(_ db: Database, contactIds: [String]) throws {
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: getUserHexEncodedPublicKey(db)
) { conf in
// Mark the contacts as hidden
try SessionUtil.upsert(
contactData: contactIds
.map { SyncedContactInfo(id: $0, hidden: true) },
in: conf
)
}
}
}
// MARK: - Convenience
// MARK: - Outgoing Changes
internal extension SessionUtil {
static func updatingContacts<T>(_ db: Database, _ updated: [T]) throws -> [T] {
@ -285,48 +275,43 @@ internal extension SessionUtil {
// If we only updated the current user contact then no need to continue
guard !targetContacts.isEmpty else { return updated }
do {
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: userPublicKey
) { conf in
// When inserting new contacts (or contacts with invalid profile data) we want
// to add any valid profile information we have so identify if any of the updated
// contacts are new/invalid, and if so, fetch any profile data we have for them
let newContactIds: [String] = targetContacts
.compactMap { contactData -> String? in
var cContactId: [CChar] = contactData.id.cArray
var contact: contacts_contact = contacts_contact()
guard
contacts_get(conf, &contact, &cContactId),
String(libSessionVal: contact.name, nullIfEmpty: true) != nil
else { return contactData.id }
return nil
}
let newProfiles: [String: Profile] = try Profile
.fetchAll(db, ids: newContactIds)
.reduce(into: [:]) { result, next in result[next.id] = next }
// Upsert the updated contact data
try SessionUtil
.upsert(
contactData: targetContacts
.map { contact in
SyncedContactInfo(
id: contact.id,
contact: contact,
profile: newProfiles[contact.id]
)
},
in: conf
)
}
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: userPublicKey
) { conf in
// When inserting new contacts (or contacts with invalid profile data) we want
// to add any valid profile information we have so identify if any of the updated
// contacts are new/invalid, and if so, fetch any profile data we have for them
let newContactIds: [String] = targetContacts
.compactMap { contactData -> String? in
var cContactId: [CChar] = contactData.id.cArray
var contact: contacts_contact = contacts_contact()
guard
contacts_get(conf, &contact, &cContactId),
String(libSessionVal: contact.name, nullIfEmpty: true) != nil
else { return contactData.id }
return nil
}
let newProfiles: [String: Profile] = try Profile
.fetchAll(db, ids: newContactIds)
.reduce(into: [:]) { result, next in result[next.id] = next }
// Upsert the updated contact data
try SessionUtil
.upsert(
contactData: targetContacts
.map { contact in
SyncedContactInfo(
id: contact.id,
contact: contact,
profile: newProfiles[contact.id]
)
},
in: conf
)
}
return updated
@ -357,42 +342,75 @@ internal extension SessionUtil {
existingContactIds.contains($0.id)
}
do {
// Update the user profile first (if needed)
if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {
try SessionUtil.performAndPushChange(
db,
for: .userProfile,
publicKey: userPublicKey
) { conf in
try SessionUtil.update(
profile: updatedUserProfile,
in: conf
)
}
}
// Update the user profile first (if needed)
if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {
try SessionUtil.performAndPushChange(
db,
for: .contacts,
for: .userProfile,
publicKey: userPublicKey
) { conf in
try SessionUtil
.upsert(
contactData: targetProfiles
.map { SyncedContactInfo(id: $0.id, profile: $0) },
in: conf
)
try SessionUtil.update(
profile: updatedUserProfile,
in: conf
)
}
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: userPublicKey
) { conf in
try SessionUtil
.upsert(
contactData: targetProfiles
.map { SyncedContactInfo(id: $0.id, profile: $0) },
in: conf
)
}
return updated
}
}
// MARK: - External Outgoing Changes
public extension SessionUtil {
static func hide(_ db: Database, contactIds: [String]) throws {
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: getUserHexEncodedPublicKey(db)
) { conf in
// Mark the contacts as hidden
try SessionUtil.upsert(
contactData: contactIds
.map { SyncedContactInfo(id: $0, hidden: true) },
in: conf
)
}
}
static func remove(_ db: Database, contactIds: [String]) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
guard !contactIds.isEmpty else { return }
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: getUserHexEncodedPublicKey(db)
) { conf in
contactIds.forEach { sessionId in
var cSessionId: [CChar] = sessionId.cArray
// Don't care if the contact doesn't exist
contacts_erase(conf, &cSessionId)
}
}
}
}
// MARK: - SyncedContactInfo
extension SessionUtil {
@ -400,21 +418,21 @@ extension SessionUtil {
let id: String
let contact: Contact?
let profile: Profile?
let priority: Int32?
let hidden: Bool?
let priority: Int32?
init(
id: String,
contact: Contact? = nil,
profile: Profile? = nil,
priority: Int32? = nil,
hidden: Bool? = nil
hidden: Bool? = nil,
priority: Int32? = nil
) {
self.id = id
self.contact = contact
self.profile = profile
self.priority = priority
self.hidden = hidden
self.priority = priority
}
}
}

View File

@ -158,16 +158,10 @@ internal extension SessionUtil {
// If there are no newer local last read timestamps then just return the mergeResult
guard !newerLocalChanges.isEmpty else { return }
try SessionUtil.performAndPushChange(
db,
for: .convoInfoVolatile,
publicKey: getUserHexEncodedPublicKey(db)
) { conf in
try upsert(
convoInfoVolatileChanges: newerLocalChanges,
in: conf
)
}
try upsert(
convoInfoVolatileChanges: newerLocalChanges,
in: conf
)
}
static func upsert(
@ -176,7 +170,21 @@ internal extension SessionUtil {
) throws {
guard conf != nil else { throw SessionUtilError.nilConfigObject }
convoInfoVolatileChanges.forEach { threadInfo in
// Exclude any invalid thread info
let validChanges: [VolatileThreadInfo] = convoInfoVolatileChanges
.filter { info in
switch info.variant {
case .contact:
// FIXME: libSession V1 doesn't sync volatileThreadInfo for blinded message requests
guard SessionId(from: info.threadId)?.prefix == .standard else { return false }
return true
default: return true
}
}
validChanges.forEach { threadInfo in
var cThreadId: [CChar] = threadInfo.threadId.cArray
switch threadInfo.variant {
@ -250,11 +258,7 @@ internal extension SessionUtil {
}
}
}
}
// MARK: - Convenience
internal extension SessionUtil {
static func updateMarkedAsUnreadState(
_ db: Database,
threads: [SessionThread]
@ -284,13 +288,20 @@ internal extension SessionUtil {
)
}
}
}
// MARK: - External Outgoing Changes
public extension SessionUtil {
static func syncThreadLastReadIfNeeded(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
lastReadTimestampMs: Int64
) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
let change: VolatileThreadInfo = VolatileThreadInfo(
threadId: threadId,
variant: threadVariant,

View File

@ -20,6 +20,7 @@ internal extension SessionUtil {
.appending(contentsOf: columnsRelatedToContacts)
.appending(contentsOf: columnsRelatedToConvoInfoVolatile)
.appending(contentsOf: columnsRelatedToUserGroups)
.appending(contentsOf: columnsRelatedToThreads)
.map { ColumnKey($0) }
.asSet()
@ -34,28 +35,36 @@ internal extension SessionUtil {
) throws {
// Since we are doing direct memory manipulation we are using an `Atomic`
// type which has blocking access in it's `mutate` closure
let needsPush: Bool = try SessionUtil
.config(
for: variant,
publicKey: publicKey
)
.mutate { conf in
guard conf != nil else { throw SessionUtilError.nilConfigObject }
// Peform the change
try change(conf)
// If we don't need to dump the data the we can finish early
guard config_needs_dump(conf) else { return config_needs_push(conf) }
try SessionUtil.createDump(
conf: conf,
let needsPush: Bool
do {
needsPush = try SessionUtil
.config(
for: variant,
publicKey: publicKey
)?.save(db)
return config_needs_push(conf)
}
)
.mutate { conf in
guard conf != nil else { throw SessionUtilError.nilConfigObject }
// Peform the change
try change(conf)
// If we don't need to dump the data the we can finish early
guard config_needs_dump(conf) else { return config_needs_push(conf) }
try SessionUtil.createDump(
conf: conf,
for: variant,
publicKey: publicKey
)?.save(db)
return config_needs_push(conf)
}
}
catch {
SNLog("[libSession] Failed to update/dump updated \(variant) config data")
throw error
}
// Make sure we need a push before scheduling one
guard needsPush else { return }
@ -80,115 +89,164 @@ internal extension SessionUtil {
.fetchAll(db, ids: updatedThreads.map { $0.id })
.reduce(into: [:]) { result, next in result[next.threadId] = next }
do {
// Update the unread state for the threads first (just in case that's what changed)
try SessionUtil.updateMarkedAsUnreadState(db, threads: updatedThreads)
// Then update the `hidden` and `priority` values
try groupedThreads.forEach { variant, threads in
switch variant {
case .contact:
// If the 'Note to Self' conversation is pinned then we need to custom handle it
// first as it's part of the UserProfile config
if let noteToSelf: SessionThread = threads.first(where: { $0.id == userPublicKey }) {
try SessionUtil.performAndPushChange(
db,
for: .userProfile,
publicKey: userPublicKey
) { conf in
try SessionUtil.updateNoteToSelf(
db,
priority: noteToSelf.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0),
hidden: noteToSelf.shouldBeVisible,
in: conf
)
}
}
// Remove the 'Note to Self' convo from the list for updating contact priorities
let remainingThreads: [SessionThread] = threads.filter { $0.id != userPublicKey }
guard !remainingThreads.isEmpty else { return }
// Update the unread state for the threads first (just in case that's what changed)
try SessionUtil.updateMarkedAsUnreadState(db, threads: updatedThreads)
// Then update the `hidden` and `priority` values
try groupedThreads.forEach { variant, threads in
switch variant {
case .contact:
// If the 'Note to Self' conversation is pinned then we need to custom handle it
// first as it's part of the UserProfile config
if let noteToSelf: SessionThread = threads.first(where: { $0.id == userPublicKey }) {
try SessionUtil.performAndPushChange(
db,
for: .contacts,
for: .userProfile,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
contactData: remainingThreads
.map { thread in
SyncedContactInfo(
id: thread.id,
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0),
hidden: thread.shouldBeVisible
)
},
try SessionUtil.updateNoteToSelf(
hidden: !noteToSelf.shouldBeVisible,
priority: noteToSelf.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0),
in: conf
)
}
case .community:
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
communities: threads
.compactMap { thread -> CommunityInfo? in
urlInfo[thread.id].map { urlInfo in
CommunityInfo(
urlInfo: urlInfo,
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0)
)
}
},
in: conf
)
}
case .legacyGroup:
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
legacyGroups: threads
.map { thread in
LegacyGroupInfo(
id: thread.id,
hidden: thread.shouldBeVisible,
}
// Remove the 'Note to Self' convo from the list for updating contact priorities
let remainingThreads: [SessionThread] = threads.filter { $0.id != userPublicKey }
guard !remainingThreads.isEmpty else { return }
try SessionUtil.performAndPushChange(
db,
for: .contacts,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
contactData: remainingThreads
.map { thread in
SyncedContactInfo(
id: thread.id,
hidden: !thread.shouldBeVisible,
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0)
)
},
in: conf
)
}
case .community:
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
communities: threads
.compactMap { thread -> CommunityInfo? in
urlInfo[thread.id].map { urlInfo in
CommunityInfo(
urlInfo: urlInfo,
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0)
)
},
in: conf
)
}
}
},
in: conf
)
}
case .group:
// TODO: Add this
break
}
case .legacyGroup:
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
publicKey: userPublicKey
) { conf in
try SessionUtil.upsert(
legacyGroups: threads
.map { thread in
LegacyGroupInfo(
id: thread.id,
hidden: !thread.shouldBeVisible,
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0)
)
},
in: conf
)
}
case .group:
break
}
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
}
return updated
}
}
// MARK: - External Outgoing Changes
public extension SessionUtil {
static func conversationExistsInConfig(
threadId: String,
threadVariant: SessionThread.Variant
) -> Bool {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return true }
let configVariant: ConfigDump.Variant = {
switch threadVariant {
case .contact: return .contacts
case .legacyGroup, .group, .community: return .userGroups
}
}()
return SessionUtil
.config(for: configVariant, publicKey: getUserHexEncodedPublicKey())
.wrappedValue
.map { conf in
var cThreadId: [CChar] = threadId.cArray
switch threadVariant {
case .contact: return contacts_get(conf, nil, &cThreadId)
case .community:
let maybeUrlInfo: OpenGroupUrlInfo? = Storage.shared
.read { db in try OpenGroupUrlInfo.fetchAll(db, ids: [threadId]) }?
.first
guard let urlInfo: OpenGroupUrlInfo = maybeUrlInfo else { return false }
var cBaseUrl: [CChar] = urlInfo.server.cArray
var cRoom: [CChar] = urlInfo.roomToken.cArray
return user_groups_get_community(conf, nil, &cBaseUrl, &cRoom)
case .legacyGroup:
let groupInfo: UnsafeMutablePointer<ugroups_legacy_group_info>? = user_groups_get_legacy_group(conf, &cThreadId)
if groupInfo != nil {
ugroups_legacy_group_free(groupInfo)
return true
}
return false
case .group:
return false
}
}
.defaulting(to: false)
}
}
// MARK: - ColumnKey
internal extension SessionUtil {

View File

@ -25,7 +25,6 @@ internal extension SessionUtil {
var communities: [PrioritisedData<OpenGroupUrlInfo>] = []
var legacyGroups: [LegacyGroupInfo] = []
var groups: [PrioritisedData<String>] = []
var community: ugroups_community_info = ugroups_community_info()
var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info()
let groupsIterator: OpaquePointer = user_groups_iterator_new(conf)
@ -85,18 +84,28 @@ internal extension SessionUtil {
.defaultWith(groupId)
.with(
isEnabled: (legacyGroup.disappearing_timer > 0),
durationSeconds: (legacyGroup.disappearing_timer == 0 ? nil :
TimeInterval(legacyGroup.disappearing_timer)
)
durationSeconds: TimeInterval(legacyGroup.disappearing_timer)
),
groupMembers: members.map { memberId, admin in
GroupMember(
groupId: groupId,
profileId: memberId,
role: (admin ? .admin : .standard),
isHidden: false
)
},
groupMembers: members
.filter { _, isAdmin in !isAdmin }
.map { memberId, admin in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .standard,
isHidden: false
)
},
groupAdmins: members
.filter { _, isAdmin in isAdmin }
.map { memberId, admin in
GroupMember(
groupId: groupId,
profileId: memberId,
role: .admin,
isHidden: false
)
},
hidden: legacyGroup.hidden,
priority: legacyGroup.priority
)
@ -111,7 +120,7 @@ internal extension SessionUtil {
user_groups_iterator_free(groupsIterator) // Need to free the iterator
// If we don't have any conversations then no need to continue
guard !communities.isEmpty || !legacyGroups.isEmpty || !groups.isEmpty else { return }
guard !communities.isEmpty || !legacyGroups.isEmpty else { return }
// Extract all community/legacyGroup/group thread priorities
let existingThreadInfo: [String: PriorityVisibilityInfo] = (try? SessionThread
@ -187,7 +196,8 @@ internal extension SessionUtil {
guard
let name: String = group.name,
let lastKeyPair: ClosedGroupKeyPair = group.lastKeyPair,
let members: [GroupMember] = group.groupMembers
let members: [GroupMember] = group.groupMembers,
let updatedAdmins: [GroupMember] = group.groupAdmins
else { return }
if !existingLegacyGroupIds.contains(group.id) {
@ -201,13 +211,12 @@ internal extension SessionUtil {
secretKey: lastKeyPair.secretKey.bytes
),
members: members
.filter { $0.role == .standard }
.map { $0.profileId },
admins: members
.filter { $0.role == .admin }
.appending(contentsOf: updatedAdmins) // Admins should also have 'standard' member entries
.map { $0.profileId },
admins: updatedAdmins.map { $0.profileId },
expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0),
messageSentTimestamp: UInt64(latestConfigUpdateSentTimestamp * 1000)
messageSentTimestamp: UInt64(latestConfigUpdateSentTimestamp * 1000),
calledFromConfigHandling: true
)
}
else {
@ -223,11 +232,7 @@ internal extension SessionUtil {
// Add the lastKey if it doesn't already exist
let keyPairExists: Bool = ClosedGroupKeyPair
.filter(
ClosedGroupKeyPair.Columns.threadId == lastKeyPair.threadId &&
ClosedGroupKeyPair.Columns.publicKey == lastKeyPair.publicKey &&
ClosedGroupKeyPair.Columns.secretKey == lastKeyPair.secretKey
)
.filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == lastKeyPair.threadKeyPairHash)
.isNotEmpty(db)
if !keyPairExists {
@ -245,27 +250,70 @@ internal extension SessionUtil {
.saved(db)
// Update the members
// TODO: This
// TODO: Going to need to decide whether we want to update the 'GroupMember' records in the database based on this config message changing
// let members: [String]
// let admins: [String]
let updatedMembers: [GroupMember] = members
.appending(
contentsOf: updatedAdmins.map { admin in
GroupMember(
groupId: admin.groupId,
profileId: admin.profileId,
role: .standard,
isHidden: false
)
}
)
if
let existingMembers: [GroupMember] = existingLegacyGroupMembers[group.id]?
.filter({ $0.role == .standard || $0.role == .zombie }),
existingMembers != updatedMembers
{
// Add in any new members and remove any removed members
try updatedMembers.forEach { try $0.save(db) }
try existingMembers
.filter { !updatedMembers.contains($0) }
.forEach { member in
try GroupMember
.filter(
GroupMember.Columns.groupId == group.id &&
GroupMember.Columns.profileId == member.profileId && (
GroupMember.Columns.role == GroupMember.Role.standard ||
GroupMember.Columns.role == GroupMember.Role.zombie
)
)
.deleteAll(db)
}
}
if
let existingAdmins: [GroupMember] = existingLegacyGroupMembers[group.id]?
.filter({ $0.role == .admin }),
existingAdmins != updatedAdmins
{
// Add in any new admins and remove any removed admins
try updatedAdmins.forEach { try $0.save(db) }
try existingAdmins
.filter { !updatedAdmins.contains($0) }
.forEach { member in
try GroupMember
.filter(
GroupMember.Columns.groupId == group.id &&
GroupMember.Columns.profileId == member.profileId &&
GroupMember.Columns.role == GroupMember.Role.admin
)
.deleteAll(db)
}
}
}
// Make any thread-specific changes
var threadChanges: [ConfigColumnAssignment] = []
// Set the visibility if it's changed
if existingThreadInfo[group.id]?.shouldBeVisible != (group.hidden == false) {
threadChanges.append(
// Make any thread-specific changes if needed
let threadChanges: [ConfigColumnAssignment] = [
(existingThreadInfo[group.id]?.shouldBeVisible == (group.hidden == false) ? nil :
SessionThread.Columns.shouldBeVisible.set(to: (group.hidden == false))
)
}
// Set the priority if it's changed
if existingThreadInfo[group.id]?.pinnedPriority != group.priority {
threadChanges.append(
),
(existingThreadInfo[group.id]?.pinnedPriority == group.priority ? nil :
SessionThread.Columns.pinnedPriority.set(to: group.priority)
)
}
].compactMap { $0 }
if !threadChanges.isEmpty {
_ = try? SessionThread
@ -321,6 +369,7 @@ internal extension SessionUtil {
if let lastKeyPair: ClosedGroupKeyPair = legacyGroup.lastKeyPair {
userGroup.pointee.enc_pubkey = lastKeyPair.publicKey.toLibSession()
userGroup.pointee.enc_seckey = lastKeyPair.secretKey.toLibSession()
userGroup.pointee.have_enc_keys = true
// Store the updated group (needs to happen before variables go out of scope)
user_groups_set_legacy_group(conf, userGroup)
@ -328,7 +377,6 @@ internal extension SessionUtil {
// Assign all properties to match the updated disappearing messages config (if there is one)
if let updatedConfig: DisappearingMessagesConfiguration = legacyGroup.disappearingConfig {
// TODO: double check the 'isEnabled' flag
userGroup.pointee.disappearing_timer = (!updatedConfig.isEnabled ? 0 :
Int64(floor(updatedConfig.durationSeconds))
)
@ -393,6 +441,9 @@ public extension SessionUtil {
rootToken: String,
publicKey: String
) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -415,6 +466,9 @@ public extension SessionUtil {
}
static func remove(_ db: Database, server: String, roomToken: String) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -437,9 +491,13 @@ public extension SessionUtil {
latestKeyPairPublicKey: Data,
latestKeyPairSecretKey: Data,
latestKeyPairReceivedTimestamp: TimeInterval,
disappearingConfig: DisappearingMessagesConfiguration,
members: Set<String>,
admins: Set<String>
) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -456,6 +514,7 @@ public extension SessionUtil {
secretKey: latestKeyPairSecretKey,
receivedTimestamp: latestKeyPairReceivedTimestamp
),
disappearingConfig: disappearingConfig,
groupMembers: members
.map { memberId in
GroupMember(
@ -486,9 +545,13 @@ public extension SessionUtil {
groupPublicKey: String,
name: String? = nil,
latestKeyPair: ClosedGroupKeyPair? = nil,
disappearingConfig: DisappearingMessagesConfiguration? = nil,
members: Set<String>? = nil,
admins: Set<String>? = nil
) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -500,6 +563,7 @@ public extension SessionUtil {
id: groupPublicKey,
name: name,
lastKeyPair: latestKeyPair,
disappearingConfig: disappearingConfig,
groupMembers: members?
.map { memberId in
GroupMember(
@ -526,6 +590,8 @@ public extension SessionUtil {
}
static func hide(_ db: Database, legacyGroupIds: [String]) throws {
guard !legacyGroupIds.isEmpty else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -544,6 +610,10 @@ public extension SessionUtil {
}
static func remove(_ db: Database, legacyGroupIds: [String]) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
guard !legacyGroupIds.isEmpty else { return }
try SessionUtil.performAndPushChange(
db,
for: .userGroups,
@ -561,9 +631,13 @@ public extension SessionUtil {
// MARK: -- Group Changes
static func hide(_ db: Database, groupIds: [String]) throws {
guard !groupIds.isEmpty else { return }
}
static func remove(_ db: Database, groupIds: [String]) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
guard !groupIds.isEmpty else { return }
}
}

View File

@ -1,4 +1,4 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
@ -44,7 +44,7 @@ internal extension SessionUtil {
return .updateTo(
url: profilePictureUrl,
key: Data(
libSessionVal: profilePic.url,
libSessionVal: profilePic.key,
count: ProfileManager.avatarAES256KeyByteLength
),
fileName: nil
@ -54,6 +54,52 @@ internal extension SessionUtil {
calledFromConfigHandling: true
)
// Update the 'Note to Self' visibility and priority
let threadInfo: PriorityVisibilityInfo? = try? SessionThread
.filter(id: userPublicKey)
.select(.id, .variant, .pinnedPriority, .shouldBeVisible)
.asRequest(of: PriorityVisibilityInfo.self)
.fetchOne(db)
let targetPriority: Int32 = user_profile_get_nts_priority(conf)
let targetHiddenState: Bool = user_profile_get_nts_hidden(conf)
// Create the 'Note to Self' thread if it doesn't exist
if let threadInfo: PriorityVisibilityInfo = threadInfo {
let threadChanges: [ConfigColumnAssignment] = [
(threadInfo.shouldBeVisible == (targetHiddenState == false) ? nil :
SessionThread.Columns.shouldBeVisible.set(to: (targetHiddenState == false))
),
(threadInfo.pinnedPriority == targetPriority ? nil :
SessionThread.Columns.pinnedPriority.set(to: targetPriority)
)
].compactMap { $0 }
if !threadChanges.isEmpty {
try SessionThread
.filter(id: userPublicKey)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
threadChanges
)
}
}
else {
try SessionThread
.fetchOrCreate(
db,
id: userPublicKey,
variant: .contact,
shouldBeVisible: (targetHiddenState == false)
)
try SessionThread
.filter(id: userPublicKey)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
SessionThread.Columns.pinnedPriority.set(to: targetPriority)
)
}
// Create a contact for the current user if needed (also force-approve the current user
// in case the account got into a weird state or restored directly from a migration)
let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey)
@ -91,14 +137,18 @@ internal extension SessionUtil {
}
static func updateNoteToSelf(
_ db: Database,
priority: Int32,
hidden: Bool,
hidden: Bool? = nil,
priority: Int32? = nil,
in conf: UnsafeMutablePointer<config_object>?
) throws {
guard conf != nil else { throw SessionUtilError.nilConfigObject }
user_profile_set_nts_priority(conf, priority)
user_profile_set_nts_hidden(conf, hidden)
if let hidden: Bool = hidden {
user_profile_set_nts_hidden(conf, hidden)
}
if let priority: Int32 = priority {
user_profile_set_nts_priority(conf, priority)
}
}
}

View File

@ -29,7 +29,10 @@ extension ColumnExpression {
// MARK: - QueryInterfaceRequest
public extension QueryInterfaceRequest {
public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord {
// MARK: -- updateAll
@discardableResult
func updateAll(
_ db: Database,
@ -61,30 +64,16 @@ public extension QueryInterfaceRequest {
) throws -> Int {
let targetAssignments: [ColumnAssignment] = assignments.map { $0.assignment }
// Before we do anything make sure the changes actually do need to be sunced
// Before we do anything custom make sure the changes actually do need to be synced
guard SessionUtil.assignmentsRequireConfigUpdate(assignments) else {
return try self.updateAll(db, targetAssignments)
}
switch self {
case let contactRequest as QueryInterfaceRequest<Contact>:
return try contactRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
case let profileRequest as QueryInterfaceRequest<Profile>:
return try profileRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
case let threadRequest as QueryInterfaceRequest<SessionThread>:
return try threadRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
case let threadRequest as QueryInterfaceRequest<ClosedGroup>:
return try threadRequest.updateAndFetchAllAndUpdateConfig(db, assignments).count
default: return try self.updateAll(db, targetAssignments)
}
return try self.updateAndFetchAllAndUpdateConfig(db, assignments).count
}
}
public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord {
// MARK: -- updateAndFetchAll
@discardableResult
func updateAndFetchAllAndUpdateConfig(
_ db: Database,
@ -120,9 +109,6 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
}
// Update the config dump state where needed
try SessionUtil.updateThreadPrioritiesIfNeeded(db, assignments, updatedData)
switch self {
case is QueryInterfaceRequest<Contact>:
return try SessionUtil.updatingContacts(db, updatedData)
@ -130,9 +116,6 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
case is QueryInterfaceRequest<Profile>:
return try SessionUtil.updatingProfiles(db, updatedData)
case is QueryInterfaceRequest<ClosedGroup>:
return updatedData
case is QueryInterfaceRequest<SessionThread>:
return try SessionUtil.updatingThreads(db, updatedData)

View File

@ -49,6 +49,9 @@ public enum SessionUtil {
/// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been
/// loaded yet (eg. fresh install)
public static var needsSync: Bool {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return false }
return configStore
.wrappedValue
.contains { _, atomicConf in
@ -63,15 +66,30 @@ public enum SessionUtil {
// MARK: - Loading
public static func loadState(
_ db: Database? = nil,
userPublicKey: String,
ed25519SecretKey: [UInt8]?
) {
guard let secretKey: [UInt8] = ed25519SecretKey else { return }
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
// Ensure we have the ed25519 key and that we haven't already loaded the state before
// we continue
guard
let secretKey: [UInt8] = ed25519SecretKey,
SessionUtil.configStore.wrappedValue.isEmpty
else { return }
// If we weren't given a database instance then get one
guard let db: Database = db else {
Storage.shared.read { db in
SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey)
}
return
}
// Retrieve the existing dumps from the database
let existingDumps: Set<ConfigDump> = Storage.shared
.read { db in try ConfigDump.fetchSet(db) }
.defaulting(to: [])
let existingDumps: Set<ConfigDump> = ((try? ConfigDump.fetchSet(db)) ?? [])
let existingDumpVariants: Set<ConfigDump.Variant> = existingDumps
.map { $0.variant }
.asSet()
@ -103,7 +121,7 @@ public enum SessionUtil {
}
}
internal static func loadState(
private static func loadState(
for variant: ConfigDump.Variant,
secretKey ed25519SecretKey: [UInt8],
cachedData: Data?
@ -265,6 +283,9 @@ public enum SessionUtil {
}
public static func configHashes(for publicKey: String) -> [String] {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return [] }
return Storage.shared
.read { db -> [String] in
guard Identity.userExists(db) else { return [] }
@ -342,36 +363,42 @@ public enum SessionUtil {
mergeData.forEach { $0?.deallocate() }
// Apply the updated states to the database
switch next.key {
case .userProfile:
try SessionUtil.handleUserProfileUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
)
case .contacts:
try SessionUtil.handleContactsUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf)
)
case .convoInfoVolatile:
try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf)
)
case .userGroups:
try SessionUtil.handleGroupsUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
)
do {
switch next.key {
case .userProfile:
try SessionUtil.handleUserProfileUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
)
case .contacts:
try SessionUtil.handleContactsUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf)
)
case .convoInfoVolatile:
try SessionUtil.handleConvoInfoVolatileUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf)
)
case .userGroups:
try SessionUtil.handleGroupsUpdate(
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
)
}
}
catch {
SNLog("[libSession] Failed to process merge of \(next.key) config data")
throw error
}
// Need to check if the config needs to be dumped (this might have changed

View File

@ -4,18 +4,6 @@
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libsession-util.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
@ -31,6 +19,18 @@
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libsession-util.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>

View File

@ -488,43 +488,43 @@ public final class OpenGroupManager {
.deleteAll(db)
try roomDetails.admins.forEach { adminId in
_ = try GroupMember(
try GroupMember(
groupId: threadId,
profileId: adminId,
role: .admin,
isHidden: false
).saved(db)
).save(db)
}
try roomDetails.hiddenAdmins
.defaulting(to: [])
.forEach { adminId in
_ = try GroupMember(
try GroupMember(
groupId: threadId,
profileId: adminId,
role: .admin,
isHidden: true
).saved(db)
).save(db)
}
try roomDetails.moderators.forEach { moderatorId in
_ = try GroupMember(
try GroupMember(
groupId: threadId,
profileId: moderatorId,
role: .moderator,
isHidden: false
).saved(db)
).save(db)
}
try roomDetails.hiddenModerators
.defaulting(to: [])
.forEach { moderatorId in
_ = try GroupMember(
try GroupMember(
groupId: threadId,
profileId: moderatorId,
role: .moderator,
isHidden: true
).saved(db)
).save(db)
}
}

View File

@ -8,16 +8,56 @@ import SessionUtilitiesKit
import SessionSnodeKit
extension MessageReceiver {
public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage) throws {
public static func handleClosedGroupControlMessage(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
switch message.kind {
case .new: try handleNewClosedGroup(db, message: message)
case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair(db, message: message)
case .nameChange: try handleClosedGroupNameChanged(db, message: message)
case .membersAdded: try handleClosedGroupMembersAdded(db, message: message)
case .membersRemoved: try handleClosedGroupMembersRemoved(db, message: message)
case .memberLeft: try handleClosedGroupMemberLeft(db, message: message)
case .encryptionKeyPairRequest:
handleClosedGroupEncryptionKeyPairRequest(db, message: message) // Currently not used
case .encryptionKeyPair:
try handleClosedGroupEncryptionKeyPair(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message
)
case .nameChange:
try handleClosedGroupNameChanged(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message
)
case .membersAdded:
try handleClosedGroupMembersAdded(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message
)
case .membersRemoved:
try handleClosedGroupMembersRemoved(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message
)
case .memberLeft:
try handleClosedGroupMemberLeft(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message
)
case .encryptionKeyPairRequest: break // Currently not used
default: throw MessageReceiverError.invalidMessage
}
@ -39,7 +79,8 @@ extension MessageReceiver {
members: membersAsData.map { $0.toHexString() },
admins: adminsAsData.map { $0.toHexString() },
expirationTimer: expirationTimer,
messageSentTimestamp: sentTimestamp
messageSentTimestamp: sentTimestamp,
calledFromConfigHandling: false
)
}
@ -51,7 +92,8 @@ extension MessageReceiver {
members: [String],
admins: [String],
expirationTimer: UInt32,
messageSentTimestamp: UInt64
messageSentTimestamp: UInt64,
calledFromConfigHandling: Bool
) throws {
// With new closed groups we only want to create them if the admin creating the closed group is an
// approved contact (to prevent spam via closed groups getting around message requests if users are
@ -65,13 +107,14 @@ extension MessageReceiver {
}
}
guard hasApprovedAdmin else { return }
// If the group came from the updated config handling then it doesn't matter if we
// have an approved admin - we should add it regardless (as it's been synced from
// antoher device)
guard hasApprovedAdmin || calledFromConfigHandling else { return }
// Create the group
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup)
.with(shouldBeVisible: true)
.saved(db)
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
let closedGroup: ClosedGroup = try ClosedGroup(
threadId: groupPublicKey,
name: name,
@ -103,7 +146,7 @@ extension MessageReceiver {
}
// Update the DisappearingMessages config
try thread.disappearingMessagesConfiguration
let disappearingConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration
.fetchOne(db)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id))
.with(
@ -113,15 +156,38 @@ extension MessageReceiver {
(24 * 60 * 60)
)
)
.save(db)
.saved(db)
// Store the key pair
try ClosedGroupKeyPair(
// Store the key pair if it doesn't already exist
let receivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
threadId: groupPublicKey,
publicKey: Data(encryptionKeyPair.publicKey),
secretKey: Data(encryptionKeyPair.secretKey),
receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
).insert(db)
receivedTimestamp: receivedTimestamp
)
let keyPairExists: Bool = ClosedGroupKeyPair
.filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == newKeyPair.threadKeyPairHash)
.isNotEmpty(db)
if !keyPairExists {
try newKeyPair.insert(db)
}
if !calledFromConfigHandling {
// Update libSession
try? SessionUtil.add(
db,
groupPublicKey: groupPublicKey,
name: name,
latestKeyPairPublicKey: Data(encryptionKeyPair.publicKey),
latestKeyPairSecretKey: Data(encryptionKeyPair.secretKey),
latestKeyPairReceivedTimestamp: receivedTimestamp,
disappearingConfig: disappearingConfig,
members: members.asSet(),
admins: admins.asSet()
)
}
// Start polling
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey)
@ -132,18 +198,24 @@ extension MessageReceiver {
/// Extracts and adds the new encryption key pair to our list of key pairs if there is one for our public key, AND the message was
/// sent by the group admin.
private static func handleClosedGroupEncryptionKeyPair(_ db: Database, message: ClosedGroupControlMessage) throws {
guard
case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind,
let groupPublicKey: String = (explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey)
else { return }
private static func handleClosedGroupEncryptionKeyPair(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
guard case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind else {
return
}
let groupPublicKey: String = (explicitGroupPublicKey?.toHexString() ?? threadId)
guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else {
return SNLog("Couldn't find user X25519 key pair.")
}
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else {
return SNLog("Ignoring closed group encryption key pair for nonexistent group.")
}
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return }
guard let groupAdmins: [GroupMember] = try? closedGroup.admins.fetchAll(db) else { return }
guard let sender: String = message.sender, groupAdmins.contains(where: { $0.profileId == sender }) else {
return SNLog("Ignoring closed group encryption key pair from non-admin.")
@ -203,368 +275,355 @@ extension MessageReceiver {
SNLog("Received a new closed group encryption key pair.")
}
private static func handleClosedGroupNameChanged(_ db: Database, message: ClosedGroupControlMessage) throws {
guard case let .nameChange(name) = message.kind else { return }
private static func handleClosedGroupNameChanged(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
guard
let messageKind: ClosedGroupControlMessage.Kind = message.kind,
case let .nameChange(name) = message.kind
else { return }
try performIfValid(db, message: message) { id, sender, thread, closedGroup in
_ = try ClosedGroup
.filter(id: id)
.updateAll(db, ClosedGroup.Columns.name.set(to: name))
// Notify the user if needed
guard name != closedGroup.name else { return }
_ = try Interaction(
serverHash: message.serverHash,
threadId: thread.id,
authorId: sender,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.nameChange(name: name)
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
try processIfValid(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message,
messageKind: messageKind,
infoMessageVariant: .infoClosedGroupUpdated,
legacyGroupChanges: { sender, closedGroup, allMembers in
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: threadId,
name: name
)
).inserted(db)
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: id,
name: name
)
}
_ = try ClosedGroup
.filter(id: threadId)
.updateAll( // Explicit config update so no need to use 'updateAllAndConfig'
db,
ClosedGroup.Columns.name.set(to: name)
)
}
)
}
private static func handleClosedGroupMembersAdded(_ db: Database, message: ClosedGroupControlMessage) throws {
guard case let .membersAdded(membersAsData) = message.kind else { return }
private static func handleClosedGroupMembersAdded(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
guard
let messageKind: ClosedGroupControlMessage.Kind = message.kind,
case let .membersAdded(membersAsData) = message.kind
else { return }
try performIfValid(db, message: message) { id, sender, thread, closedGroup in
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return
}
// Update the group
let addedMembers: [String] = membersAsData.map { $0.toHexString() }
let currentMemberIds: Set<String> = allGroupMembers
.filter { $0.role == .standard }
.map { $0.profileId }
.asSet()
let members: Set<String> = currentMemberIds.union(addedMembers)
// Create records for any new members
try addedMembers
.filter { !currentMemberIds.contains($0) }
.forEach { memberId in
try GroupMember(
groupId: id,
profileId: memberId,
role: .standard,
isHidden: false
).insert(db)
}
// Send the latest encryption key pair to the added members if the current user is
// the admin of the group
//
// This fixes a race condition where:
// A member removes another member.
// A member adds someone to the group and sends them the latest group key pair.
// The admin is offline during all of this.
// When the admin comes back online they see the member removed message and generate +
// distribute a new key pair, but they don't know about the added member yet.
// Now they see the member added message.
//
// Without the code below, the added member(s) would never get the key pair that was
// generated by the admin when they saw the member removed message.
let userPublicKey: String = getUserHexEncodedPublicKey(db)
if allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) {
addedMembers.forEach { memberId in
MessageSender.sendLatestEncryptionKeyPair(db, to: memberId, for: id)
}
}
// Remove any 'zombie' versions of the added members (in case they were re-added)
_ = try GroupMember
.filter(GroupMember.Columns.groupId == id)
.filter(GroupMember.Columns.role == GroupMember.Role.zombie)
.filter(addedMembers.contains(GroupMember.Columns.profileId))
.deleteAll(db)
// Notify the user if needed
guard members != currentMemberIds else { return }
_ = try Interaction(
serverHash: message.serverHash,
threadId: thread.id,
authorId: sender,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.membersAdded(
members: addedMembers
.asSet()
.subtracting(currentMemberIds)
.map { Data(hex: $0) }
)
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
try processIfValid(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message,
messageKind: messageKind,
infoMessageVariant: .infoClosedGroupUpdated,
legacyGroupChanges: { sender, closedGroup, allMembers in
// Update the group
let addedMembers: [String] = membersAsData.map { $0.toHexString() }
let currentMemberIds: Set<String> = allMembers
.filter { $0.role == .standard }
.map { $0.profileId }
.asSet()
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: threadId,
members: allMembers
.filter { $0.role == .standard || $0.role == .zombie }
.map { $0.profileId }
.asSet()
.union(addedMembers),
admins: allMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
)
).inserted(db)
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: id,
members: allGroupMembers
.filter { $0.role == .standard || $0.role == .zombie }
.map { $0.profileId }
.asSet()
.union(addedMembers),
admins: allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
)
}
// Create records for any new members
try addedMembers
.filter { !currentMemberIds.contains($0) }
.forEach { memberId in
try GroupMember(
groupId: threadId,
profileId: memberId,
role: .standard,
isHidden: false
).save(db)
}
// Send the latest encryption key pair to the added members if the current user is
// the admin of the group
//
// This fixes a race condition where:
// A member removes another member.
// A member adds someone to the group and sends them the latest group key pair.
// The admin is offline during all of this.
// When the admin comes back online they see the member removed message and generate +
// distribute a new key pair, but they don't know about the added member yet.
// Now they see the member added message.
//
// Without the code below, the added member(s) would never get the key pair that was
// generated by the admin when they saw the member removed message.
let userPublicKey: String = getUserHexEncodedPublicKey(db)
if allMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) {
addedMembers.forEach { memberId in
MessageSender.sendLatestEncryptionKeyPair(db, to: memberId, for: threadId)
}
}
// Remove any 'zombie' versions of the added members (in case they were re-added)
_ = try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(GroupMember.Columns.role == GroupMember.Role.zombie)
.filter(addedMembers.contains(GroupMember.Columns.profileId))
.deleteAll(db)
}
)
}
/// Removes the given members from the group IF
/// it wasn't the admin that was removed (that should happen through a `MEMBER_LEFT` message).
/// the admin sent the message (only the admin can truly remove members).
/// If we're among the users that were removed, delete all encryption key pairs and the group public key, unsubscribe
/// from push notifications for this closed group, and remove the given members from the zombie list for this group.
private static func handleClosedGroupMembersRemoved(_ db: Database, message: ClosedGroupControlMessage) throws {
guard case let .membersRemoved(membersAsData) = message.kind else { return }
private static func handleClosedGroupMembersRemoved(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
guard
let messageKind: ClosedGroupControlMessage.Kind = message.kind,
case let .membersRemoved(membersAsData) = messageKind
else { return }
try performIfValid(db, message: message) { id, sender, thread, closedGroup in
// Check that the admin wasn't removed
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return
}
let removedMembers = membersAsData.map { $0.toHexString() }
let currentMemberIds: Set<String> = allGroupMembers
.filter { $0.role == .standard }
.map { $0.profileId }
.asSet()
let members = currentMemberIds.subtracting(removedMembers)
guard let firstAdminId: String = allGroupMembers.filter({ $0.role == .admin }).first?.profileId, members.contains(firstAdminId) else {
return SNLog("Ignoring invalid closed group update.")
}
// Check that the message was sent by the group admin
guard allGroupMembers.filter({ $0.role == .admin }).contains(where: { $0.profileId == sender }) else {
return SNLog("Ignoring invalid closed group update.")
}
// Delete the removed members
try GroupMember
.filter(GroupMember.Columns.groupId == id)
.filter(removedMembers.contains(GroupMember.Columns.profileId))
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
.deleteAll(db)
// If the current user was removed:
// Stop polling for the group
// Remove the key pairs associated with the group
// Notify the PN server
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey)
if wasCurrentUserRemoved {
ClosedGroupPoller.shared.stopPolling(for: id)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let removedMemberIds: [String] = membersAsData.map { $0.toHexString() }
try processIfValid(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message,
messageKind: messageKind,
infoMessageVariant: (removedMemberIds.contains(userPublicKey) ?
.infoClosedGroupCurrentUserLeft :
.infoClosedGroupUpdated
),
legacyGroupChanges: { sender, closedGroup, allMembers in
let removedMembers = membersAsData.map { $0.toHexString() }
let currentMemberIds: Set<String> = allMembers
.filter { $0.role == .standard }
.map { $0.profileId }
.asSet()
let members = currentMemberIds.subtracting(removedMembers)
_ = try closedGroup
.keyPairs
// Check that the group creator is still a member and that the message was
// sent by a group admin
guard
let firstAdminId: String = allMembers.filter({ $0.role == .admin })
.first?
.profileId,
members.contains(firstAdminId),
allMembers
.filter({ $0.role == .admin })
.contains(where: { $0.profileId == sender })
else { return SNLog("Ignoring invalid closed group update.") }
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: threadId,
members: allMembers
.filter { $0.role == .standard || $0.role == .zombie }
.map { $0.profileId }
.asSet()
.subtracting(removedMembers),
admins: allMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
)
// Delete the removed members
try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(removedMembers.contains(GroupMember.Columns.profileId))
.filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role))
.deleteAll(db)
let _ = PushNotificationAPI.performOperation(
.unsubscribe,
for: id,
publicKey: userPublicKey
)
}
// Notify the user if needed
guard members != currentMemberIds else { return }
_ = try Interaction(
serverHash: message.serverHash,
threadId: thread.id,
authorId: sender,
variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated),
body: ClosedGroupControlMessage.Kind
.membersRemoved(
members: removedMembers
.asSet()
.intersection(currentMemberIds)
.map { Data(hex: $0) }
// If the current user was removed:
// Stop polling for the group
// Remove the key pairs associated with the group
// Notify the PN server
let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey)
if wasCurrentUserRemoved {
ClosedGroupPoller.shared.stopPolling(for: threadId)
_ = try closedGroup
.keyPairs
.deleteAll(db)
let _ = PushNotificationAPI.performOperation(
.unsubscribe,
for: threadId,
publicKey: userPublicKey
)
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
).inserted(db)
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: id,
members: allGroupMembers
.filter { $0.role == .standard || $0.role == .zombie }
.map { $0.profileId }
.asSet()
.subtracting(removedMembers),
admins: allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
)
}
}
}
)
}
/// If a regular member left:
/// Mark them as a zombie (to be removed by the admin later).
/// If the admin left:
/// Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded.
private static func handleClosedGroupMemberLeft(_ db: Database, message: ClosedGroupControlMessage) throws {
guard case .memberLeft = message.kind else { return }
private static func handleClosedGroupMemberLeft(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage
) throws {
guard
let messageKind: ClosedGroupControlMessage.Kind = message.kind,
case .memberLeft = messageKind
else { return }
try performIfValid(db, message: message) { id, sender, thread, closedGroup in
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return
}
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let didAdminLeave: Bool = allGroupMembers.contains(where: { member in
member.role == .admin && member.profileId == sender
})
let members: [GroupMember] = allGroupMembers.filter { $0.role == .standard }
let membersToRemove: [GroupMember] = members
.filter { member in
didAdminLeave || // If the admin leaves the group is disbanded
member.profileId == sender
}
let updatedMemberIds: Set<String> = members
.map { $0.profileId }
.asSet()
.subtracting(membersToRemove.map { $0.profileId })
// Delete the members to remove
try GroupMember
.filter(GroupMember.Columns.groupId == id)
.filter(updatedMemberIds.contains(GroupMember.Columns.profileId))
.deleteAll(db)
if didAdminLeave || sender == userPublicKey {
// Remove the group from the database and unsubscribe from PNs
ClosedGroupPoller.shared.stopPolling(for: id)
_ = try closedGroup
.keyPairs
.deleteAll(db)
let _ = PushNotificationAPI.performOperation(
.unsubscribe,
for: id,
publicKey: userPublicKey
)
}
// Re-add the removed member as a zombie (unless the admin left which disbands the
// group)
if !didAdminLeave {
try GroupMember(
groupId: id,
profileId: sender,
role: .zombie,
isHidden: false
).insert(db)
}
// Notify the user if needed
guard updatedMemberIds != Set(members.map { $0.profileId }) else { return }
_ = try Interaction(
serverHash: message.serverHash,
threadId: thread.id,
authorId: sender,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.memberLeft
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
).inserted(db)
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: id,
members: allGroupMembers
.filter {
($0.role == .standard || $0.role == .zombie) &&
!membersToRemove.contains($0)
try processIfValid(
db,
threadId: threadId,
threadVariant: threadVariant,
message: message,
messageKind: messageKind,
infoMessageVariant: .infoClosedGroupUpdated,
legacyGroupChanges: { sender, closedGroup, allMembers in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let didAdminLeave: Bool = allMembers.contains(where: { member in
member.role == .admin && member.profileId == sender
})
let members: [GroupMember] = allMembers.filter { $0.role == .standard }
let membersToRemove: [GroupMember] = members
.filter { member in
didAdminLeave || // If the admin leaves the group is disbanded
member.profileId == sender
}
.map { $0.profileId }
.asSet(),
admins: allGroupMembers
.filter { $0.role == .admin }
let updatedMemberIds: Set<String> = members
.map { $0.profileId }
.asSet()
)
}
}
private static func handleClosedGroupEncryptionKeyPairRequest(_ db: Database, message: ClosedGroupControlMessage) {
/*
guard case .encryptionKeyPairRequest = message.kind else { return }
let transaction = transaction as! YapDatabaseReadWriteTransaction
guard let groupPublicKey = message.groupPublicKey else { return }
performIfValid(for: message, using: transaction) { groupID, _, group in
let publicKey = message.sender!
// Guard against self-sends
guard publicKey != getUserHexEncodedPublicKey() else {
return SNLog("Ignoring invalid closed group update.")
.subtracting(membersToRemove.map { $0.profileId })
// Update libSession
try? SessionUtil.update(
db,
groupPublicKey: threadId,
members: allMembers
.filter {
($0.role == .standard || $0.role == .zombie) &&
!membersToRemove.contains($0)
}
.map { $0.profileId }
.asSet(),
admins: allMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
)
// Delete the members to remove
try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.filter(updatedMemberIds.contains(GroupMember.Columns.profileId))
.deleteAll(db)
if didAdminLeave || sender == userPublicKey {
try ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: threadId,
removeGroupData: false,
calledFromConfigHandling: false
)
}
// Re-add the removed member as a zombie (unless the admin left which disbands the
// group)
if !didAdminLeave {
try GroupMember(
groupId: threadId,
profileId: sender,
role: .zombie,
isHidden: false
).save(db)
}
}
MessageSender.sendLatestEncryptionKeyPair(to: publicKey, for: groupPublicKey, using: transaction)
}
*/
)
}
// MARK: - Convenience
private static func performIfValid(
private static func processIfValid(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage,
_ update: (String, String, SessionThread, ClosedGroup
) throws -> Void) throws {
guard let groupPublicKey: String = message.groupPublicKey else { return }
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
return SNLog("Ignoring closed group update for nonexistent group.")
}
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return }
// Check that the message isn't from before the group was created
guard Double(message.sentTimestamp ?? 0) > closedGroup.formationTimestamp else {
return SNLog("Ignoring closed group update from before thread was created.")
}
messageKind: ClosedGroupControlMessage.Kind,
infoMessageVariant: Interaction.Variant,
legacyGroupChanges: (String, ClosedGroup, [GroupMember]) throws -> ()
) throws {
guard let sender: String = message.sender else { return }
guard let members: [GroupMember] = try? closedGroup.members.fetchAll(db) else { return }
// Check that the sender is a member of the group
guard members.contains(where: { $0.profileId == sender }) else {
return SNLog("Ignoring closed group update from non-member.")
guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: threadId) else {
return SNLog("Ignoring group update for nonexistent group.")
}
try update(groupPublicKey, sender, thread, closedGroup)
// Legacy groups used these control messages for making changes, new groups only use them
// for information purposes
switch threadVariant {
case .legacyGroup:
// Check that the message isn't from before the group was created
guard Double(message.sentTimestamp ?? 0) > closedGroup.formationTimestamp else {
return SNLog("Ignoring legacy group update from before thread was created.")
}
// If these values are missing then we probably won't be able to validly handle the message
guard
let allMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db),
allMembers.contains(where: { $0.profileId == sender })
else { return SNLog("Ignoring legacy group update from non-member.") }
try legacyGroupChanges(sender, closedGroup, allMembers)
case .group:
break
default: return // Ignore as invalid
}
// Insert the info message for this group control message
_ = try Interaction(
serverHash: message.serverHash,
threadId: threadId,
authorId: sender,
variant: infoMessageVariant,
body: messageKind
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
).inserted(db)
}
}

View File

@ -189,7 +189,8 @@ extension MessageReceiver {
members: [String](closedGroup.members),
admins: [String](closedGroup.admins),
expirationTimer: closedGroup.expirationTimer,
messageSentTimestamp: message.sentTimestamp!
messageSentTimestamp: message.sentTimestamp!,
calledFromConfigHandling: false // Legacy config isn't an issue
)
}
}

View File

@ -35,6 +35,19 @@ extension MessageReceiver {
)
)
// Legacy closed groups need to update the SessionUtil
switch threadVariant {
case .legacyGroup:
try SessionUtil
.update(
db,
groupPublicKey: threadId,
disappearingConfig: config
)
default: break
}
// Add an info message for the user
_ = try Interaction(
serverHash: nil, // Intentionally null so sync messages are seen as duplicates

View File

@ -65,7 +65,8 @@ extension MessageReceiver {
// Loop through all blinded threads and extract any interactions relating to the user accepting
// the message request
try pendingBlindedIdLookups.forEach { blindedIdLookup in
// If the sessionId matches the blindedId then this thread needs to be converted to an un-blinded thread
// If the sessionId matches the blindedId then this thread needs to be converted to an
// un-blinded thread
guard
dependencies.sodium.sessionId(
senderId,
@ -111,6 +112,9 @@ extension MessageReceiver {
.filter(ids: blindedContactIds)
.deleteAll(db)
try? SessionUtil
.remove(db, contactIds: blindedContactIds)
try updateContactApprovalStatusIfNeeded(
db,
senderSessionId: userPublicKey,

View File

@ -46,9 +46,20 @@ extension MessageReceiver {
)
}
// Get or create thread
guard let threadInfo: (id: String, variant: SessionThread.Variant) = MessageReceiver.threadInfo(db, message: message, openGroupId: openGroupId) else {
throw MessageReceiverError.noThread
switch threadVariant {
case .contact: break // Always continue
case .community:
// Only process visible messages for communities if they have an existing thread
guard (try? SessionThread.exists(db, id: threadId)) == true else {
throw MessageReceiverError.noThread
}
case .legacyGroup, .group:
// Only process visible messages for groups if they have a ClosedGroup record
guard (try? ClosedGroup.exists(db, id: threadId)) == true else {
throw MessageReceiverError.noThread
}
}
// Store the message variant so we can run variant-specific behaviours

View File

@ -60,7 +60,7 @@ extension MessageSender {
profileId: adminId,
role: .admin,
isHidden: false
).insert(db)
).save(db)
}
try members.forEach { memberId in
@ -69,7 +69,7 @@ extension MessageSender {
profileId: memberId,
role: .standard,
isHidden: false
).insert(db)
).save(db)
}
// Update libSession
@ -80,6 +80,7 @@ extension MessageSender {
latestKeyPairPublicKey: encryptionKeyPair.publicKey,
latestKeyPairSecretKey: encryptionKeyPair.privateKey,
latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp,
disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey),
members: members,
admins: admins
)
@ -462,7 +463,7 @@ extension MessageSender {
profileId: member,
role: .standard,
isHidden: false
).insert(db)
).save(db)
}
}
@ -629,12 +630,18 @@ extension MessageSender {
}
}
catch {
try? ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: groupPublicKey,
removeGroupData: false,
calledFromConfigHandling: false
)
switch error {
case MessageSenderError.noKeyPair, MessageSenderError.encryptionFailed:
try? ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: groupPublicKey,
removeGroupData: false,
calledFromConfigHandling: false
)
default: break
}
return Fail(error: error)
.eraseToAnyPublisher()
}

View File

@ -281,6 +281,17 @@ public enum MessageReceiver {
case is TypingIndicator: break
default:
// Only update the `shouldBeVisible` flag if the thread is currently not visible
// as we don't want to trigger a config update if not needed
let isCurrentlyVisible: Bool = try SessionThread
.filter(id: threadId)
.select(.shouldBeVisible)
.asRequest(of: Bool.self)
.fetchOne(db)
.defaulting(to: false)
guard !isCurrentlyVisible else { return }
try SessionThread
.filter(id: threadId)
.updateAllAndConfig(

View File

@ -558,23 +558,25 @@ public struct ProfileManager {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
case .updateTo(let url, let key, let fileName):
if
(
url != profile.profilePictureUrl ||
key != profile.profileEncryptionKey
) &&
key.count == ProfileManager.avatarAES256KeyByteLength &&
key != profile.profileEncryptionKey
{
if url != profile.profilePictureUrl {
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url))
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key))
avatarNeedsDownload = true
targetAvatarUrl = url
}
if key != profile.profileEncryptionKey && key.count == ProfileManager.avatarAES256KeyByteLength {
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key))
}
// Profile filename (this isn't synchronized between devices)
if let fileName: String = fileName {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName))
// If we have already downloaded the image then no need to download it again
avatarNeedsDownload = (
avatarNeedsDownload &&
!ProfileManager.hasProfileImageData(with: fileName)
)
}
}
}

View File

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--ShareVC-->
<!--Share Nav Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareVC" customModule="SessionShareExtension" customModuleProvider="target" sceneMemberID="viewController">
<viewController id="j1y-V4-xli" customClass="ShareNavController" customModule="SessionShareExtension" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@ -27,6 +27,10 @@ public extension Database {
}
}
func drop<T>(table: T.Type) throws where T: TableRecord {
try drop(table: T.databaseTableName)
}
func createIndex<T>(
withCustomName customName: String? = nil,
on table: T.Type,