Merge branch 'updated-user-config-handling' into disappearing-message-redesign

pull/941/head
Ryan Zhao 2 years ago
commit 43e38c5644

@ -14,35 +14,45 @@ let currentPath = (
/// List of files in currentPath - recursive
var pathFiles: [String] = {
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
fatalError("Could not locate files in path directory: \(currentPath)")
}
guard
let enumerator: FileManager.DirectoryEnumerator = fileManager.enumerator(
at: URL(fileURLWithPath: currentPath),
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
),
let fileUrls: [URL] = enumerator.allObjects as? [URL]
else { fatalError("Could not locate files in path directory: \(currentPath)") }
return files
return fileUrls
.filter {
((try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) && // No directories
!$0.path.contains("Pods/") && // Exclude files under the pods folder
!$0.path.contains(".xcassets") && // Exclude asset bundles
!$0.path.contains(".app/") && // Exclude files in the app build directories
!$0.path.contains(".appex/") && // Exclude files in the extension build directories
!$0.path.localizedCaseInsensitiveContains("tests/") && // Exclude files under test directories
!$0.path.localizedCaseInsensitiveContains("external/") && ( // Exclude files under external directories
// Only include relevant files
$0.path.hasSuffix("Localizable.strings") ||
NSString(string: $0.path).pathExtension == "swift" ||
NSString(string: $0.path).pathExtension == "m"
)
}
.map { $0.path }
}()
/// List of localizable files - not including Localizable files in the Pods
var localizableFiles: [String] = {
return pathFiles
.filter {
$0.hasSuffix("Localizable.strings") &&
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") // Exclude Pods
}
return pathFiles.filter { $0.hasSuffix("Localizable.strings") }
}()
/// List of executable files
var executableFiles: [String] = {
return pathFiles.filter {
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") && // Exclude Pods
(
NSString(string: $0).pathExtension == "swift" ||
NSString(string: $0).pathExtension == "m"
)
$0.hasSuffix(".swift") ||
$0.hasSuffix(".m")
}
}()

@ -592,6 +592,7 @@
FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */; };
FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */; };
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */; };
FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */; };
FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */; };
FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */; };
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
@ -1740,6 +1741,7 @@
FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = "<group>"; };
FD2B4AFE2946C93200AB4848 /* ConfigurationSyncJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationSyncJob.swift; sourceTree = "<group>"; };
FD2B4B032949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "QueryInterfaceRequest+Utilities.swift"; sourceTree = "<group>"; };
FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigMessageReceiveJob.swift; sourceTree = "<group>"; };
FD368A6729DE8F9B000DBF1E /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
FD368A6929DE9E30000DBF1E /* UIContextualAction+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Utilities.swift"; sourceTree = "<group>"; };
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
@ -4322,6 +4324,7 @@
FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */,
C352A2FE25574B6300338F3E /* MessageSendJob.swift */,
C352A31225574F5200338F3E /* MessageReceiveJob.swift */,
FD3003652A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift */,
C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */,
FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
@ -5760,6 +5763,7 @@
C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */,
FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */,
FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */,
FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */,
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */,
FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */,
FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */,

@ -301,7 +301,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
private func handleMembersChanged() {
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 72
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78
tableView.reloadData()
}
@ -440,7 +440,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let updatedName: String = self.name
let userPublicKey: String = self.userPublicKey
let updatedMemberIds: Set<String> = self.membersAndZombies

@ -22,7 +22,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: 0)
private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
@ -300,9 +299,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
)
// Profile picture view (should always be handled as a standard 'contact' profile picture)
let profileShouldBeVisible: Bool = (
cellViewModel.canHaveProfile &&
cellViewModel.shouldShowProfile &&
cellViewModel.profile != nil
)
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
profilePictureViewWidthConstraint.constant = (isGroupThread ? ProfilePictureView.Size.message.viewSize : 0)
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
profilePictureView.isHidden = !cellViewModel.canHaveProfile
profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0)
profilePictureView.update(
publicKey: cellViewModel.authorId,
threadVariant: .contact, // Always show the display picture in 'contact' mode

@ -1,8 +0,0 @@
[main]
host = https://www.transifex.com
[signal-ios.localizablestrings-30]
file_filter = <lang>.lproj/Localizable.strings
source_file = en.lproj/Localizable.strings
source_lang = en

@ -219,11 +219,18 @@ enum Onboarding {
}
func completeRegistration() {
// Set the `lastDisplayNameUpdate` to the current date, so that we don't
// overwrite what the user set in the display name step with whatever we
// find in their swarm (otherwise the user could enter a display name and
// have it immediately overwritten due to the config request running slow)
UserDefaults.standard[.lastDisplayNameUpdate] = Date()
// Set the `lastNameUpdate` to the current date, so that we don't overwrite
// what the user set in the display name step with whatever we find in their
// swarm (otherwise the user could enter a display name and have it immediately
// overwritten due to the config request running slow)
Storage.shared.write { db in
try Profile
.filter(id: getUserHexEncodedPublicKey(db))
.updateAllAndConfig(
db,
Profile.Columns.lastNameUpdate.set(to: Date().timeIntervalSince1970)
)
}
// Notify the app that registration is complete
Identity.didRegister()

@ -257,6 +257,12 @@ final class NukeDataModal: Modal {
PushNotificationAPI.unregister(data).sinkUntilComplete()
}
/// Stop and cancel all current jobs (don't want to inadvertantly have a job store data after it's table has already been cleared)
///
/// **Note:** This is file as long as this process kills the app, if it doesn't then we need an alternate mechanism to flag that
/// the `JobRunner` is allowed to start it's queues again
JobRunner.stopAndClearPendingJobs()
// Clear the app badge and notifications
AppEnvironment.shared.notificationPresenter.clearAllNotifications()
CurrentAppContext().setMainAppBadgeNumber(0)

@ -55,10 +55,10 @@ extension SessionCell {
highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing),
highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self)
]
private lazy var profilePictureViewLeadingConstraint: NSLayoutConstraint = profilePictureView.pin(.leading, to: .leading, of: self)
private lazy var profilePictureViewTrailingConstraint: NSLayoutConstraint = profilePictureView.pin(.trailing, to: .trailing, of: self)
private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [
profilePictureView.pin(.top, to: .top, of: self),
profilePictureView.pin(.leading, to: .leading, of: self),
profilePictureView.pin(.trailing, to: .trailing, of: self),
profilePictureView.pin(.bottom, to: .bottom, of: self)
]
private lazy var searchBarConstraints: [NSLayoutConstraint] = [
@ -269,8 +269,6 @@ extension SessionCell {
radioBorderViewHeightConstraint.isActive = false
radioBorderViewConstraints.forEach { $0.isActive = false }
highlightingBackgroundLabelConstraints.forEach { $0.isActive = false }
profilePictureViewLeadingConstraint.isActive = false
profilePictureViewTrailingConstraint.isActive = false
profilePictureViewConstraints.forEach { $0.isActive = false }
searchBarConstraints.forEach { $0.isActive = false }
buttonConstraints.forEach { $0.isActive = false }
@ -458,10 +456,6 @@ extension SessionCell {
fixedWidthConstraint.constant = profileSize.viewSize
fixedWidthConstraint.isActive = true
profilePictureViewLeadingConstraint.constant = (profileSize.viewSize > AccessoryView.minWidth ? 0 : Values.smallSpacing)
profilePictureViewTrailingConstraint.constant = (profileSize.viewSize > AccessoryView.minWidth ? 0 : -Values.smallSpacing)
profilePictureViewLeadingConstraint.isActive = true
profilePictureViewTrailingConstraint.isActive = true
profilePictureViewConstraints.forEach { $0.isActive = true }
case .search(let placeholder, let accessibility, let searchTermChanged):

@ -158,7 +158,9 @@ enum MockDataGenerator {
id: randomSessionId,
name: (0..<contactNameLength)
.compactMap { _ in stringContent.randomElement(using: &dmThreadRandomGenerator) }
.joined()
.joined(),
lastNameUpdate: Date().timeIntervalSince1970,
lastProfilePictureUpdate: Date().timeIntervalSince1970
)
.saved(db)
@ -237,7 +239,9 @@ enum MockDataGenerator {
id: randomSessionId,
name: (0..<contactNameLength)
.compactMap { _ in stringContent.randomElement(using: &cgThreadRandomGenerator) }
.joined()
.joined(),
lastNameUpdate: Date().timeIntervalSince1970,
lastProfilePictureUpdate: Date().timeIntervalSince1970
)
.saved(db)
@ -365,7 +369,9 @@ enum MockDataGenerator {
id: randomSessionId,
name: (0..<contactNameLength)
.compactMap { _ in stringContent.randomElement(using: &ogThreadRandomGenerator) }
.joined()
.joined(),
lastNameUpdate: Date().timeIntervalSince1970,
lastProfilePictureUpdate: Date().timeIntervalSince1970
)
.saved(db)

@ -59,5 +59,6 @@ public enum SNMessagingKit { // Just to make the external API nice
JobRunner.add(executor: GroupLeavingJob.self, for: .groupLeaving)
JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload)
JobRunner.add(executor: ConfigurationSyncJob.self, for: .configurationSync)
JobRunner.add(executor: ConfigMessageReceiveJob.self, for: .configMessageReceive)
}
}

@ -417,10 +417,12 @@ enum _003_YDBToGRDBMigration: Migration {
try Profile(
id: legacyContact.sessionID,
name: (legacyContact.name ?? legacyContact.sessionID),
lastNameUpdate: 0,
nickname: legacyContact.nickname,
profilePictureUrl: legacyContact.profilePictureURL,
profilePictureFileName: legacyContact.profilePictureFileName,
profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData
profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData,
lastProfilePictureUpdate: 0
).migrationSafeInsert(db)
/// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they
@ -642,7 +644,9 @@ enum _003_YDBToGRDBMigration: Migration {
// constraint violation
try? Profile(
id: profileId,
name: profileId
name: profileId,
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).migrationSafeSave(db)
}
@ -1056,7 +1060,9 @@ enum _003_YDBToGRDBMigration: Migration {
// constraint violation
try Profile(
id: quotedMessage.authorId,
name: quotedMessage.authorId
name: quotedMessage.authorId,
lastNameUpdate: 0,
lastProfilePictureUpdate: 0
).migrationSafeSave(db)
}

@ -20,6 +20,16 @@ enum _013_SessionUtilChanges: Migration {
t.add(.pinnedPriority, .integer)
}
// Add `lastNameUpdate` and `lastProfilePictureUpdate` columns to the profile table
try db.alter(table: Profile.self) { t in
t.add(.lastNameUpdate, .integer)
.notNull()
.defaults(to: 0)
t.add(.lastProfilePictureUpdate, .integer)
.notNull()
.defaults(to: 0)
}
// 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 {
@ -154,6 +164,8 @@ enum _013_SessionUtilChanges: Migration {
.indexed()
t.column(.data, .blob)
.notNull()
t.column(.timestampMs, .integer)
.notNull()
t.primaryKey([.variant, .publicKey])
}

@ -23,6 +23,7 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
// Create the initial config state
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let timestampMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey)
@ -56,7 +57,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
.createDump(
conf: conf,
for: .userProfile,
publicKey: userPublicKey
publicKey: userPublicKey,
timestampMs: timestampMs
)?
.save(db)
}
@ -120,7 +122,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
.createDump(
conf: conf,
for: .contacts,
publicKey: userPublicKey
publicKey: userPublicKey,
timestampMs: timestampMs
)?
.save(db)
}
@ -144,7 +147,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
.createDump(
conf: conf,
for: .convoInfoVolatile,
publicKey: userPublicKey
publicKey: userPublicKey,
timestampMs: timestampMs
)?
.save(db)
}
@ -179,7 +183,8 @@ enum _014_GenerateInitialUserConfigDumps: Migration {
.createDump(
conf: conf,
for: .userGroups,
publicKey: userPublicKey
publicKey: userPublicKey,
timestampMs: timestampMs
)?
.save(db)
}

