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 #!/usr/bin/xcrun --sdk macosx swift
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// ListLocalizableStrings.swift
// Archa
//
// Created by Morgan Pretty on 18/5/20.
// Copyright © 2020 Archa. All rights reserved.
// //
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference // This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
// is canges to the localized usage regex // is canges to the localized usage regex
@ -56,7 +51,6 @@ var executableFiles: [String] = {
/// - Parameter path: path of file /// - Parameter path: path of file
/// - Returns: content in file /// - Returns: content in file
func contents(atPath path: String) -> String { func contents(atPath path: String) -> String {
print("Path: \(path)")
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else { guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
fatalError("Could not read from path: \(path)") fatalError("Could not read from path: \(path)")
} }
@ -109,8 +103,6 @@ func localizedStringsInCode() -> [LocalizationCodeFile] {
/// ///
/// - Parameter files: list of localizable files to validate /// - Parameter files: list of localizable files to validate
func validateMatchKeys(_ files: [LocalizationStringsFile]) { func validateMatchKeys(_ files: [LocalizationStringsFile]) {
print("------------ Validating keys match in all localizable files ------------")
guard let base = files.first, files.count > 1 else { return } guard let base = files.first, files.count > 1 else { return }
let files = Array(files.dropFirst()) let files = Array(files.dropFirst())
@ -128,8 +120,6 @@ func validateMatchKeys(_ files: [LocalizationStringsFile]) {
/// - codeFiles: Array of LocalizationCodeFile /// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles /// - localizationFiles: Array of LocalizableStringFiles
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) { func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for missing keys -----------")
guard let baseFile = localizationFiles.first else { guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file") fatalError("Could not locate base localization file")
} }
@ -150,8 +140,6 @@ func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles:
/// - codeFiles: Array of LocalizationCodeFile /// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles /// - localizationFiles: Array of LocalizableStringFiles
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) { func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for any dead keys in localizable file -----------")
guard let baseFile = localizationFiles.first else { guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file") fatalError("Could not locate base localization file")
} }
@ -174,14 +162,18 @@ protocol Pathable {
struct LocalizationStringsFile: Pathable { struct LocalizationStringsFile: Pathable {
let path: String let path: String
let kv: [String: String] let kv: [String: String]
let duplicates: [(key: String, path: String)]
var keys: [String] { var keys: [String] {
return Array(kv.keys) return Array(kv.keys)
} }
init(path: String) { init(path: String) {
let result = ContentParser.parse(path)
self.path = 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 /// 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 /// - Parameter path: Localizable file paths
/// - Returns: localizable key and value for content at path /// - Returns: localizable key and value for content at path
static func parse(_ path: String) -> [String: String] { static func parse(_ path: String) -> (kv: [String: String], duplicates: [(key: String, path: String)]) {
print("------------ Checking for duplicate keys: \(path) ------------")
let content = contents(atPath: path) let content = contents(atPath: path)
let trimmed = content let trimmed = content
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil) .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)") 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 var duplicates: [(key: String, path: String)] = []
if results[keyValue.0] != nil { let kv: [String: String] = zip(keys, values)
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)") .reduce(into: [:]) { results, keyValue in
abort() 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: "")) print(string.replacingOccurrences(of: "\\", with: ""))
} }
let stringFiles = create() // MARK: - Processing
let stringFiles: [LocalizationStringsFile] = create()
if !stringFiles.isEmpty { 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) 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...) // 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() } // stringFiles.forEach { $0.cleanWrite() }
let codeFiles = localizedStringsInCode() let codeFiles: [LocalizationCodeFile] = localizedStringsInCode()
validateMissingKeys(codeFiles, localizationFiles: stringFiles) validateMissingKeys(codeFiles, localizationFiles: stringFiles)
validateDeadKeys(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 */; }; 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 */; }; 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 */; }; 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; }; FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; platformFilter = ios; };
FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; }; FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */; };
FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; FD83B9BD27CF2243005E1583 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = "<group>"; };
@ -3629,6 +3631,7 @@
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */, FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */, FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */, FD8ECF7C2934293A00C0D1BB /* _012_SharedUtilChanges.swift */,
FD778B6329B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift */,
); );
path = Migrations; path = Migrations;
sourceTree = "<group>"; sourceTree = "<group>";
@ -5791,6 +5794,7 @@
FD09796E27FA6D0000936362 /* Contact.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */,
C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */,
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */,
FD778B6429B189FF001BAC6B /* _013_GenerateInitialUserConfigDumps.swift in Sources */,
C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */,
FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */,
FD8ECF7F2934298100C0D1BB /* SharedConfigDump.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 // Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible // Update the thread to be visible (if it isn't already)
_ = try SessionThread if !thread.shouldBeVisible {
.filter(id: threadId) _ = try SessionThread
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) .filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
let authorId: String = { let authorId: String = {
if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey { if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey {
@ -585,10 +587,12 @@ extension ConversationVC:
// Let the viewModel know we are about to send a message // Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible // Update the thread to be visible (if it isn't already)
_ = try SessionThread if !thread.shouldBeVisible {
.filter(id: threadId) _ = try SessionThread
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) .filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
// Create the interaction // Create the interaction
let interaction: Interaction = try Interaction( let interaction: Interaction = try Interaction(
@ -1301,7 +1305,7 @@ extension ConversationVC:
.suffix(19)) .suffix(19))
.appending(sentTimestamp) .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 // Perform the sending logic
Storage.shared Storage.shared
.writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in .writePublisherFlatMap(receiveOn: DispatchQueue.global(qos: .userInitiated)) { db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in
@ -1311,10 +1315,12 @@ extension ConversationVC:
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// Update the thread to be visible // Update the thread to be visible (if it isn't already)
_ = try SessionThread if !thread.shouldBeVisible {
.filter(id: thread.id) _ = try SessionThread
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) .filter(id: thread.id)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
let pendingReaction: Reaction? = { let pendingReaction: Reaction? = {
if remove { if remove {

View File

@ -528,6 +528,25 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
mediaCache.removeAllObjects() mediaCache.removeAllObjects()
hasReloadedThreadDataAfterDisappearance = false hasReloadedThreadDataAfterDisappearance = false
viewIsDisappearing = 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) { @objc func applicationDidBecomeActive(_ notification: Notification) {

View File

@ -30,6 +30,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
private let dependencies: Dependencies private let dependencies: Dependencies
private let threadId: String private let threadId: String
private let threadVariant: SessionThread.Variant
private let config: DisappearingMessagesConfiguration private let config: DisappearingMessagesConfiguration
private var storedSelection: TimeInterval private var storedSelection: TimeInterval
private var currentSelection: CurrentValueSubject<TimeInterval, Never> private var currentSelection: CurrentValueSubject<TimeInterval, Never>
@ -39,10 +40,12 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
init( init(
dependencies: Dependencies = Dependencies(), dependencies: Dependencies = Dependencies(),
threadId: String, threadId: String,
threadVariant: SessionThread.Variant,
config: DisappearingMessagesConfiguration config: DisappearingMessagesConfiguration
) { ) {
self.dependencies = dependencies self.dependencies = dependencies
self.threadId = threadId self.threadId = threadId
self.threadVariant = threadVariant
self.config = config self.config = config
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0) self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
self.currentSelection = CurrentValueSubject(self.storedSelection) self.currentSelection = CurrentValueSubject(self.storedSelection)
@ -134,6 +137,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
private func saveChanges() { private func saveChanges() {
let threadId: String = self.threadId let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let currentSelection: TimeInterval = self.currentSelection.value let currentSelection: TimeInterval = self.currentSelection.value
let updatedConfig: DisappearingMessagesConfiguration = self.config let updatedConfig: DisappearingMessagesConfiguration = self.config
.with( .with(
@ -175,6 +179,19 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
interactionId: interaction.id, interactionId: interaction.id,
in: thread 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( SessionTableViewController(
viewModel: ThreadDisappearingMessagesSettingsViewModel( viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadId, threadId: threadId,
threadVariant: threadVariant,
config: disappearingMessagesConfig config: disappearingMessagesConfig
) )
) )

View File

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

View File

@ -4,6 +4,7 @@ import Foundation
import GRDB import GRDB
import DifferenceKit import DifferenceKit
import SignalUtilitiesKit import SignalUtilitiesKit
import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
public class HomeViewModel { public class HomeViewModel {
@ -365,10 +366,27 @@ public class HomeViewModel {
threadViewModel.markAsUnread() threadViewModel.markAsUnread()
} }
public func delete(threadId: String, threadVariant: SessionThread.Variant) { public func deleteOrLeave(threadId: String, threadVariant: SessionThread.Variant) {
Storage.shared.writeAsync { db in Storage.shared.writeAsync { db in
switch threadVariant { switch threadVariant {
case .contact: 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 try SessionUtil
.hide(db, contactIds: [threadId]) .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 // Check if the user selected an animated image (if so then don't crop, just
// set the avatar directly // set the avatar directly
guard guard
let type: Any = try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey]) let type: URLResourceValues = try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey]),
.allValues let typeString: String = type.typeIdentifier,
.first,
let typeString: String = type as? String,
MIMETypeUtil.supportedAnimatedImageUTITypes().contains(typeString) MIMETypeUtil.supportedAnimatedImageUTITypes().contains(typeString)
else { else {
let viewController: CropScaleImageViewController = CropScaleImageViewController( let viewController: CropScaleImageViewController = CropScaleImageViewController(

View File

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

View File

@ -259,33 +259,33 @@ enum MockDataGenerator {
.saved(db) .saved(db)
members.forEach { memberId in members.forEach { memberId in
_ = try! GroupMember( try! GroupMember(
groupId: randomGroupPublicKey, groupId: randomGroupPublicKey,
profileId: memberId, profileId: memberId,
role: .standard, role: .standard,
isHidden: false isHidden: false
) )
.saved(db) .save(db)
} }
[members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId].forEach { adminId in [members.randomElement(using: &cgThreadRandomGenerator) ?? userSessionId].forEach { adminId in
_ = try! GroupMember( try! GroupMember(
groupId: randomGroupPublicKey, groupId: randomGroupPublicKey,
profileId: adminId, profileId: adminId,
role: .admin, role: .admin,
isHidden: false 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 // Add the group to the user's set of public keys to poll for and store the key pair
let encryptionKeyPair = Curve25519.generateKeyPair() let encryptionKeyPair = Curve25519.generateKeyPair()
_ = try! ClosedGroupKeyPair( try! ClosedGroupKeyPair(
threadId: randomGroupPublicKey, threadId: randomGroupPublicKey,
publicKey: encryptionKeyPair.publicKey, publicKey: encryptionKeyPair.publicKey,
secretKey: encryptionKeyPair.privateKey, secretKey: encryptionKeyPair.privateKey,
receivedTimestamp: timestampNow receivedTimestamp: timestampNow
) )
.saved(db) .save(db)
// Generate the message history (Note: Unapproved message requests will only include incoming messages) // Generate the message history (Note: Unapproved message requests will only include incoming messages)
logProgress("Closed Group Thread \(threadIndex)", "Generate \(numMessages) 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 ], // Add job priorities
[ [
_011_AddPendingReadReceipts.self, _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. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation import Foundation
import CryptoKit
import GRDB import GRDB
import SessionUtil import SessionUtil
import SessionUtilitiesKit import SessionUtilitiesKit
/// This migration recreates the interaction FTS table and adds the threadId so we can do a performant in-conversation /// This migration makes the neccessary changes to support the updated user config syncing system
/// searh (currently it's much slower than the global search)
enum _012_SharedUtilChanges: Migration { enum _012_SharedUtilChanges: Migration {
static let target: TargetMigrations.Identifier = .messagingKit static let target: TargetMigrations.Identifier = .messagingKit
static let identifier: String = "SharedUtilChanges" static let identifier: String = "SharedUtilChanges"
@ -20,10 +20,123 @@ enum _012_SharedUtilChanges: Migration {
t.add(.pinnedPriority, .integer) 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( try db.createIndex(
on: ClosedGroupKeyPair.self, on: ClosedGroupKeyPair.self,
columns: [.threadId, .publicKey, .secretKey] columns: [.threadId, .threadKeyPairHash]
) )
// New table for storing the latest config dump for each type // 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 // If we don't have an ed25519 key then no need to create cached dump data
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
guard let secretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey else { // There was previously a bug which allowed users to fully delete the 'Note to Self'
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration // conversation but we don't want that, so create it again if it doesn't exists
return try SessionThread
} .fetchOrCreate(db, id: userPublicKey, variant: .contact, shouldBeVisible: false)
// 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)
}
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration 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. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation import Foundation
import CryptoKit
import GRDB import GRDB
import SessionUtilitiesKit import SessionUtilitiesKit
@ -18,12 +19,14 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab
case publicKey case publicKey
case secretKey case secretKey
case receivedTimestamp case receivedTimestamp
case threadKeyPairHash
} }
public let threadId: String public let threadId: String
public let publicKey: Data public let publicKey: Data
public let secretKey: Data public let secretKey: Data
public let receivedTimestamp: TimeInterval public let receivedTimestamp: TimeInterval
public let threadKeyPairHash: String
// MARK: - Relationships // MARK: - Relationships
@ -43,6 +46,12 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab
self.publicKey = publicKey self.publicKey = publicKey
self.secretKey = secretKey self.secretKey = secretKey
self.receivedTimestamp = receivedTimestamp 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 Foundation
import GRDB import GRDB
@ -27,7 +27,8 @@ internal extension SessionUtil {
String: ( String: (
contact: Contact, contact: Contact,
profile: Profile, profile: Profile,
isHiddenConversation: Bool shouldBeVisible: Bool,
priority: Int32
) )
] ]
@ -66,7 +67,8 @@ internal extension SessionUtil {
contactData[contactId] = ( contactData[contactId] = (
contactResult, contactResult,
profileResult, profileResult,
contact.hidden (contact.hidden == false),
contact.priority
) )
contacts_iterator_advance(contactIterator) 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 /// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the
/// associated contact conversation accordingly /// associated contact conversation accordingly
let threadExists: Bool = try SessionThread.exists(db, id: contact.id) let threadInfo: PriorityVisibilityInfo? = try? SessionThread
let threadIsVisible: Bool = try SessionThread .select(.id, .variant, .pinnedPriority, .shouldBeVisible)
.filter(id: contact.id) .asRequest(of: PriorityVisibilityInfo.self)
.select(.shouldBeVisible)
.asRequest(of: Bool.self)
.fetchOne(db) .fetchOne(db)
.defaulting(to: false) let threadExists: Bool = (threadInfo != nil)
let threadIsVisible: Bool = (threadInfo?.shouldBeVisible ?? false)
switch (data.isHiddenConversation, threadExists, threadIsVisible) { switch (data.shouldBeVisible, threadExists, threadIsVisible) {
case (true, true, _): case (false, true, _):
try SessionThread try SessionThread
.filter(id: contact.id) .filter(id: contact.id)
.deleteAll(db) .deleteAll(db)
case (false, false, _): case (true, false, _):
try SessionThread( try SessionThread(
id: contact.id, id: contact.id,
variant: .contact, variant: .contact,
shouldBeVisible: true shouldBeVisible: true,
pinnedPriority: data.priority
).save(db) ).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 try SessionThread
.filter(id: contact.id) .filter(id: contact.id)
.updateAll( // Handling a config update so don't use `updateAllAndConfig` .updateAll( // Handling a config update so don't use `updateAllAndConfig`
db, db,
SessionThread.Columns.shouldBeVisible.set(to: !data.isHiddenConversation) changes
) )
default: break default: break
@ -253,26 +262,7 @@ internal extension SessionUtil {
} }
} }
// MARK: - External Outgoing Changes // MARK: - 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
internal extension SessionUtil { internal extension SessionUtil {
static func updatingContacts<T>(_ db: Database, _ updated: [T]) throws -> [T] { 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 // If we only updated the current user contact then no need to continue
guard !targetContacts.isEmpty else { return updated } guard !targetContacts.isEmpty else { return updated }
do { try SessionUtil.performAndPushChange(
try SessionUtil.performAndPushChange( db,
db, for: .contacts,
for: .contacts, publicKey: userPublicKey
publicKey: userPublicKey ) { conf in
) { conf in // When inserting new contacts (or contacts with invalid profile data) we want
// 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
// 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
// contacts are new/invalid, and if so, fetch any profile data we have for them let newContactIds: [String] = targetContacts
let newContactIds: [String] = targetContacts .compactMap { contactData -> String? in
.compactMap { contactData -> String? in var cContactId: [CChar] = contactData.id.cArray
var cContactId: [CChar] = contactData.id.cArray var contact: contacts_contact = contacts_contact()
var contact: contacts_contact = contacts_contact()
guard
guard contacts_get(conf, &contact, &cContactId),
contacts_get(conf, &contact, &cContactId), String(libSessionVal: contact.name, nullIfEmpty: true) != nil
String(libSessionVal: contact.name, nullIfEmpty: true) != nil else { return contactData.id }
else { return contactData.id }
return nil
return nil }
} let newProfiles: [String: Profile] = try Profile
let newProfiles: [String: Profile] = try Profile .fetchAll(db, ids: newContactIds)
.fetchAll(db, ids: newContactIds) .reduce(into: [:]) { result, next in result[next.id] = next }
.reduce(into: [:]) { result, next in result[next.id] = next }
// Upsert the updated contact data
// Upsert the updated contact data try SessionUtil
try SessionUtil .upsert(
.upsert( contactData: targetContacts
contactData: targetContacts .map { contact in
.map { contact in SyncedContactInfo(
SyncedContactInfo( id: contact.id,
id: contact.id, contact: contact,
contact: contact, profile: newProfiles[contact.id]
profile: newProfiles[contact.id] )
) },
}, in: conf
in: conf )
)
}
}
catch {
SNLog("[libSession-util] Failed to dump updated data")
} }
return updated return updated
@ -357,42 +342,75 @@ internal extension SessionUtil {
existingContactIds.contains($0.id) existingContactIds.contains($0.id)
} }
do { // Update the user profile first (if needed)
// Update the user profile first (if needed) if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) {
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
)
}
}
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .contacts, for: .userProfile,
publicKey: userPublicKey publicKey: userPublicKey
) { conf in ) { conf in
try SessionUtil try SessionUtil.update(
.upsert( profile: updatedUserProfile,
contactData: targetProfiles in: conf
.map { SyncedContactInfo(id: $0.id, profile: $0) }, )
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 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 // MARK: - SyncedContactInfo
extension SessionUtil { extension SessionUtil {
@ -400,21 +418,21 @@ extension SessionUtil {
let id: String let id: String
let contact: Contact? let contact: Contact?
let profile: Profile? let profile: Profile?
let priority: Int32?
let hidden: Bool? let hidden: Bool?
let priority: Int32?
init( init(
id: String, id: String,
contact: Contact? = nil, contact: Contact? = nil,
profile: Profile? = nil, profile: Profile? = nil,
priority: Int32? = nil, hidden: Bool? = nil,
hidden: Bool? = nil priority: Int32? = nil
) { ) {
self.id = id self.id = id
self.contact = contact self.contact = contact
self.profile = profile self.profile = profile
self.priority = priority
self.hidden = hidden 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 // If there are no newer local last read timestamps then just return the mergeResult
guard !newerLocalChanges.isEmpty else { return } guard !newerLocalChanges.isEmpty else { return }
try SessionUtil.performAndPushChange( try upsert(
db, convoInfoVolatileChanges: newerLocalChanges,
for: .convoInfoVolatile, in: conf
publicKey: getUserHexEncodedPublicKey(db) )
) { conf in
try upsert(
convoInfoVolatileChanges: newerLocalChanges,
in: conf
)
}
} }
static func upsert( static func upsert(
@ -176,7 +170,21 @@ internal extension SessionUtil {
) throws { ) throws {
guard conf != nil else { throw SessionUtilError.nilConfigObject } 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 var cThreadId: [CChar] = threadInfo.threadId.cArray
switch threadInfo.variant { switch threadInfo.variant {
@ -250,11 +258,7 @@ internal extension SessionUtil {
} }
} }
} }
}
// MARK: - Convenience
internal extension SessionUtil {
static func updateMarkedAsUnreadState( static func updateMarkedAsUnreadState(
_ db: Database, _ db: Database,
threads: [SessionThread] threads: [SessionThread]
@ -284,13 +288,20 @@ internal extension SessionUtil {
) )
} }
} }
}
// MARK: - External Outgoing Changes
public extension SessionUtil {
static func syncThreadLastReadIfNeeded( static func syncThreadLastReadIfNeeded(
_ db: Database, _ db: Database,
threadId: String, threadId: String,
threadVariant: SessionThread.Variant, threadVariant: SessionThread.Variant,
lastReadTimestampMs: Int64 lastReadTimestampMs: Int64
) throws { ) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
let change: VolatileThreadInfo = VolatileThreadInfo( let change: VolatileThreadInfo = VolatileThreadInfo(
threadId: threadId, threadId: threadId,
variant: threadVariant, variant: threadVariant,

View File

@ -20,6 +20,7 @@ internal extension SessionUtil {
.appending(contentsOf: columnsRelatedToContacts) .appending(contentsOf: columnsRelatedToContacts)
.appending(contentsOf: columnsRelatedToConvoInfoVolatile) .appending(contentsOf: columnsRelatedToConvoInfoVolatile)
.appending(contentsOf: columnsRelatedToUserGroups) .appending(contentsOf: columnsRelatedToUserGroups)
.appending(contentsOf: columnsRelatedToThreads)
.map { ColumnKey($0) } .map { ColumnKey($0) }
.asSet() .asSet()
@ -34,28 +35,36 @@ internal extension SessionUtil {
) throws { ) throws {
// Since we are doing direct memory manipulation we are using an `Atomic` // Since we are doing direct memory manipulation we are using an `Atomic`
// type which has blocking access in it's `mutate` closure // type which has blocking access in it's `mutate` closure
let needsPush: Bool = try SessionUtil let needsPush: Bool
.config(
for: variant, do {
publicKey: publicKey needsPush = try SessionUtil
) .config(
.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, for: variant,
publicKey: publicKey publicKey: publicKey
)?.save(db) )
.mutate { conf in
return config_needs_push(conf) 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 // Make sure we need a push before scheduling one
guard needsPush else { return } guard needsPush else { return }
@ -80,115 +89,164 @@ internal extension SessionUtil {
.fetchAll(db, ids: updatedThreads.map { $0.id }) .fetchAll(db, ids: updatedThreads.map { $0.id })
.reduce(into: [:]) { result, next in result[next.threadId] = next } .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)
// Update the unread state for the threads first (just in case that's what changed) try SessionUtil.updateMarkedAsUnreadState(db, threads: updatedThreads)
try SessionUtil.updateMarkedAsUnreadState(db, threads: updatedThreads)
// Then update the `hidden` and `priority` values
// Then update the `hidden` and `priority` values try groupedThreads.forEach { variant, threads in
try groupedThreads.forEach { variant, threads in switch variant {
switch variant { case .contact:
case .contact: // If the 'Note to Self' conversation is pinned then we need to custom handle it
// If the 'Note to Self' conversation is pinned then we need to custom handle it // first as it's part of the UserProfile config
// first as it's part of the UserProfile config if let noteToSelf: SessionThread = threads.first(where: { $0.id == userPublicKey }) {
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 }
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .contacts, for: .userProfile,
publicKey: userPublicKey publicKey: userPublicKey
) { conf in ) { conf in
try SessionUtil.upsert( try SessionUtil.updateNoteToSelf(
contactData: remainingThreads hidden: !noteToSelf.shouldBeVisible,
.map { thread in priority: noteToSelf.pinnedPriority
SyncedContactInfo( .map { Int32($0 == 0 ? 0 : max($0, 1)) }
id: thread.id, .defaulting(to: 0),
priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0),
hidden: thread.shouldBeVisible
)
},
in: conf in: conf
) )
} }
}
case .community:
try SessionUtil.performAndPushChange( // Remove the 'Note to Self' convo from the list for updating contact priorities
db, let remainingThreads: [SessionThread] = threads.filter { $0.id != userPublicKey }
for: .userGroups,
publicKey: userPublicKey guard !remainingThreads.isEmpty else { return }
) { conf in
try SessionUtil.upsert( try SessionUtil.performAndPushChange(
communities: threads db,
.compactMap { thread -> CommunityInfo? in for: .contacts,
urlInfo[thread.id].map { urlInfo in publicKey: userPublicKey
CommunityInfo( ) { conf in
urlInfo: urlInfo, try SessionUtil.upsert(
priority: thread.pinnedPriority contactData: remainingThreads
.map { Int32($0 == 0 ? 0 : max($0, 1)) } .map { thread in
.defaulting(to: 0) SyncedContactInfo(
) id: thread.id,
} hidden: !thread.shouldBeVisible,
}, priority: thread.pinnedPriority
in: conf .map { Int32($0 == 0 ? 0 : max($0, 1)) }
) .defaulting(to: 0)
} )
},
case .legacyGroup: in: conf
try SessionUtil.performAndPushChange( )
db, }
for: .userGroups,
publicKey: userPublicKey case .community:
) { conf in try SessionUtil.performAndPushChange(
try SessionUtil.upsert( db,
legacyGroups: threads for: .userGroups,
.map { thread in publicKey: userPublicKey
LegacyGroupInfo( ) { conf in
id: thread.id, try SessionUtil.upsert(
hidden: thread.shouldBeVisible, communities: threads
.compactMap { thread -> CommunityInfo? in
urlInfo[thread.id].map { urlInfo in
CommunityInfo(
urlInfo: urlInfo,
priority: thread.pinnedPriority priority: thread.pinnedPriority
.map { Int32($0 == 0 ? 0 : max($0, 1)) } .map { Int32($0 == 0 ? 0 : max($0, 1)) }
.defaulting(to: 0) .defaulting(to: 0)
) )
}, }
in: conf },
) in: conf
} )
}
case .group: case .legacyGroup:
// TODO: Add this try SessionUtil.performAndPushChange(
break 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 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 // MARK: - ColumnKey
internal extension SessionUtil { internal extension SessionUtil {

View File

@ -25,7 +25,6 @@ internal extension SessionUtil {
var communities: [PrioritisedData<OpenGroupUrlInfo>] = [] var communities: [PrioritisedData<OpenGroupUrlInfo>] = []
var legacyGroups: [LegacyGroupInfo] = [] var legacyGroups: [LegacyGroupInfo] = []
var groups: [PrioritisedData<String>] = []
var community: ugroups_community_info = ugroups_community_info() var community: ugroups_community_info = ugroups_community_info()
var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info() var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info()
let groupsIterator: OpaquePointer = user_groups_iterator_new(conf) let groupsIterator: OpaquePointer = user_groups_iterator_new(conf)
@ -85,18 +84,28 @@ internal extension SessionUtil {
.defaultWith(groupId) .defaultWith(groupId)
.with( .with(
isEnabled: (legacyGroup.disappearing_timer > 0), isEnabled: (legacyGroup.disappearing_timer > 0),
durationSeconds: (legacyGroup.disappearing_timer == 0 ? nil : durationSeconds: TimeInterval(legacyGroup.disappearing_timer)
TimeInterval(legacyGroup.disappearing_timer)
)
), ),
groupMembers: members.map { memberId, admin in groupMembers: members
GroupMember( .filter { _, isAdmin in !isAdmin }
groupId: groupId, .map { memberId, admin in
profileId: memberId, GroupMember(
role: (admin ? .admin : .standard), groupId: groupId,
isHidden: false 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, hidden: legacyGroup.hidden,
priority: legacyGroup.priority priority: legacyGroup.priority
) )
@ -111,7 +120,7 @@ internal extension SessionUtil {
user_groups_iterator_free(groupsIterator) // Need to free the iterator user_groups_iterator_free(groupsIterator) // Need to free the iterator
// If we don't have any conversations then no need to continue // 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 // Extract all community/legacyGroup/group thread priorities
let existingThreadInfo: [String: PriorityVisibilityInfo] = (try? SessionThread let existingThreadInfo: [String: PriorityVisibilityInfo] = (try? SessionThread
@ -187,7 +196,8 @@ internal extension SessionUtil {
guard guard
let name: String = group.name, let name: String = group.name,
let lastKeyPair: ClosedGroupKeyPair = group.lastKeyPair, let lastKeyPair: ClosedGroupKeyPair = group.lastKeyPair,
let members: [GroupMember] = group.groupMembers let members: [GroupMember] = group.groupMembers,
let updatedAdmins: [GroupMember] = group.groupAdmins
else { return } else { return }
if !existingLegacyGroupIds.contains(group.id) { if !existingLegacyGroupIds.contains(group.id) {
@ -201,13 +211,12 @@ internal extension SessionUtil {
secretKey: lastKeyPair.secretKey.bytes secretKey: lastKeyPair.secretKey.bytes
), ),
members: members members: members
.filter { $0.role == .standard } .appending(contentsOf: updatedAdmins) // Admins should also have 'standard' member entries
.map { $0.profileId },
admins: members
.filter { $0.role == .admin }
.map { $0.profileId }, .map { $0.profileId },
admins: updatedAdmins.map { $0.profileId },
expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0), expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0),
messageSentTimestamp: UInt64(latestConfigUpdateSentTimestamp * 1000) messageSentTimestamp: UInt64(latestConfigUpdateSentTimestamp * 1000),
calledFromConfigHandling: true
) )
} }
else { else {
@ -223,11 +232,7 @@ internal extension SessionUtil {
// Add the lastKey if it doesn't already exist // Add the lastKey if it doesn't already exist
let keyPairExists: Bool = ClosedGroupKeyPair let keyPairExists: Bool = ClosedGroupKeyPair
.filter( .filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == lastKeyPair.threadKeyPairHash)
ClosedGroupKeyPair.Columns.threadId == lastKeyPair.threadId &&
ClosedGroupKeyPair.Columns.publicKey == lastKeyPair.publicKey &&
ClosedGroupKeyPair.Columns.secretKey == lastKeyPair.secretKey
)
.isNotEmpty(db) .isNotEmpty(db)
if !keyPairExists { if !keyPairExists {
@ -245,27 +250,70 @@ internal extension SessionUtil {
.saved(db) .saved(db)
// Update the members // Update the members
// TODO: This let updatedMembers: [GroupMember] = members
// TODO: Going to need to decide whether we want to update the 'GroupMember' records in the database based on this config message changing .appending(
// let members: [String] contentsOf: updatedAdmins.map { admin in
// let admins: [String] 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 // Make any thread-specific changes if needed
var threadChanges: [ConfigColumnAssignment] = [] let threadChanges: [ConfigColumnAssignment] = [
// Set the visibility if it's changed (existingThreadInfo[group.id]?.shouldBeVisible == (group.hidden == false) ? nil :
if existingThreadInfo[group.id]?.shouldBeVisible != (group.hidden == false) {
threadChanges.append(
SessionThread.Columns.shouldBeVisible.set(to: (group.hidden == false)) SessionThread.Columns.shouldBeVisible.set(to: (group.hidden == false))
) ),
} (existingThreadInfo[group.id]?.pinnedPriority == group.priority ? nil :
// Set the priority if it's changed
if existingThreadInfo[group.id]?.pinnedPriority != group.priority {
threadChanges.append(
SessionThread.Columns.pinnedPriority.set(to: group.priority) SessionThread.Columns.pinnedPriority.set(to: group.priority)
) )
} ].compactMap { $0 }
if !threadChanges.isEmpty { if !threadChanges.isEmpty {
_ = try? SessionThread _ = try? SessionThread
@ -321,6 +369,7 @@ internal extension SessionUtil {
if let lastKeyPair: ClosedGroupKeyPair = legacyGroup.lastKeyPair { if let lastKeyPair: ClosedGroupKeyPair = legacyGroup.lastKeyPair {
userGroup.pointee.enc_pubkey = lastKeyPair.publicKey.toLibSession() userGroup.pointee.enc_pubkey = lastKeyPair.publicKey.toLibSession()
userGroup.pointee.enc_seckey = lastKeyPair.secretKey.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) // Store the updated group (needs to happen before variables go out of scope)
user_groups_set_legacy_group(conf, userGroup) 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) // Assign all properties to match the updated disappearing messages config (if there is one)
if let updatedConfig: DisappearingMessagesConfiguration = legacyGroup.disappearingConfig { if let updatedConfig: DisappearingMessagesConfiguration = legacyGroup.disappearingConfig {
// TODO: double check the 'isEnabled' flag
userGroup.pointee.disappearing_timer = (!updatedConfig.isEnabled ? 0 : userGroup.pointee.disappearing_timer = (!updatedConfig.isEnabled ? 0 :
Int64(floor(updatedConfig.durationSeconds)) Int64(floor(updatedConfig.durationSeconds))
) )
@ -393,6 +441,9 @@ public extension SessionUtil {
rootToken: String, rootToken: String,
publicKey: String publicKey: String
) throws { ) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -415,6 +466,9 @@ public extension SessionUtil {
} }
static func remove(_ db: Database, server: String, roomToken: String) throws { 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( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -437,9 +491,13 @@ public extension SessionUtil {
latestKeyPairPublicKey: Data, latestKeyPairPublicKey: Data,
latestKeyPairSecretKey: Data, latestKeyPairSecretKey: Data,
latestKeyPairReceivedTimestamp: TimeInterval, latestKeyPairReceivedTimestamp: TimeInterval,
disappearingConfig: DisappearingMessagesConfiguration,
members: Set<String>, members: Set<String>,
admins: Set<String> admins: Set<String>
) throws { ) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -456,6 +514,7 @@ public extension SessionUtil {
secretKey: latestKeyPairSecretKey, secretKey: latestKeyPairSecretKey,
receivedTimestamp: latestKeyPairReceivedTimestamp receivedTimestamp: latestKeyPairReceivedTimestamp
), ),
disappearingConfig: disappearingConfig,
groupMembers: members groupMembers: members
.map { memberId in .map { memberId in
GroupMember( GroupMember(
@ -486,9 +545,13 @@ public extension SessionUtil {
groupPublicKey: String, groupPublicKey: String,
name: String? = nil, name: String? = nil,
latestKeyPair: ClosedGroupKeyPair? = nil, latestKeyPair: ClosedGroupKeyPair? = nil,
disappearingConfig: DisappearingMessagesConfiguration? = nil,
members: Set<String>? = nil, members: Set<String>? = nil,
admins: Set<String>? = nil admins: Set<String>? = nil
) throws { ) throws {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return }
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -500,6 +563,7 @@ public extension SessionUtil {
id: groupPublicKey, id: groupPublicKey,
name: name, name: name,
lastKeyPair: latestKeyPair, lastKeyPair: latestKeyPair,
disappearingConfig: disappearingConfig,
groupMembers: members? groupMembers: members?
.map { memberId in .map { memberId in
GroupMember( GroupMember(
@ -526,6 +590,8 @@ public extension SessionUtil {
} }
static func hide(_ db: Database, legacyGroupIds: [String]) throws { static func hide(_ db: Database, legacyGroupIds: [String]) throws {
guard !legacyGroupIds.isEmpty else { return }
try SessionUtil.performAndPushChange( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -544,6 +610,10 @@ public extension SessionUtil {
} }
static func remove(_ db: Database, legacyGroupIds: [String]) throws { 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( try SessionUtil.performAndPushChange(
db, db,
for: .userGroups, for: .userGroups,
@ -561,9 +631,13 @@ public extension SessionUtil {
// MARK: -- Group Changes // MARK: -- Group Changes
static func hide(_ db: Database, groupIds: [String]) throws { static func hide(_ db: Database, groupIds: [String]) throws {
guard !groupIds.isEmpty else { return }
} }
static func remove(_ db: Database, groupIds: [String]) throws { 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 Foundation
import GRDB import GRDB
@ -44,7 +44,7 @@ internal extension SessionUtil {
return .updateTo( return .updateTo(
url: profilePictureUrl, url: profilePictureUrl,
key: Data( key: Data(
libSessionVal: profilePic.url, libSessionVal: profilePic.key,
count: ProfileManager.avatarAES256KeyByteLength count: ProfileManager.avatarAES256KeyByteLength
), ),
fileName: nil fileName: nil
@ -54,6 +54,52 @@ internal extension SessionUtil {
calledFromConfigHandling: true 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 // 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) // in case the account got into a weird state or restored directly from a migration)
let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey)
@ -91,14 +137,18 @@ internal extension SessionUtil {
} }
static func updateNoteToSelf( static func updateNoteToSelf(
_ db: Database, hidden: Bool? = nil,
priority: Int32, priority: Int32? = nil,
hidden: Bool,
in conf: UnsafeMutablePointer<config_object>? in conf: UnsafeMutablePointer<config_object>?
) throws { ) throws {
guard conf != nil else { throw SessionUtilError.nilConfigObject } guard conf != nil else { throw SessionUtilError.nilConfigObject }
user_profile_set_nts_priority(conf, priority) if let hidden: Bool = hidden {
user_profile_set_nts_hidden(conf, 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 // MARK: - QueryInterfaceRequest
public extension QueryInterfaceRequest { public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord {
// MARK: -- updateAll
@discardableResult @discardableResult
func updateAll( func updateAll(
_ db: Database, _ db: Database,
@ -61,30 +64,16 @@ public extension QueryInterfaceRequest {
) throws -> Int { ) throws -> Int {
let targetAssignments: [ColumnAssignment] = assignments.map { $0.assignment } 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 { guard SessionUtil.assignmentsRequireConfigUpdate(assignments) else {
return try self.updateAll(db, targetAssignments) return try self.updateAll(db, targetAssignments)
} }
switch self { return try self.updateAndFetchAllAndUpdateConfig(db, assignments).count
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)
}
} }
}
// MARK: -- updateAndFetchAll
public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord {
@discardableResult @discardableResult
func updateAndFetchAllAndUpdateConfig( func updateAndFetchAllAndUpdateConfig(
_ db: Database, _ db: Database,
@ -120,9 +109,6 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
} }
// Update the config dump state where needed // Update the config dump state where needed
try SessionUtil.updateThreadPrioritiesIfNeeded(db, assignments, updatedData)
switch self { switch self {
case is QueryInterfaceRequest<Contact>: case is QueryInterfaceRequest<Contact>:
return try SessionUtil.updatingContacts(db, updatedData) return try SessionUtil.updatingContacts(db, updatedData)
@ -130,9 +116,6 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table
case is QueryInterfaceRequest<Profile>: case is QueryInterfaceRequest<Profile>:
return try SessionUtil.updatingProfiles(db, updatedData) return try SessionUtil.updatingProfiles(db, updatedData)
case is QueryInterfaceRequest<ClosedGroup>:
return updatedData
case is QueryInterfaceRequest<SessionThread>: case is QueryInterfaceRequest<SessionThread>:
return try SessionUtil.updatingThreads(db, updatedData) 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 /// 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) /// loaded yet (eg. fresh install)
public static var needsSync: Bool { public static var needsSync: Bool {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return false }
return configStore return configStore
.wrappedValue .wrappedValue
.contains { _, atomicConf in .contains { _, atomicConf in
@ -63,15 +66,30 @@ public enum SessionUtil {
// MARK: - Loading // MARK: - Loading
public static func loadState( public static func loadState(
_ db: Database? = nil,
userPublicKey: String, userPublicKey: String,
ed25519SecretKey: [UInt8]? 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 // Retrieve the existing dumps from the database
let existingDumps: Set<ConfigDump> = Storage.shared let existingDumps: Set<ConfigDump> = ((try? ConfigDump.fetchSet(db)) ?? [])
.read { db in try ConfigDump.fetchSet(db) }
.defaulting(to: [])
let existingDumpVariants: Set<ConfigDump.Variant> = existingDumps let existingDumpVariants: Set<ConfigDump.Variant> = existingDumps
.map { $0.variant } .map { $0.variant }
.asSet() .asSet()
@ -103,7 +121,7 @@ public enum SessionUtil {
} }
} }
internal static func loadState( private static func loadState(
for variant: ConfigDump.Variant, for variant: ConfigDump.Variant,
secretKey ed25519SecretKey: [UInt8], secretKey ed25519SecretKey: [UInt8],
cachedData: Data? cachedData: Data?
@ -265,6 +283,9 @@ public enum SessionUtil {
} }
public static func configHashes(for publicKey: String) -> [String] { public static func configHashes(for publicKey: String) -> [String] {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard Features.useSharedUtilForUserConfig else { return [] }
return Storage.shared return Storage.shared
.read { db -> [String] in .read { db -> [String] in
guard Identity.userExists(db) else { return [] } guard Identity.userExists(db) else { return [] }
@ -342,36 +363,42 @@ public enum SessionUtil {
mergeData.forEach { $0?.deallocate() } mergeData.forEach { $0?.deallocate() }
// Apply the updated states to the database // Apply the updated states to the database
switch next.key { do {
case .userProfile: switch next.key {
try SessionUtil.handleUserProfileUpdate( case .userProfile:
db, try SessionUtil.handleUserProfileUpdate(
in: conf, db,
mergeNeedsDump: config_needs_dump(conf), in: conf,
latestConfigUpdateSentTimestamp: messageSentTimestamp mergeNeedsDump: config_needs_dump(conf),
) latestConfigUpdateSentTimestamp: messageSentTimestamp
)
case .contacts:
try SessionUtil.handleContactsUpdate( case .contacts:
db, try SessionUtil.handleContactsUpdate(
in: conf, db,
mergeNeedsDump: config_needs_dump(conf) in: conf,
) mergeNeedsDump: config_needs_dump(conf)
)
case .convoInfoVolatile:
try SessionUtil.handleConvoInfoVolatileUpdate( case .convoInfoVolatile:
db, try SessionUtil.handleConvoInfoVolatileUpdate(
in: conf, db,
mergeNeedsDump: config_needs_dump(conf) in: conf,
) mergeNeedsDump: config_needs_dump(conf)
)
case .userGroups:
try SessionUtil.handleGroupsUpdate( case .userGroups:
db, try SessionUtil.handleGroupsUpdate(
in: conf, db,
mergeNeedsDump: config_needs_dump(conf), in: conf,
latestConfigUpdateSentTimestamp: messageSentTimestamp 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 // Need to check if the config needs to be dumped (this might have changed

View File

@ -4,18 +4,6 @@
<dict> <dict>
<key>AvailableLibraries</key> <key>AvailableLibraries</key>
<array> <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> <dict>
<key>LibraryIdentifier</key> <key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string> <string>ios-arm64_x86_64-simulator</string>
@ -31,6 +19,18 @@
<key>SupportedPlatformVariant</key> <key>SupportedPlatformVariant</key>
<string>simulator</string> <string>simulator</string>
</dict> </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> </array>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XFWK</string> <string>XFWK</string>

View File

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

View File

@ -8,16 +8,56 @@ import SessionUtilitiesKit
import SessionSnodeKit import SessionSnodeKit
extension MessageReceiver { 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 { switch message.kind {
case .new: try handleNewClosedGroup(db, message: message) case .new: try handleNewClosedGroup(db, message: message)
case .encryptionKeyPair: try handleClosedGroupEncryptionKeyPair(db, message: message)
case .nameChange: try handleClosedGroupNameChanged(db, message: message) case .encryptionKeyPair:
case .membersAdded: try handleClosedGroupMembersAdded(db, message: message) try handleClosedGroupEncryptionKeyPair(
case .membersRemoved: try handleClosedGroupMembersRemoved(db, message: message) db,
case .memberLeft: try handleClosedGroupMemberLeft(db, message: message) threadId: threadId,
case .encryptionKeyPairRequest: threadVariant: threadVariant,
handleClosedGroupEncryptionKeyPairRequest(db, message: message) // Currently not used 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 default: throw MessageReceiverError.invalidMessage
} }
@ -39,7 +79,8 @@ extension MessageReceiver {
members: membersAsData.map { $0.toHexString() }, members: membersAsData.map { $0.toHexString() },
admins: adminsAsData.map { $0.toHexString() }, admins: adminsAsData.map { $0.toHexString() },
expirationTimer: expirationTimer, expirationTimer: expirationTimer,
messageSentTimestamp: sentTimestamp messageSentTimestamp: sentTimestamp,
calledFromConfigHandling: false
) )
} }
@ -51,7 +92,8 @@ extension MessageReceiver {
members: [String], members: [String],
admins: [String], admins: [String],
expirationTimer: UInt32, expirationTimer: UInt32,
messageSentTimestamp: UInt64 messageSentTimestamp: UInt64,
calledFromConfigHandling: Bool
) throws { ) throws {
// With new closed groups we only want to create them if the admin creating the closed group is an // 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 // 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 // Create the group
let thread: SessionThread = try SessionThread let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup) .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true)
.with(shouldBeVisible: true)
.saved(db)
let closedGroup: ClosedGroup = try ClosedGroup( let closedGroup: ClosedGroup = try ClosedGroup(
threadId: groupPublicKey, threadId: groupPublicKey,
name: name, name: name,
@ -103,7 +146,7 @@ extension MessageReceiver {
} }
// Update the DisappearingMessages config // Update the DisappearingMessages config
try thread.disappearingMessagesConfiguration let disappearingConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration
.fetchOne(db) .fetchOne(db)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id))
.with( .with(
@ -113,15 +156,38 @@ extension MessageReceiver {
(24 * 60 * 60) (24 * 60 * 60)
) )
) )
.save(db) .saved(db)
// Store the key pair // Store the key pair if it doesn't already exist
try ClosedGroupKeyPair( let receivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
threadId: groupPublicKey, threadId: groupPublicKey,
publicKey: Data(encryptionKeyPair.publicKey), publicKey: Data(encryptionKeyPair.publicKey),
secretKey: Data(encryptionKeyPair.secretKey), secretKey: Data(encryptionKeyPair.secretKey),
receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) receivedTimestamp: receivedTimestamp
).insert(db) )
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 // Start polling
ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) 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 /// 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. /// sent by the group admin.
private static func handleClosedGroupEncryptionKeyPair(_ db: Database, message: ClosedGroupControlMessage) throws { private static func handleClosedGroupEncryptionKeyPair(
guard _ db: Database,
case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind, threadId: String,
let groupPublicKey: String = (explicitGroupPublicKey?.toHexString() ?? message.groupPublicKey) threadVariant: SessionThread.Variant,
else { return } 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 { guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else {
return SNLog("Couldn't find user X25519 key pair.") 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.") 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 groupAdmins: [GroupMember] = try? closedGroup.admins.fetchAll(db) else { return }
guard let sender: String = message.sender, groupAdmins.contains(where: { $0.profileId == sender }) else { guard let sender: String = message.sender, groupAdmins.contains(where: { $0.profileId == sender }) else {
return SNLog("Ignoring closed group encryption key pair from non-admin.") 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.") SNLog("Received a new closed group encryption key pair.")
} }
private static func handleClosedGroupNameChanged(_ db: Database, message: ClosedGroupControlMessage) throws { private static func handleClosedGroupNameChanged(
guard case let .nameChange(name) = message.kind else { return } _ 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 processIfValid(
_ = try ClosedGroup db,
.filter(id: id) threadId: threadId,
.updateAll(db, ClosedGroup.Columns.name.set(to: name)) threadVariant: threadVariant,
message: message,
// Notify the user if needed messageKind: messageKind,
guard name != closedGroup.name else { return } infoMessageVariant: .infoClosedGroupUpdated,
legacyGroupChanges: { sender, closedGroup, allMembers in
_ = try Interaction( // Update libSession
serverHash: message.serverHash, try? SessionUtil.update(
threadId: thread.id, db,
authorId: sender, groupPublicKey: threadId,
variant: .infoClosedGroupUpdated, name: name
body: ClosedGroupControlMessage.Kind
.nameChange(name: name)
.infoMessage(db, sender: sender),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
) )
).inserted(db)
_ = try ClosedGroup
// Update libSession .filter(id: threadId)
try? SessionUtil.update( .updateAll( // Explicit config update so no need to use 'updateAllAndConfig'
db, db,
groupPublicKey: id, ClosedGroup.Columns.name.set(to: name)
name: name )
) }
} )
} }
private static func handleClosedGroupMembersAdded(_ db: Database, message: ClosedGroupControlMessage) throws { private static func handleClosedGroupMembersAdded(
guard case let .membersAdded(membersAsData) = message.kind else { return } _ 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 try processIfValid(
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { db,
return threadId: threadId,
} threadVariant: threadVariant,
message: message,
// Update the group messageKind: messageKind,
let addedMembers: [String] = membersAsData.map { $0.toHexString() } infoMessageVariant: .infoClosedGroupUpdated,
let currentMemberIds: Set<String> = allGroupMembers legacyGroupChanges: { sender, closedGroup, allMembers in
.filter { $0.role == .standard } // Update the group
.map { $0.profileId } let addedMembers: [String] = membersAsData.map { $0.toHexString() }
.asSet() let currentMemberIds: Set<String> = allMembers
let members: Set<String> = currentMemberIds.union(addedMembers) .filter { $0.role == .standard }
.map { $0.profileId }
// Create records for any new members .asSet()
try addedMembers
.filter { !currentMemberIds.contains($0) } // Update libSession
.forEach { memberId in try? SessionUtil.update(
try GroupMember( db,
groupId: id, groupPublicKey: threadId,
profileId: memberId, members: allMembers
role: .standard, .filter { $0.role == .standard || $0.role == .zombie }
isHidden: false .map { $0.profileId }
).insert(db) .asSet()
} .union(addedMembers),
admins: allMembers
// Send the latest encryption key pair to the added members if the current user is .filter { $0.role == .admin }
// the admin of the group .map { $0.profileId }
// .asSet()
// 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()
) )
).inserted(db)
// Create records for any new members
// Update libSession try addedMembers
try? SessionUtil.update( .filter { !currentMemberIds.contains($0) }
db, .forEach { memberId in
groupPublicKey: id, try GroupMember(
members: allGroupMembers groupId: threadId,
.filter { $0.role == .standard || $0.role == .zombie } profileId: memberId,
.map { $0.profileId } role: .standard,
.asSet() isHidden: false
.union(addedMembers), ).save(db)
admins: allGroupMembers }
.filter { $0.role == .admin }
.map { $0.profileId } // Send the latest encryption key pair to the added members if the current user is
.asSet() // 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 /// Removes the given members from the group IF
/// it wasn't the admin that was removed (that should happen through a `MEMBER_LEFT` message). /// 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). /// 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 /// 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. /// 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 { private static func handleClosedGroupMembersRemoved(
guard case let .membersRemoved(membersAsData) = message.kind else { return } _ 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 let userPublicKey: String = getUserHexEncodedPublicKey(db)
// Check that the admin wasn't removed let removedMemberIds: [String] = membersAsData.map { $0.toHexString() }
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return try processIfValid(
} db,
threadId: threadId,
let removedMembers = membersAsData.map { $0.toHexString() } threadVariant: threadVariant,
let currentMemberIds: Set<String> = allGroupMembers message: message,
.filter { $0.role == .standard } messageKind: messageKind,
.map { $0.profileId } infoMessageVariant: (removedMemberIds.contains(userPublicKey) ?
.asSet() .infoClosedGroupCurrentUserLeft :
let members = currentMemberIds.subtracting(removedMembers) .infoClosedGroupUpdated
),
guard let firstAdminId: String = allGroupMembers.filter({ $0.role == .admin }).first?.profileId, members.contains(firstAdminId) else { legacyGroupChanges: { sender, closedGroup, allMembers in
return SNLog("Ignoring invalid closed group update.") let removedMembers = membersAsData.map { $0.toHexString() }
} let currentMemberIds: Set<String> = allMembers
// Check that the message was sent by the group admin .filter { $0.role == .standard }
guard allGroupMembers.filter({ $0.role == .admin }).contains(where: { $0.profileId == sender }) else { .map { $0.profileId }
return SNLog("Ignoring invalid closed group update.") .asSet()
} let members = currentMemberIds.subtracting(removedMembers)
// 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)
_ = try closedGroup // Check that the group creator is still a member and that the message was
.keyPairs // 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) .deleteAll(db)
let _ = PushNotificationAPI.performOperation( // If the current user was removed:
.unsubscribe, // Stop polling for the group
for: id, // Remove the key pairs associated with the group
publicKey: userPublicKey // Notify the PN server
) let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey)
}
if wasCurrentUserRemoved {
// Notify the user if needed ClosedGroupPoller.shared.stopPolling(for: threadId)
guard members != currentMemberIds else { return }
_ = try closedGroup
_ = try Interaction( .keyPairs
serverHash: message.serverHash, .deleteAll(db)
threadId: thread.id,
authorId: sender, let _ = PushNotificationAPI.performOperation(
variant: (wasCurrentUserRemoved ? .infoClosedGroupCurrentUserLeft : .infoClosedGroupUpdated), .unsubscribe,
body: ClosedGroupControlMessage.Kind for: threadId,
.membersRemoved( publicKey: userPublicKey
members: removedMembers
.asSet()
.intersection(currentMemberIds)
.map { Data(hex: $0) }
) )
.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: /// If a regular member left:
/// Mark them as a zombie (to be removed by the admin later). /// Mark them as a zombie (to be removed by the admin later).
/// If the admin left: /// If the admin left:
/// Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded. /// Unsubscribe from PNs, delete the group public key, etc. as the group will be disbanded.
private static func handleClosedGroupMemberLeft(_ db: Database, message: ClosedGroupControlMessage) throws { private static func handleClosedGroupMemberLeft(
guard case .memberLeft = message.kind else { return } _ 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 try processIfValid(
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { db,
return threadId: threadId,
} threadVariant: threadVariant,
message: message,
let userPublicKey: String = getUserHexEncodedPublicKey(db) messageKind: messageKind,
let didAdminLeave: Bool = allGroupMembers.contains(where: { member in infoMessageVariant: .infoClosedGroupUpdated,
member.role == .admin && member.profileId == sender legacyGroupChanges: { sender, closedGroup, allMembers in
}) let userPublicKey: String = getUserHexEncodedPublicKey(db)
let members: [GroupMember] = allGroupMembers.filter { $0.role == .standard } let didAdminLeave: Bool = allMembers.contains(where: { member in
let membersToRemove: [GroupMember] = members member.role == .admin && member.profileId == sender
.filter { member in })
didAdminLeave || // If the admin leaves the group is disbanded let members: [GroupMember] = allMembers.filter { $0.role == .standard }
member.profileId == sender let membersToRemove: [GroupMember] = members
} .filter { member in
let updatedMemberIds: Set<String> = members didAdminLeave || // If the admin leaves the group is disbanded
.map { $0.profileId } member.profileId == sender
.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)
} }
.map { $0.profileId } let updatedMemberIds: Set<String> = members
.asSet(),
admins: allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId } .map { $0.profileId }
.asSet() .asSet()
) .subtracting(membersToRemove.map { $0.profileId })
}
} // Update libSession
try? SessionUtil.update(
private static func handleClosedGroupEncryptionKeyPairRequest(_ db: Database, message: ClosedGroupControlMessage) { db,
/* groupPublicKey: threadId,
guard case .encryptionKeyPairRequest = message.kind else { return } members: allMembers
let transaction = transaction as! YapDatabaseReadWriteTransaction .filter {
guard let groupPublicKey = message.groupPublicKey else { return } ($0.role == .standard || $0.role == .zombie) &&
performIfValid(for: message, using: transaction) { groupID, _, group in !membersToRemove.contains($0)
let publicKey = message.sender! }
// Guard against self-sends .map { $0.profileId }
guard publicKey != getUserHexEncodedPublicKey() else { .asSet(),
return SNLog("Ignoring invalid closed group update.") 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 // MARK: - Convenience
private static func performIfValid( private static func processIfValid(
_ db: Database, _ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
message: ClosedGroupControlMessage, message: ClosedGroupControlMessage,
_ update: (String, String, SessionThread, ClosedGroup messageKind: ClosedGroupControlMessage.Kind,
) throws -> Void) throws { infoMessageVariant: Interaction.Variant,
guard let groupPublicKey: String = message.groupPublicKey else { return } legacyGroupChanges: (String, ClosedGroup, [GroupMember]) throws -> ()
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { ) throws {
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.")
}
guard let sender: String = message.sender else { return } guard let sender: String = message.sender else { return }
guard let members: [GroupMember] = try? closedGroup.members.fetchAll(db) else { return } guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: threadId) else {
return SNLog("Ignoring group update for nonexistent group.")
// 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.")
} }
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), members: [String](closedGroup.members),
admins: [String](closedGroup.admins), admins: [String](closedGroup.admins),
expirationTimer: closedGroup.expirationTimer, 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 // Add an info message for the user
_ = try Interaction( _ = try Interaction(
serverHash: nil, // Intentionally null so sync messages are seen as duplicates 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 // Loop through all blinded threads and extract any interactions relating to the user accepting
// the message request // the message request
try pendingBlindedIdLookups.forEach { blindedIdLookup in 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 guard
dependencies.sodium.sessionId( dependencies.sodium.sessionId(
senderId, senderId,
@ -111,6 +112,9 @@ extension MessageReceiver {
.filter(ids: blindedContactIds) .filter(ids: blindedContactIds)
.deleteAll(db) .deleteAll(db)
try? SessionUtil
.remove(db, contactIds: blindedContactIds)
try updateContactApprovalStatusIfNeeded( try updateContactApprovalStatusIfNeeded(
db, db,
senderSessionId: userPublicKey, senderSessionId: userPublicKey,

View File

@ -46,9 +46,20 @@ extension MessageReceiver {
) )
} }
// Get or create thread switch threadVariant {
guard let threadInfo: (id: String, variant: SessionThread.Variant) = MessageReceiver.threadInfo(db, message: message, openGroupId: openGroupId) else { case .contact: break // Always continue
throw MessageReceiverError.noThread
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 // Store the message variant so we can run variant-specific behaviours

View File

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

View File

@ -281,6 +281,17 @@ public enum MessageReceiver {
case is TypingIndicator: break case is TypingIndicator: break
default: 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 try SessionThread
.filter(id: threadId) .filter(id: threadId)
.updateAllAndConfig( .updateAllAndConfig(

View File

@ -558,23 +558,25 @@ public struct ProfileManager {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil)) profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
case .updateTo(let url, let key, let fileName): case .updateTo(let url, let key, let fileName):
if if url != profile.profilePictureUrl {
(
url != profile.profilePictureUrl ||
key != profile.profileEncryptionKey
) &&
key.count == ProfileManager.avatarAES256KeyByteLength &&
key != profile.profileEncryptionKey
{
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url)) profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url))
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key))
avatarNeedsDownload = true avatarNeedsDownload = true
targetAvatarUrl = url 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) // Profile filename (this isn't synchronized between devices)
if let fileName: String = fileName { if let fileName: String = fileName {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: 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"?> <?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"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--ShareVC--> <!--Share Nav Controller-->
<scene sceneID="ceB-am-kn3"> <scene sceneID="ceB-am-kn3">
<objects> <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"> <view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <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>( func createIndex<T>(
withCustomName customName: String? = nil, withCustomName customName: String? = nil,
on table: T.Type, on table: T.Type,