@ -13,6 +13,7 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist
case variant
case publicKey
case data
case timestampMs
}
public enum Variant: String, Codable, DatabaseValueConvertible {
@ -33,14 +34,19 @@ public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, Persist
/// The data for this dump
public let data: Data
/// When the configDump was created in milliseconds since epoch
public let timestampMs: Int64
internal init(
variant: Variant,
publicKey: String,
data: Data
data: Data,
timestampMs: Int64
) {
self.variant = variant
self.publicKey = publicKey
self.data = data
self.timestampMs = timestampMs
}
}

@ -20,11 +20,13 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
case id
case name
case lastNameUpdate
case nickname
case profilePictureUrl
case profilePictureFileName
case profileEncryptionKey
case lastProfilePictureUpdate
}
/// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant)
@ -33,6 +35,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
/// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
public let name: String
/// The timestamp (in seconds since epoch) that the name was last updated
public let lastNameUpdate: TimeInterval
/// A custom name for the profile set by the current user
public let nickname: String?
@ -45,22 +50,29 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco
/// The key with which the profile is encrypted.
public let profileEncryptionKey: Data?
/// The timestamp (in seconds since epoch) that the profile picture was last updated
public let lastProfilePictureUpdate: TimeInterval
// MARK: - Initialization
public init(
id: String,
name: String,
lastNameUpdate: TimeInterval,
nickname: String? = nil,
profilePictureUrl: String? = nil,
profilePictureFileName: String? = nil,
profileEncryptionKey: Data? = nil
profileEncryptionKey: Data? = nil,
lastProfilePictureUpdate: TimeInterval
) {
self.id = id
self.name = name
self.lastNameUpdate = lastNameUpdate
self.nickname = nickname
self.profilePictureUrl = profilePictureUrl
self.profilePictureFileName = profilePictureFileName
self.profileEncryptionKey = profileEncryptionKey
self.lastProfilePictureUpdate = lastProfilePictureUpdate
}
// MARK: - Description
@ -97,10 +109,12 @@ public extension Profile {
self = Profile(
id: try container.decode(String.self, forKey: .id),
name: try container.decode(String.self, forKey: .name),
lastNameUpdate: try container.decode(TimeInterval.self, forKey: .lastNameUpdate),
nickname: try? container.decode(String.self, forKey: .nickname),
profilePictureUrl: profilePictureUrl,
profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName),
profileEncryptionKey: profileKey
profileEncryptionKey: profileKey,
lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate)
)
}
@ -109,10 +123,12 @@ public extension Profile {
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(lastNameUpdate, forKey: .lastNameUpdate)
try container.encodeIfPresent(nickname, forKey: .nickname)
try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl)
try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName)
try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey)
try container.encode(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate)
}
}
@ -124,6 +140,7 @@ public extension Profile {
var profileKey: Data?
var profilePictureUrl: String?
let sentTimestamp: TimeInterval = (proto.hasTimestamp ? (TimeInterval(proto.timestamp) / 1000) : 0)
// If we have both a `profileKey` and a `profilePicture` then the key MUST be valid
if let profileKeyData: Data = proto.profileKey, profileProto.profilePicture != nil {
@ -134,10 +151,12 @@ public extension Profile {
return Profile(
id: id,
name: displayName,
lastNameUpdate: sentTimestamp,
nickname: nil,
profilePictureUrl: profilePictureUrl,
profilePictureFileName: nil,
profileEncryptionKey: profileKey
profileEncryptionKey: profileKey,
lastProfilePictureUpdate: sentTimestamp
)
}
@ -218,10 +237,12 @@ public extension Profile {
return Profile(
id: id,
name: "",
lastNameUpdate: 0,
nickname: nil,
profilePictureUrl: nil,
profilePictureFileName: nil,
profileEncryptionKey: nil
profileEncryptionKey: nil,
lastProfilePictureUpdate: 0
)
}

@ -42,11 +42,11 @@ public enum AttachmentDownloadJob: JobExecutor {
// if an attachment ends up stuck in a "downloading" state incorrectly
guard attachment.state != .downloading else {
let otherCurrentJobAttachmentIds: Set<String> = JobRunner
.defailsForCurrentlyRunningJobs(of: .attachmentDownload)
.infoForCurrentlyRunningJobs(of: .attachmentDownload)
.filter { key, _ in key != job.id }
.values
.compactMap { data -> String? in
guard let data: Data = data else { return nil }
.compactMap { info -> String? in
guard let data: Data = info.detailsData else { return nil }
return (try? JSONDecoder().decode(Details.self, from: data))?
.attachmentId

@ -0,0 +1,86 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public enum ConfigMessageReceiveJob: JobExecutor {
public static var maxFailureCount: Int = 0
public static var requiresThreadId: Bool = true
public static let requiresInteractionId: Bool = false
public static func run(
_ job: Job,
queue: DispatchQueue,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
/// When the `configMessageReceive` job fails we want to unblock any `messageReceive` jobs it was blocking
/// to ensure the user isn't losing any messages - this generally _shouldn't_ happen but if it does then having a temporary
/// "outdated" state due to standard messages which would have been invalidated by a config change incorrectly being
/// processed is less severe then dropping a bunch on messages just because they were processed in the same poll as
/// invalid config messages
let removeDependencyOnMessageReceiveJobs: () -> () = {
guard let jobId: Int64 = job.id else { return }
Storage.shared.write { db in
try JobDependencies
.filter(JobDependencies.Columns.dependantId == jobId)
.joining(
required: JobDependencies.job
.filter(Job.Columns.variant == Job.Variant.messageReceive)
)
.deleteAll(db)
}
}
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
removeDependencyOnMessageReceiveJobs()
failure(job, JobRunnerError.missingRequiredDetails, true)
return
}
// Ensure no standard messages are sent through this job
guard !details.messages.contains(where: { $0.variant != .sharedConfigMessage }) else {
SNLog("[ConfigMessageReceiveJob] Standard messages incorrectly sent to the 'configMessageReceive' job")
removeDependencyOnMessageReceiveJobs()
failure(job, MessageReceiverError.invalidMessage, true)
return
}
var lastError: Error?
let sharedConfigMessages: [SharedConfigMessage] = details.messages
.compactMap { $0.message as? SharedConfigMessage }
Storage.shared.write { db in
// Send any SharedConfigMessages to the SessionUtil to handle it
do {
try SessionUtil.handleConfigMessages(
db,
messages: sharedConfigMessages,
publicKey: (job.threadId ?? "")
)
}
catch { lastError = error }
}
// Handle the result
switch lastError {
case .some(let error):
removeDependencyOnMessageReceiveJobs()
failure(job, error, true)
case .none: success(job, false)
}
}
}
// MARK: - ConfigMessageReceiveJob.Details
extension ConfigMessageReceiveJob {
typealias Details = MessageReceiveJob.Details
}

@ -9,7 +9,7 @@ import SessionUtilitiesKit
public enum ConfigurationSyncJob: JobExecutor {
public static let maxFailureCount: Int = -1
public static let requiresThreadId: Bool = false
public static let requiresThreadId: Bool = true
public static let requiresInteractionId: Bool = false
private static let maxRunFrequency: TimeInterval = 3
@ -25,13 +25,29 @@ public enum ConfigurationSyncJob: JobExecutor {
Identity.userCompletedRequiredOnboarding()
else { return success(job, true) }
// On startup it's possible for multiple ConfigSyncJob's to run at the same time (which is
// redundant) so check if there is another job already running and, if so, defer this job
let jobDetails: [Int64: Data?] = JobRunner.defailsForCurrentlyRunningJobs(of: .configurationSync)
guard jobDetails.setting(job.id, nil).count == 0 else {
deferred(job) // We will re-enqueue when needed
return
// It's possible for multiple ConfigSyncJob's with the same target (user/group) to try to run at the
// same time since as soon as one is started we will enqueue a second one, rather than adding dependencies
// between the jobs we just continue to defer the subsequent job while the first one is running in
// order to prevent multiple configurationSync jobs with the same target from running at the same time
guard
JobRunner
.infoForCurrentlyRunningJobs(of: .configurationSync)
.filter({ key, info in
key != job.id && // Exclude this job
info.threadId == job.threadId // Exclude jobs for different ids
})
.isEmpty
else {
// Defer the job to run 'maxRunFrequency' from when this one ran (if we don't it'll try start
// it again immediately which is pointless)
let updatedJob: Job? = Storage.shared.write { db in
try job
.with(nextRunTimestamp: Date().timeIntervalSince1970 + maxRunFrequency)
.saved(db)
}
SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") deferred due to in progress job")
return deferred(updatedJob ?? job)
}
// If we don't have a userKeyPair yet then there is no need to sync the configuration
@ -42,16 +58,15 @@ public enum ConfigurationSyncJob: JobExecutor {
let pendingConfigChanges: [SessionUtil.OutgoingConfResult] = Storage.shared
.read({ db in try SessionUtil.pendingChanges(db, publicKey: publicKey) })
else {
failure(job, StorageError.generic, false)
return
SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") failed due to invalid data")
return failure(job, StorageError.generic, false)
}
// If there are no pending changes then the job can just complete (next time something
// is updated we want to try and run immediately so don't scuedule another run in this case)
guard !pendingConfigChanges.isEmpty else {
SNLog("[ConfigurationSyncJob] Completed with no pending changes")
success(job, true)
return
SNLog("[ConfigurationSyncJob] For \(publicKey) completed with no pending changes")
return success(job, true)
}
// Identify the destination and merge all obsolete hashes into a single set
@ -63,6 +78,8 @@ public enum ConfigurationSyncJob: JobExecutor {
.map { $0.obsoleteHashes }
.reduce([], +)
.asSet()
let jobStartTimestamp: TimeInterval = Date().timeIntervalSince1970
SNLog("[ConfigurationSyncJob] For \(publicKey) started with \(pendingConfigChanges.count) change\(pendingConfigChanges.count == 1 ? "" : "s")")
Storage.shared
.readPublisher { db in
@ -119,9 +136,9 @@ public enum ConfigurationSyncJob: JobExecutor {
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: SNLog("[ConfigurationSyncJob] Completed")
case .finished: SNLog("[ConfigurationSyncJob] For \(publicKey) completed")
case .failure(let error):
SNLog("[ConfigurationSyncJob] Failed due to error: \(error)")
SNLog("[ConfigurationSyncJob] For \(publicKey) failed due to error: \(error)")
failure(job, error, false)
}
},
@ -137,7 +154,7 @@ public enum ConfigurationSyncJob: JobExecutor {
// When we complete the 'ConfigurationSync' job we want to immediately schedule
// another one with a 'nextRunTimestamp' set to the 'maxRunFrequency' value to
// throttle the config sync requests
let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency)
let nextRunTimestamp: TimeInterval = (jobStartTimestamp + maxRunFrequency)
// If another 'ConfigurationSync' job was scheduled then update that one
// to run at 'nextRunTimestamp' and make the current job stop
@ -146,6 +163,7 @@ public enum ConfigurationSyncJob: JobExecutor {
.filter(Job.Columns.id != job.id)
.filter(Job.Columns.variant == Job.Variant.configurationSync)
.filter(Job.Columns.threadId == publicKey)
.order(Job.Columns.nextRunTimestamp.asc)
.fetchOne(db)
{
// If the next job isn't currently running then delay it's start time
@ -175,7 +193,6 @@ public enum ConfigurationSyncJob: JobExecutor {
// MARK: - Convenience
public extension ConfigurationSyncJob {
static func enqueue(_ db: Database, publicKey: String) {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard SessionUtil.userConfigsEnabled(db) else {

@ -25,10 +25,17 @@ public enum MessageReceiveJob: JobExecutor {
return
}
// Ensure no config messages are sent through this job
guard !details.messages.contains(where: { $0.variant == .sharedConfigMessage }) else {
SNLog("[MessageReceiveJob] Config messages incorrectly sent to the 'messageReceive' job")
failure(job, MessageReceiverError.invalidSharedConfigMessageHandling, true)
return
}
var updatedJob: Job = job
var lastError: Error?
var remainingMessagesToProcess: [Details.MessageInfo] = []
let nonConfigMessages: [(info: Details.MessageInfo, proto: SNProtoContent)] = details.messages
let messageData: [(info: Details.MessageInfo, proto: SNProtoContent)] = details.messages
.filter { $0.variant != .sharedConfigMessage }
.compactMap { messageInfo -> (info: Details.MessageInfo, proto: SNProtoContent)? in
do {
@ -44,19 +51,9 @@ public enum MessageReceiveJob: JobExecutor {
return nil
}
}
let sharedConfigMessages: [SharedConfigMessage] = details.messages
.compactMap { $0.message as? SharedConfigMessage }
Storage.shared.write { db in
// Send any SharedConfigMessages to the SessionUtil to handle it
try SessionUtil.handleConfigMessages(
db,
messages: sharedConfigMessages,
publicKey: (job.threadId ?? "")
)
// Handle the remaining messages
for (messageInfo, protoContent) in nonConfigMessages {
for (messageInfo, protoContent) in messageData {
do {
try MessageReceiver.handle(
db,
@ -98,6 +95,8 @@ public enum MessageReceiveJob: JobExecutor {
// If any messages failed to process then we want to update the job to only include
// those failed messages
guard !remainingMessagesToProcess.isEmpty else { return }
updatedJob = try job
.with(
details: Details(

@ -42,13 +42,14 @@ public final class SharedConfigMessage: ControlMessage {
public init(
kind: Kind,
seqNo: Int64,
data: Data
data: Data,
sentTimestamp: UInt64? = nil
) {
self.kind = kind
self.seqNo = seqNo
self.data = data
super.init()
super.init(sentTimestamp: sentTimestamp)
}
// MARK: - Codable

@ -21,13 +21,14 @@ public enum MessageReceiverError: LocalizedError {
case noGroupKeyPair
case invalidSharedConfigMessageHandling
case requiredThreadNotInConfig
case outdatedMessage
public var isRetryable: Bool {
switch self {
case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage,
.invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature,
.noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed,
.invalidSharedConfigMessageHandling, .requiredThreadNotInConfig:
.invalidSharedConfigMessageHandling, .requiredThreadNotInConfig, .outdatedMessage:
return false
default: return true
@ -57,6 +58,7 @@ public enum MessageReceiverError: LocalizedError {
case .invalidSharedConfigMessageHandling: return "Invalid handling of a shared config message."
case .requiredThreadNotInConfig: return "Required thread not in config."
case .outdatedMessage: return "Message was sent before a config change which would have removed the message."
}
}
}

@ -13,8 +13,31 @@ extension MessageReceiver {
threadVariant: SessionThread.Variant,
message: CallMessage
) throws {
let timestampMs: Int64 = (message.sentTimestamp.map { Int64($0) } ?? SnodeAPI.currentOffsetTimestampMs())
// Only support calls from contact threads
guard threadVariant == .contact else { return }
guard
threadVariant == .contact,
/// Only process the message if the thread `shouldBeVisible` or it was sent after the libSession buffer period
(
SessionThread
.filter(id: threadId)
.filter(SessionThread.Columns.shouldBeVisible == true)
.isNotEmpty(db) ||
SessionUtil.conversationInConfig(
db,
threadId: threadId,
threadVariant: threadVariant,
visibleOnly: true
) ||
SessionUtil.canPerformChange(
db,
threadId: threadId,
targetConfig: .contacts,
changeTimestampMs: timestampMs
)
)
else { return }
switch message.kind {
case .preOffer: try MessageReceiver.handleNewCallMessage(db, message: message)

@ -69,7 +69,36 @@ extension MessageReceiver {
guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else {
return
}
guard let sentTimestamp: UInt64 = message.sentTimestamp else { return }
guard
let sentTimestamp: UInt64 = message.sentTimestamp,
SessionUtil.canPerformChange(
db,
threadId: publicKeyAsData.toHexString(),
targetConfig: .userGroups,
changeTimestampMs: Int64(sentTimestamp)
)
else {
// If the closed group already exists then store the encryption keys (just in case - there can be
// some weird edge-cases where we don't have keys we need if we don't store them)
let groupPublicKey: String = publicKeyAsData.toHexString()
let receivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000)
let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair(
threadId: groupPublicKey,
publicKey: Data(encryptionKeyPair.publicKey),
secretKey: Data(encryptionKeyPair.secretKey),
receivedTimestamp: receivedTimestamp
)
guard
ClosedGroup.filter(id: groupPublicKey).isNotEmpty(db),
!ClosedGroupKeyPair
.filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == newKeyPair.threadKeyPairHash)
.isNotEmpty(db)
else { return SNLog("Ignoring outdated NEW legacy group message due to more recent config state") }
try newKeyPair.insert(db)
return
}
try handleNewClosedGroup(
db,
@ -473,16 +502,11 @@ extension MessageReceiver {
let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey)
if wasCurrentUserRemoved {
ClosedGroupPoller.shared.stopPolling(for: threadId)
_ = try closedGroup
.keyPairs
.deleteAll(db)
let _ = PushNotificationAPI.performOperation(
.unsubscribe,
for: threadId,
publicKey: userPublicKey
try ClosedGroup.removeKeysAndUnsubscribe(
db,
threadId: threadId,
removeGroupData: true,
calledFromConfigHandling: false
)
}
}
@ -584,29 +608,41 @@ extension MessageReceiver {
return SNLog("Ignoring group update for nonexistent group.")
}
// 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
let timestampMs: Int64 = (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
// Only actually make the change if SessionUtil says we can (we always want to insert the info
// message though)
if SessionUtil.canPerformChange(db, threadId: threadId, targetConfig: .userGroups, changeTimestampMs: timestampMs) {
// 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
}
}
// Ensure the group still exists before inserting the info message
guard ClosedGroup.filter(id: threadId).isNotEmpty(db) else { return }
// Insert the info message for this group control message
_ = try Interaction(
serverHash: message.serverHash,

@ -3,6 +3,7 @@
import Foundation
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
extension MessageReceiver {
internal static func handleDataExtractionNotification(
@ -11,11 +12,45 @@ extension MessageReceiver {
threadVariant: SessionThread.Variant,
message: DataExtractionNotification
) throws {
let timestampMs: Int64 = (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
guard
threadVariant == .contact,
let sender: String = message.sender,
let messageKind: DataExtractionNotification.Kind = message.kind
else { return }
else { throw MessageReceiverError.invalidMessage }
/// Only process the message if the thread `shouldBeVisible` or it was sent after the libSession buffer period
guard
SessionThread
.filter(id: threadId)
.filter(SessionThread.Columns.shouldBeVisible == true)
.isNotEmpty(db) ||
SessionUtil.conversationInConfig(
db,
threadId: threadId,
threadVariant: threadVariant,
visibleOnly: true
) ||
SessionUtil.canPerformChange(
db,
threadId: threadId,
targetConfig: {
switch threadVariant {
case .contact:
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
return (threadId == currentUserPublicKey ? .userProfile : .contacts)
default: return .userGroups
}
}(),
changeTimestampMs: timestampMs
)
else { throw MessageReceiverError.outdatedMessage }
_ = try Interaction(
serverHash: message.serverHash,
@ -27,10 +62,7 @@ extension MessageReceiver {
case .mediaSaved: return .infoMediaSavedNotification
}
}(),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
timestampMs: timestampMs
).inserted(db)
}
}

@ -16,9 +16,9 @@ extension MessageReceiver {
// Only process these for contact and legacy groups (new groups handle it separately)
(threadVariant == .contact || threadVariant == .legacyGroup),
let sender: String = message.sender
else { return }
else { throw MessageReceiverError.invalidMessage }
// Update the configuration
// Generate an updated configuration
//
// Note: Messages which had been sent during the previous configuration will still
// use it's settings (so if you enable, send a message and then disable disappearing
@ -56,51 +56,79 @@ extension MessageReceiver {
type: defaultType
)
try remoteConfig.save(db)
let timestampMs: Int64 = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set
// Remove previous info messages
_ = try Interaction
.filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.variant == Interaction.Variant.infoDisappearingMessagesUpdate)
.deleteAll(db)
// Only actually make the change if SessionUtil says we can (we always want to insert the info
// message though)
let canPerformChange: Bool = SessionUtil.canPerformChange(
db,
threadId: threadId,
targetConfig: {
switch threadVariant {
case .contact:
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
return (threadId == currentUserPublicKey ? .userProfile : .contacts)
default: return .userGroups
}
}(),
changeTimestampMs: timestampMs
)
// Contacts & legacy closed groups need to update the SessionUtil
switch threadVariant {
case .contact:
try SessionUtil
.update(
db,
sessionId: threadId,
disappearingMessagesConfig: remoteConfig
)
case .legacyGroup:
try SessionUtil
.update(
db,
groupPublicKey: threadId,
disappearingConfig: remoteConfig
)
// Only update libSession if we can perform the change
if canPerformChange {
// Contacts & legacy closed groups need to update the SessionUtil
switch threadVariant {
case .contact:
try SessionUtil
.update(
db,
sessionId: threadId,
disappearingMessagesConfig: remoteConfig
)
default: break
case .legacyGroup:
try SessionUtil
.update(
db,
groupPublicKey: threadId,
disappearingConfig: remoteConfig
)
default: break
}
}
// Add an info message for the user
_ = try Interaction(
serverHash: nil, // Intentionally null so sync messages are seen as duplicates
threadId: threadId,
authorId: sender,
variant: .infoDisappearingMessagesUpdate,
body: remoteConfig.messageInfoString(
with: (sender != getUserHexEncodedPublicKey(db) ?
Profile.displayName(db, id: sender) :
nil
// Only save the updated config if we can perform the change
if canPerformChange {
// Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate
// then the interaction unique constraint will prevent the code from getting here)
try remoteConfig.save(db)
// Remove previous info messages
_ = try Interaction
.filter(Interaction.Columns.threadId == threadId)
.filter(Interaction.Columns.variant == Interaction.Variant.infoDisappearingMessagesUpdate)
.deleteAll(db)
// Add an info message for the user
_ = try Interaction(
serverHash: nil, // Intentionally null so sync messages are seen as duplicates
threadId: threadId,
authorId: sender,
variant: .infoDisappearingMessagesUpdate,
body: remoteConfig.messageInfoString(
with: (sender != getUserHexEncodedPublicKey(db) ?
Profile.displayName(db, id: sender) :
nil
),
isPreviousOff: false
),
isPreviousOff: false
),
timestampMs: Int64(message.sentTimestamp ?? 0), // Default to `0` if not set
expiresInSeconds: (remoteConfig.isEnabled ? nil : localConfig.durationSeconds)
).inserted(db)
timestampMs: timestampMs,
expiresInSeconds: (remoteConfig.isEnabled ? nil : localConfig.durationSeconds)
).inserted(db)
}
}
internal static func updateDisappearingMessagesConfigurationIfNeeded(

@ -13,11 +13,37 @@ extension MessageReceiver {
dependencies: SMKDependencies
) throws {
let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let timestampMs: Int64 = (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
var blindedContactIds: [String] = []
// Ignore messages which were sent from the current user
guard message.sender != userPublicKey else { return }
guard let senderId: String = message.sender else { return }
guard
message.sender != userPublicKey,
let senderId: String = message.sender
else { throw MessageReceiverError.invalidMessage }
/// Only process the message if the thread `shouldBeVisible` or it was sent after the libSession buffer period
guard
SessionThread
.filter(id: senderId)
.filter(SessionThread.Columns.shouldBeVisible == true)
.isNotEmpty(db) ||
SessionUtil.conversationInConfig(
db,
threadId: senderId,
threadVariant: .contact,
visibleOnly: true
) ||
SessionUtil.canPerformChange(
db,
threadId: senderId,
targetConfig: .contacts,
changeTimestampMs: timestampMs
)
else { throw MessageReceiverError.outdatedMessage }
// Update profile if needed (want to do this regardless of whether the message exists or
// not to ensure the profile info gets sync between a users devices at every chance)
@ -134,10 +160,7 @@ extension MessageReceiver {
threadId: unblindedThread.id,
authorId: senderId,
variant: .infoMessageRequestAccepted,
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
timestampMs: timestampMs
).inserted(db)
}

@ -3,6 +3,7 @@
import Foundation
import GRDB
import Sodium
import SessionSnodeKit
import SessionUtilitiesKit
extension MessageReceiver {
@ -22,6 +23,32 @@ extension MessageReceiver {
// seconds to maintain the accuracy)
let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000)
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false)
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
/// Only process the message if the thread `shouldBeVisible` or it was sent after the libSession buffer period
guard
SessionThread
.filter(id: threadId)
.filter(SessionThread.Columns.shouldBeVisible == true)
.isNotEmpty(db) ||
SessionUtil.conversationInConfig(
db,
threadId: threadId,
threadVariant: threadVariant,
visibleOnly: true
) ||
SessionUtil.canPerformChange(
db,
threadId: threadId,
targetConfig: {
switch threadVariant {
case .contact: return (threadId == currentUserPublicKey ? .userProfile : .contacts)
default: return .userGroups
}
}(),
changeTimestampMs: (message.sentTimestamp.map { Int64($0) } ?? SnodeAPI.currentOffsetTimestampMs())
)
else { throw MessageReceiverError.outdatedMessage }
// Update profile if needed (want to do this regardless of whether the message exists or
// not to ensure the profile info gets sync between a users devices at every chance)
@ -63,7 +90,6 @@ extension MessageReceiver {
}
// Store the message variant so we can run variant-specific behaviours
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: threadId, variant: threadVariant, shouldBeVisible: nil)
let maybeOpenGroup: OpenGroup? = {

@ -248,10 +248,11 @@ public class Poller {
var messageCount: Int = 0
var processedMessages: [Message] = []
var hadValidHashUpdate: Bool = false
var jobsToRun: [Job] = []
var configMessageJobsToRun: [Job] = []
var standardMessageJobsToRun: [Job] = []
Storage.shared.write { db in
allMessages
let allProcessedMessages: [ProcessedMessage] = allMessages
.compactMap { message -> ProcessedMessage? in
do {
return try Message.processRawReceivedMessage(db, rawMessage: message)
@ -284,6 +285,39 @@ public class Poller {
return nil
}
}
// Add a job to process the config messages first
let configJobIds: [Int64] = allProcessedMessages
.filter { $0.messageInfo.variant == .sharedConfigMessage }
.grouped { threadId, _, _, _ in threadId }
.compactMap { threadId, threadMessages in
messageCount += threadMessages.count
processedMessages += threadMessages.map { $0.messageInfo.message }
let jobToRun: Job? = Job(
variant: .configMessageReceive,
behaviour: .runOnce,
threadId: threadId,
details: ConfigMessageReceiveJob.Details(
messages: threadMessages.map { $0.messageInfo },
calledFromBackgroundPoller: calledFromBackgroundPoller
)
)
configMessageJobsToRun = configMessageJobsToRun.appending(jobToRun)
// If we are force-polling then add to the JobRunner so they are
// persistent and will retry on the next app run if they fail but
// don't let them auto-start
let updatedJob: Job? = JobRunner
.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
return updatedJob?.id
}
// Add jobs for processing non-config messages which are dependant on the config message
// processing jobs
allProcessedMessages
.filter { $0.messageInfo.variant != .sharedConfigMessage }
.grouped { threadId, _, _, _ in threadId }
.forEach { threadId, threadMessages in
messageCount += threadMessages.count
@ -298,12 +332,29 @@ public class Poller {
calledFromBackgroundPoller: calledFromBackgroundPoller
)
)
jobsToRun = jobsToRun.appending(jobToRun)
standardMessageJobsToRun = standardMessageJobsToRun.appending(jobToRun)
// If we are force-polling then add to the JobRunner so they are
// persistent and will retry on the next app run if they fail but
// don't let them auto-start
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
let updatedJob: Job? = JobRunner
.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
// Create the dependency between the jobs
if let updatedJobId: Int64 = updatedJob?.id {
do {
try configJobIds.forEach { configJobId in
try JobDependencies(
jobId: updatedJobId,
dependantId: configJobId
)
.insert(db)
}
}
catch {
SNLog("Failed to add dependency between config processing and non-config processing messageReceive jobs.")
}
}
}
// Clean up message hashes and add some logs about the poll results
@ -334,11 +385,11 @@ public class Poller {
// We want to try to handle the receive jobs immediately in the background
return Publishers
.MergeMany(
jobsToRun.map { job -> AnyPublisher<Void, Error> in
configMessageJobsToRun.map { job -> AnyPublisher<Void, Error> in
Deferred {
Future<Void, Error> { resolver in
// Note: In the background we just want jobs to fail silently
MessageReceiveJob.run(
ConfigMessageReceiveJob.run(
job,
queue: queue,
success: { _, _ in resolver(Result.success(())) },
@ -351,6 +402,27 @@ public class Poller {
}
)
.collect()
.flatMap { _ in
Publishers
.MergeMany(
standardMessageJobsToRun.map { job -> AnyPublisher<Void, Error> in
Deferred {
Future<Void, Error> { resolver in
// Note: In the background we just want jobs to fail silently
MessageReceiveJob.run(
job,
queue: queue,
success: { _, _ in resolver(Result.success(())) },
failure: { _, _, _ in resolver(Result.success(())) },
deferred: { _ in resolver(Result.success(())) }
)
}
}
.eraseToAnyPublisher()
}
)
.collect()
}
.map { _ in processedMessages }
.eraseToAnyPublisher()
}

@ -35,7 +35,7 @@ internal extension SessionUtil {
_ db: Database,
in conf: UnsafeMutablePointer<config_object>?,
mergeNeedsDump: Bool,
latestConfigUpdateSentTimestamp: TimeInterval
latestConfigSentTimestampMs: Int64
) throws {
typealias ContactData = [
String: (
@ -69,6 +69,7 @@ internal extension SessionUtil {
let profileResult: Profile = Profile(
id: contactId,
name: String(libSessionVal: contact.name),
lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000),
nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true),
profilePictureUrl: profilePictureUrl,
profileEncryptionKey: (profilePictureUrl == nil ? nil :
@ -76,14 +77,15 @@ internal extension SessionUtil {
libSessionVal: contact.profile_pic.key,
count: ProfileManager.avatarAES256KeyByteLength
)
)
),
lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000)
)
let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration(
threadId: contactId,
isEnabled: contact.exp_seconds > 0,
durationSeconds: TimeInterval(contact.exp_seconds),
type: DisappearingMessagesConfiguration.DisappearingMessageType(sessionUtilType: contact.exp_mode),
lastChangeTimestampMs: Int64(latestConfigUpdateSentTimestamp)
lastChangeTimestampMs: latestConfigSentTimestampMs
)
contactData[contactId] = (
@ -112,12 +114,23 @@ internal extension SessionUtil {
// observation system can't differ between update calls which do and don't change anything)
let contact: Contact = Contact.fetchOrCreate(db, id: sessionId)
let profile: Profile = Profile.fetchOrCreate(db, id: sessionId)
let profileNameShouldBeUpdated: Bool = (
!data.profile.name.isEmpty &&
profile.name != data.profile.name &&
profile.lastNameUpdate < data.profile.lastNameUpdate
)
let profilePictureShouldBeUpdated: Bool = (
(
profile.profilePictureUrl != data.profile.profilePictureUrl ||
profile.profileEncryptionKey != data.profile.profileEncryptionKey
) &&
profile.lastProfilePictureUpdate < data.profile.lastProfilePictureUpdate
)
if
(!data.profile.name.isEmpty && profile.name != data.profile.name) ||
profileNameShouldBeUpdated ||
profile.nickname != data.profile.nickname ||
profile.profilePictureUrl != data.profile.profilePictureUrl ||
profile.profileEncryptionKey != data.profile.profileEncryptionKey
profilePictureShouldBeUpdated
{
try profile.save(db)
try Profile
@ -125,9 +138,12 @@ internal extension SessionUtil {
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
db,
[
(data.profile.name.isEmpty || profile.name == data.profile.name ? nil :
(!profileNameShouldBeUpdated ? nil :
Profile.Columns.name.set(to: data.profile.name)
),
(!profileNameShouldBeUpdated ? nil :
Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate)
),
(profile.nickname == data.profile.nickname ? nil :
Profile.Columns.nickname.set(to: data.profile.nickname)
),
@ -136,6 +152,9 @@ internal extension SessionUtil {
),
(profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil :
Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey)
),
(!profilePictureShouldBeUpdated ? nil :
Profile.Columns.lastProfilePictureUpdate.set(to: data.profile.lastProfilePictureUpdate)
)
].compactMap { $0 }
)

@ -9,6 +9,11 @@ import SessionUtilitiesKit
// MARK: - Convenience
internal extension SessionUtil {
/// This is a buffer period within which we will process messages which would result in a config change, any message which would normally
/// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not
/// actually have it's changes applied (info messages would still be inserted though)
static let configChangeBufferPeriod: TimeInterval = (2 * 60)
static let columnsRelatedToThreads: [ColumnExpression] = [
SessionThread.Columns.pinnedPriority,
SessionThread.Columns.shouldBeVisible
@ -66,7 +71,8 @@ internal extension SessionUtil {
try SessionUtil.createDump(
conf: conf,
for: variant,
publicKey: publicKey
publicKey: publicKey,
timestampMs: Int64(Date().timeIntervalSince1970 * 1000)
)?.save(db)
return config_needs_push(conf)
@ -293,6 +299,35 @@ internal extension SessionUtil {
}
}
}
static func canPerformChange(
_ db: Database,
threadId: String,
targetConfig: ConfigDump.Variant,
changeTimestampMs: Int64
) -> Bool {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard SessionUtil.userConfigsEnabled(db) else { return true }
let targetPublicKey: String = {
switch targetConfig {
default: return getUserHexEncodedPublicKey(db)
}
}()
let configDumpTimestampMs: Int64 = (try? ConfigDump
.filter(
ConfigDump.Columns.variant == targetConfig &&
ConfigDump.Columns.publicKey == targetPublicKey
)
.select(.timestampMs)
.asRequest(of: Int64.self)
.fetchOne(db))
.defaulting(to: 0)
// Ensure the change occurred after the last config message was handled (minus the buffer period)
return (changeTimestampMs >= (configDumpTimestampMs - Int64(SessionUtil.configChangeBufferPeriod * 1000)))
}
}
// MARK: - External Outgoing Changes

@ -29,7 +29,7 @@ internal extension SessionUtil {
_ db: Database,
in conf: UnsafeMutablePointer<config_object>?,
mergeNeedsDump: Bool,
latestConfigUpdateSentTimestamp: TimeInterval
latestConfigSentTimestampMs: Int64
) throws {
guard mergeNeedsDump else { return }
guard conf != nil else { throw SessionUtilError.nilConfigObject }
@ -219,7 +219,7 @@ internal extension SessionUtil {
.map { $0.profileId },
admins: updatedAdmins.map { $0.profileId },
expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0),
formationTimestampMs: UInt64((group.joinedAt ?? Int64(latestConfigUpdateSentTimestamp)) * 1000),
formationTimestampMs: UInt64((group.joinedAt.map { $0 * 1000 } ?? latestConfigSentTimestampMs)),
calledFromConfigHandling: true
)
}

@ -18,7 +18,7 @@ internal extension SessionUtil {
_ db: Database,
in conf: UnsafeMutablePointer<config_object>?,
mergeNeedsDump: Bool,
latestConfigUpdateSentTimestamp: TimeInterval
latestConfigSentTimestampMs: Int64
) throws {
typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?)
@ -50,7 +50,7 @@ internal extension SessionUtil {
fileName: nil
)
}(),
sentTimestamp: latestConfigUpdateSentTimestamp,
sentTimestamp: (TimeInterval(latestConfigSentTimestampMs) / 1000),
calledFromConfigHandling: true
)
@ -121,7 +121,7 @@ internal extension SessionUtil {
isEnabled: targetIsEnable,
durationSeconds: TimeInterval(targetExpiry),
type: targetIsEnable ? .disappearAfterSend : .unknown,
lastChangeTimestampMs: Int64(latestConfigUpdateSentTimestamp * 1000)
lastChangeTimestampMs: latestConfigSentTimestampMs
)
let localConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: userPublicKey)

@ -260,7 +260,8 @@ public enum SessionUtil {
internal static func createDump(
conf: UnsafeMutablePointer<config_object>?,
for variant: ConfigDump.Variant,
publicKey: String
publicKey: String,
timestampMs: Int64
) throws -> ConfigDump? {
guard conf != nil else { throw SessionUtilError.nilConfigObject }
@ -279,7 +280,8 @@ public enum SessionUtil {
return ConfigDump(
variant: variant,
publicKey: publicKey,
data: dumpData
data: dumpData,
timestampMs: timestampMs
)
}
@ -363,7 +365,8 @@ public enum SessionUtil {
return try? SessionUtil.createDump(
conf: conf,
for: message.kind.configDumpVariant,
publicKey: publicKey
publicKey: publicKey,
timestampMs: (message.sentTimestamp.map { Int64($0) } ?? 0)
)
}
}
@ -427,9 +430,7 @@ public enum SessionUtil {
let needsPush: Bool = try groupedMessages
.sorted { lhs, rhs in lhs.key.processingOrder < rhs.key.processingOrder }
.reduce(false) { prevNeedsPush, next -> Bool in
let messageSentTimestamp: TimeInterval = TimeInterval(
(next.value.compactMap { $0.sentTimestamp }.max() ?? 0) / 1000
)
let latestConfigSentTimestampMs: Int64 = Int64(next.value.compactMap { $0.sentTimestamp }.max() ?? 0)
let needsPush: Bool = try SessionUtil
.config(for: next.key, publicKey: publicKey)
.mutate { conf in
@ -453,7 +454,7 @@ public enum SessionUtil {
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
latestConfigSentTimestampMs: latestConfigSentTimestampMs
)
case .contacts:
@ -461,7 +462,7 @@ public enum SessionUtil {
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
latestConfigSentTimestampMs: latestConfigSentTimestampMs
)
case .convoInfoVolatile:
@ -476,7 +477,7 @@ public enum SessionUtil {
db,
in: conf,
mergeNeedsDump: config_needs_dump(conf),
latestConfigUpdateSentTimestamp: messageSentTimestamp
latestConfigSentTimestampMs: latestConfigSentTimestampMs
)
}
}
@ -487,12 +488,25 @@ public enum SessionUtil {
// Need to check if the config needs to be dumped (this might have changed
// after handling the merge changes)
guard config_needs_dump(conf) else { return config_needs_push(conf) }
guard config_needs_dump(conf) else {
try ConfigDump
.filter(
ConfigDump.Columns.variant == next.key &&
ConfigDump.Columns.publicKey == publicKey
)
.updateAll(
db,
ConfigDump.Columns.timestampMs.set(to: latestConfigSentTimestampMs)
)
return config_needs_push(conf)
}
try SessionUtil.createDump(
conf: conf,
for: next.key,
publicKey: publicKey
publicKey: publicKey,
timestampMs: latestConfigSentTimestampMs
)?.save(db)
return config_needs_push(conf)

@ -34,6 +34,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue)
public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue)
public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue)
public static let canHaveProfileKey: SQL = SQL(stringLiteral: CodingKeys.canHaveProfile.stringValue)
public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue)
public static let shouldShowDateHeaderKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowDateHeader.stringValue)
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
@ -115,6 +116,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
/// **Note:** This will only be populated for incoming messages
public let senderName: String?
/// A flag indicating whether the profile view can be displayed
public let canHaveProfile: Bool
/// A flag indicating whether the profile view should be displayed
public let shouldShowProfile: Bool
@ -191,6 +195,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
cellType: self.cellType,
authorName: self.authorName,
senderName: self.senderName,
canHaveProfile: self.canHaveProfile,
shouldShowProfile: self.shouldShowProfile,
shouldShowDateHeader: self.shouldShowDateHeader,
containsOnlyEmoji: self.containsOnlyEmoji,
@ -393,6 +398,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
return authorDisplayName
}(),
canHaveProfile: (
// Only group threads and incoming messages
isGroupThread &&
self.variant == .standardIncoming
),
shouldShowProfile: (
// Only group threads
isGroupThread &&
@ -564,6 +574,7 @@ public extension MessageViewModel {
self.cellType = cellType
self.authorName = ""
self.senderName = nil
self.canHaveProfile = false
self.shouldShowProfile = false
self.shouldShowDateHeader = false
self.containsOnlyEmoji = nil
@ -733,6 +744,7 @@ public extension MessageViewModel {
-- query from crashing when decoding we need to provide default values
\(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey),
'' AS \(ViewModel.authorNameKey),
false AS \(ViewModel.canHaveProfileKey),
false AS \(ViewModel.shouldShowProfileKey),
false AS \(ViewModel.shouldShowDateHeaderKey),
\(Position.middle) AS \(ViewModel.positionInClusterKey),

@ -495,47 +495,28 @@ public struct ProfileManager {
// Name
if let name: String = name, !name.isEmpty, name != profile.name {
let shouldUpdate: Bool = {
guard isCurrentUser else { return true }
return UserDefaults.standard[.lastDisplayNameUpdate]
.map { sentTimestamp > $0.timeIntervalSince1970 }
.defaulting(to: true)
}()
if shouldUpdate {
if isCurrentUser {
UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp)
}
// FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent
if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) {
profileChanges.append(Profile.Columns.name.set(to: name))
profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp))
}
}
// Profile picture & profile key
var avatarNeedsDownload: Bool = false
var targetAvatarUrl: String? = nil
let shouldUpdateAvatar: Bool = {
guard isCurrentUser else { return true }
return UserDefaults.standard[.lastProfilePictureUpdate]
.map { sentTimestamp > $0.timeIntervalSince1970 }
.defaulting(to: true)
}()
if shouldUpdateAvatar {
// FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent
if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) {
switch avatarUpdate {
case .none: break
case .uploadImageData: preconditionFailure("Invalid options for this function")
case .remove:
if isCurrentUser {
UserDefaults.standard[.lastProfilePictureUpdate] = Date(timeIntervalSince1970: sentTimestamp)
}
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil))
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil))
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
case .updateTo(let url, let key, let fileName):
if url != profile.profilePictureUrl {
@ -558,6 +539,9 @@ public struct ProfileManager {
!ProfileManager.hasProfileImageData(with: fileName)
)
}
// Update the 'lastProfilePictureUpdate' timestamp for either external or local changes
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
}
}

@ -65,7 +65,7 @@ public final class ProfilePictureView: UIView {
var iconSize: CGFloat {
switch self {
case .navigation, .message: return 8
case .navigation, .message: return 10 // Intentionally not a multiple of 4
case .list: return 16
case .hero: return 24
}
@ -119,6 +119,12 @@ public final class ProfilePictureView: UIView {
imageContainerView.layer.cornerRadius = (clipsToBounds ? (size.multiImageSize / 2) : 0)
}
}
public override var isHidden: Bool {
didSet {
widthConstraint.constant = (isHidden ? 0 : size.viewSize)
heightConstraint.constant = (isHidden ? 0 : size.viewSize)
}
}
// MARK: - Constraints

@ -111,6 +111,11 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
/// This is a job that runs once whenever the user config or a closed group config changes, it retrieves the
/// state of all config objects and syncs any that are flagged as needing to be synced
case configurationSync
/// This is a job that runs once whenever a config message is received to attempt to decode it and update the
/// config state with the changes; this job will generally be scheduled along since a `messageReceive` job
/// and will block the standard message receive job
case configMessageReceive
}
public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable {

@ -7,8 +7,8 @@ public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, Pe
public static var databaseTableName: String { "jobDependencies" }
internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id])
internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id])
internal static let job = belongsTo(Job.self, using: jobForeignKey)
internal static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey)
public static let job = belongsTo(Job.self, using: jobForeignKey)
public static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {

@ -38,8 +38,6 @@ public enum SNUserDefaults {
public enum Date: Swift.String {
case lastConfigurationSync
case lastDisplayNameUpdate
case lastProfilePictureUpdate
case lastProfilePictureUpload
case lastOpenGroupImageUpdate
case lastOpen

@ -42,6 +42,12 @@ public final class JobRunner {
case notFound
}
public struct JobInfo {
public let threadId: String?
public let interactionId: Int64?
public let detailsData: Data?
}
private static let blockingQueue: Atomic<JobQueue?> = Atomic(
JobQueue(
type: .blocking,
@ -80,7 +86,8 @@ public final class JobRunner {
executionType: .serial,
qos: .default,
jobVariants: [
jobVariants.remove(.messageReceive)
jobVariants.remove(.messageReceive),
jobVariants.remove(.configMessageReceive)
].compactMap { $0 }
)
let attachmentDownloadQueue: JobQueue = JobQueue(
@ -127,26 +134,30 @@ public final class JobRunner {
///
/// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp`
/// is in the future then the job won't be started
public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) {
@discardableResult public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) -> Job? {
// Store the job into the database (getting an id for it)
guard let updatedJob: Job = try? job?.inserted(db) else {
SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job")
return
return nil
}
guard !canStartJob || updatedJob.id != nil else {
SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id")
return
return nil
}
queues.wrappedValue[updatedJob.variant]?.add(db, job: updatedJob, canStartJob: canStartJob)
// Don't start the queue if the job can't be started
guard canStartJob else { return }
// Start the job runner if needed
db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { _ in
// Wait until the transaction has been completed before updating the queue (to ensure anything
// created during the transaction has been saved to the database before any corresponding jobs
// are run)
db.afterNextTransactionNested { _ in
queues.wrappedValue[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob)
// Don't start the queue if the job can't be started
guard canStartJob else { return }
queues.wrappedValue[updatedJob.variant]?.start()
}
return updatedJob
}
/// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start
@ -161,17 +172,22 @@ public final class JobRunner {
return
}
queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob)
// Don't start the queue if the job can't be started
guard canStartJob else { return }
// Start the job runner if needed
db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(job.variant)") { _ in
// Wait until the transaction has been completed before updating the queue (to ensure anything
// created during the transaction has been saved to the database before any corresponding jobs
// are run)
db.afterNextTransactionNested { _ in
queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob)
// Don't start the queue if the job can't be started
guard canStartJob else { return }
queues.wrappedValue[job.variant]?.start()
}
}
/// Insert a job before another job in the queue
///
/// **Note:** This function assumes the relevant job queue is already running and as such **will not** start the queue if it isn't running
@discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, Job)? {
switch job?.behaviour {
case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch:
@ -191,7 +207,12 @@ public final class JobRunner {
return nil
}
queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob)
// Wait until the transaction has been completed before updating the queue (to ensure anything
// created during the transaction has been saved to the database before any corresponding jobs
// are run)
db.afterNextTransactionNested { _ in
queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob)
}
return (jobId, updatedJob)
}
@ -366,8 +387,8 @@ public final class JobRunner {
return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true)
}
public static func defailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] {
return (queues.wrappedValue[variant]?.detailsForAllCurrentlyRunningJobs())
public static func infoForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: JobInfo] {
return (queues.wrappedValue[variant]?.infoForAllCurrentlyRunningJobs())
.defaulting(to: [:])
}
@ -380,11 +401,24 @@ public final class JobRunner {
queue.afterCurrentlyRunningJob(jobId, callback: callback)
}
public static func hasPendingOrRunningJob<T: Encodable>(with variant: Job.Variant, details: T) -> Bool {
public static func hasPendingOrRunningJob<T: Encodable>(
with variant: Job.Variant,
threadId: String? = nil,
interactionId: Int64? = nil,
details: T? = nil
) -> Bool {
guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false }
guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false }
return targetQueue.hasPendingOrRunningJob(with: detailsData)
// Ensure we can encode the details (if provided)
let detailsData: Data? = details.map { try? JSONEncoder().encode($0) }
guard details == nil || detailsData != nil else { return false }
return targetQueue.hasPendingOrRunningJobWith(
threadId: threadId,
interactionId: interactionId,
detailsData: detailsData
)
}
public static func removePendingJob(_ job: Job?) {
@ -498,9 +532,9 @@ private final class JobQueue {
private var nextTrigger: Atomic<Trigger?> = Atomic(nil)
fileprivate var isRunning: Atomic<Bool> = Atomic(false)
private var queue: Atomic<[Job]> = Atomic([])
private var jobsCurrentlyRunning: Atomic<Set<Int64>> = Atomic([])
private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:])
private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:])
private var currentlyRunningJobIds: Atomic<Set<Int64>> = Atomic([])
private var currentlyRunningJobInfo: Atomic<[Int64: JobRunner.JobInfo]> = Atomic([:])
private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:])
fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty }
@ -524,7 +558,7 @@ private final class JobQueue {
// MARK: - Execution
fileprivate func add(_ db: Database, job: Job, canStartJob: Bool = true) {
fileprivate func add(_ job: Job, canStartJob: Bool = true) {
// Check if the job should be added to the queue
guard
canStartJob,
@ -541,11 +575,7 @@ private final class JobQueue {
// If this is a concurrent queue then we should immediately start the next job
guard executionType == .concurrent else { return }
// Ensure that the database commit has completed and then trigger the next job to run (need
// to ensure any interactions have been correctly inserted first)
db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Add: \(job.variant)") { [weak self] _ in
self?.runNextJob()
}
runNextJob()
}
/// Upsert a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start
@ -553,7 +583,7 @@ private final class JobQueue {
///
/// **Note:** If the job has a `behaviour` of `runOnceNextLaunch` or the `nextRunTimestamp`
/// is in the future then the job won't be started
fileprivate func upsert(_ db: Database, job: Job, canStartJob: Bool = true) {
fileprivate func upsert(_ job: Job, canStartJob: Bool = true) {
guard let jobId: Int64 = job.id else {
SNLog("[JobRunner] Prevented attempt to upsert \(job.variant) job without id to queue")
return
@ -576,7 +606,7 @@ private final class JobQueue {
// If we didn't update an existing job then we need to add it to the queue
guard !didUpdateExistingJob else { return }
add(db, job: job, canStartJob: canStartJob)
add(job, canStartJob: canStartJob)
}
fileprivate func insert(_ job: Job, before otherJob: Job) {
@ -609,7 +639,7 @@ private final class JobQueue {
}
fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) {
let currentlyRunningJobIds: Set<Int64> = jobsCurrentlyRunning.wrappedValue
let currentlyRunningJobIds: Set<Int64> = currentlyRunningJobIds.wrappedValue
queue.mutate { queue in
// Avoid re-adding jobs to the queue that are already in it (this can
@ -631,11 +661,11 @@ private final class JobQueue {
}
fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool {
return jobsCurrentlyRunning.wrappedValue.contains(jobId)
return currentlyRunningJobIds.wrappedValue.contains(jobId)
}
fileprivate func detailsForAllCurrentlyRunningJobs() -> [Int64: Data?] {
return detailsForCurrentlyRunningJobs.wrappedValue
fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] {
return currentlyRunningJobInfo.wrappedValue
}
fileprivate func afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) {
@ -649,14 +679,65 @@ private final class JobQueue {
}
}
fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool {
guard let detailsData: Data = detailsData else { return false }
fileprivate func hasPendingOrRunningJobWith(
threadId: String? = nil,
interactionId: Int64? = nil,
detailsData: Data? = nil
) -> Bool {
let pendingJobs: [Job] = queue.wrappedValue
let currentlyRunningJobInfo: [Int64: JobRunner.JobInfo] = currentlyRunningJobInfo.wrappedValue
var possibleJobIds: Set<Int64> = Set(currentlyRunningJobInfo.keys)
.inserting(contentsOf: pendingJobs.compactMap { $0.id }.asSet())
// Remove any which don't have the matching threadId (if provided)
if let targetThreadId: String = threadId {
let pendingJobIdsWithWrongThreadId: Set<Int64> = pendingJobs
.filter { $0.threadId != targetThreadId }
.compactMap { $0.id }
.asSet()
let runningJobIdsWithWrongThreadId: Set<Int64> = currentlyRunningJobInfo
.filter { _, info -> Bool in info.threadId != targetThreadId }
.map { key, _ in key }
.asSet()
possibleJobIds = possibleJobIds
.subtracting(pendingJobIdsWithWrongThreadId)
.subtracting(runningJobIdsWithWrongThreadId)
}
// Remove any which don't have the matching interactionId (if provided)
if let targetInteractionId: Int64 = interactionId {
let pendingJobIdsWithWrongInteractionId: Set<Int64> = pendingJobs
.filter { $0.interactionId != targetInteractionId }
.compactMap { $0.id }
.asSet()
let runningJobIdsWithWrongInteractionId: Set<Int64> = currentlyRunningJobInfo
.filter { _, info -> Bool in info.interactionId != targetInteractionId }
.map { key, _ in key }
.asSet()
possibleJobIds = possibleJobIds
.subtracting(pendingJobIdsWithWrongInteractionId)
.subtracting(runningJobIdsWithWrongInteractionId)
}
// Remove any which don't have the matching details (if provided)
if let targetDetailsData: Data = detailsData {
let pendingJobIdsWithWrongDetailsData: Set<Int64> = pendingJobs
.filter { $0.details != targetDetailsData }
.compactMap { $0.id }
.asSet()
let runningJobIdsWithWrongDetailsData: Set<Int64> = currentlyRunningJobInfo
.filter { _, info -> Bool in info.detailsData != detailsData }
.map { key, _ in key }
.asSet()
possibleJobIds = possibleJobIds
.subtracting(pendingJobIdsWithWrongDetailsData)
.subtracting(runningJobIdsWithWrongDetailsData)
}
guard !pendingJobs.contains(where: { job in job.details == detailsData }) else { return true }
return detailsForCurrentlyRunningJobs.wrappedValue.values.contains(detailsData)
return !possibleJobIds.isEmpty
}
fileprivate func removePendingJob(_ jobId: Int64) {
@ -695,7 +776,7 @@ private final class JobQueue {
}
// Get any pending jobs
let jobIdsAlreadyRunning: Set<Int64> = jobsCurrentlyRunning.wrappedValue
let jobIdsAlreadyRunning: Set<Int64> = currentlyRunningJobIds.wrappedValue
let jobsAlreadyInQueue: Set<Int64> = queue.wrappedValue.compactMap { $0.id }.asSet()
let jobsToRun: [Job] = Storage.shared.read { db in
try Job
@ -754,7 +835,7 @@ private final class JobQueue {
}
guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else {
// If it's a serial queue, or there are no more jobs running then update the 'isRunning' flag
if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty {
if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty {
isRunning.mutate { $0 = false }
}
@ -816,7 +897,7 @@ private final class JobQueue {
///
/// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies
/// are successfully completed
let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys)
let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue)
let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs
.filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) }
.sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) }
@ -840,11 +921,20 @@ private final class JobQueue {
trigger?.invalidate() // Need to invalidate to prevent a memory leak
trigger = nil
}
jobsCurrentlyRunning.mutate { jobsCurrentlyRunning in
jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id)
numJobsRunning = jobsCurrentlyRunning.count
currentlyRunningJobIds.mutate { currentlyRunningJobIds in
currentlyRunningJobIds = currentlyRunningJobIds.inserting(nextJob.id)
numJobsRunning = currentlyRunningJobIds.count
}
currentlyRunningJobInfo.mutate { currentlyRunningJobInfo in
currentlyRunningJobInfo = currentlyRunningJobInfo.setting(
nextJob.id,
JobRunner.JobInfo(
threadId: nextJob.threadId,
interactionId: nextJob.interactionId,
detailsData: nextJob.details
)
)
}
detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) }
SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)")
/// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to
@ -883,7 +973,7 @@ private final class JobQueue {
}
private func scheduleNextSoonestJob() {
let jobIdsAlreadyRunning: Set<Int64> = jobsCurrentlyRunning.wrappedValue
let jobIdsAlreadyRunning: Set<Int64> = currentlyRunningJobIds.wrappedValue
let nextJobTimestamp: TimeInterval? = Storage.shared.read { db in
try Job
.filterPendingJobs(
@ -900,7 +990,7 @@ private final class JobQueue {
// If there are no remaining jobs or the JobRunner isn't allowed to start any queues then trigger
// the 'onQueueDrained' callback and stop
guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, JobRunner.canStartQueues.wrappedValue else {
if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty {
if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty {
self.onQueueDrained?()
}
return
@ -911,7 +1001,7 @@ private final class JobQueue {
guard secondsUntilNextJob > 0 else {
// Only log that the queue is getting restarted if this queue had actually been about to stop
if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty {
if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty {
let timingString: String = (nextJobTimestamp == 0 ?
"that should be in the queue" :
"scheduled \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s") ago"
@ -929,7 +1019,7 @@ private final class JobQueue {
}
// Only schedule a trigger if this queue has actually completed
guard executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty else { return }
guard executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty else { return }
// Setup a trigger
SNLog("[JobRunner] Stopping \(queueContext) until next job in \(Int(ceil(abs(secondsUntilNextJob)))) second\(Int(ceil(abs(secondsUntilNextJob))) == 1 ? "" : "s")")
@ -1018,7 +1108,7 @@ private final class JobQueue {
/// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be
/// removed from the queue, replaced by their dependencies
if !dependantJobs.isEmpty {
let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys)
let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue)
let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs
.filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) }
.sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) }
@ -1200,8 +1290,8 @@ private final class JobQueue {
private func performCleanUp(for job: Job, result: JobRunner.JobResult, shouldTriggerCallbacks: Bool = true) {
// The job is removed from the queue before it runs so all we need to to is remove it
// from the 'currentlyRunning' set
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) }
currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) }
currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) }
guard shouldTriggerCallbacks else { return }

Loading…
Cancel
Save