Added DisplayPictureDownloadJob & use PreparedRequest in FileServerAPI

pull/941/head
Morgan Pretty 2 years ago
parent 32495f0a8a
commit b47b98f2f8

@ -514,6 +514,8 @@
FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */; };
FD1936422ACBE084004BCF0F /* MockSessionUtilCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB947162A9D69A8001F271A /* MockSessionUtilCache.swift */; };
FD1936432ACBE11F004BCF0F /* CommonSMKMockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB9471A2A9D70A6001F271A /* CommonSMKMockExtensions.swift */; };
FD1936452ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */; };
FD1936472ACE752F004BCF0F /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */; };
FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */; };
FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */; };
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; };
@ -1741,6 +1743,8 @@
FD1936392ACA25BA004BCF0F /* UpdatableTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatableTimestamp.swift; sourceTree = "<group>"; };
FD19363B2ACA3134004BCF0F /* ResponseInfo+SnodeAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseInfo+SnodeAPI.swift"; sourceTree = "<group>"; };
FD19363E2ACA66DE004BCF0F /* DatabaseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseSpec.swift; sourceTree = "<group>"; };
FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayPictureDownloadJob.swift; sourceTree = "<group>"; };
FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.swift; sourceTree = "<group>"; };
FD1A94FA2900D1C2000D73D3 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = "<group>"; };
FD1A94FD2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistableRecordUtilitiesSpec.swift; sourceTree = "<group>"; };
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = "<group>"; };
@ -3879,6 +3883,7 @@
FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */,
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */,
FDBB25E22988B13800F1508E /* _004_AddJobPriority.swift */,
FD1936462ACE752F004BCF0F /* _005_AddJobUniqueHash.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -4593,6 +4598,7 @@
7B7E5B512A4D024C00A8208E /* ExpirationUpdateJob.swift */,
7B7AD41E2A5512CA00469FB1 /* GetExpirationJob.swift */,
FDB5DAD02A94838C002C8721 /* GroupInviteMemberJob.swift */,
FD1936442ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift */,
);
path = Types;
sourceTree = "<group>";
@ -6117,6 +6123,7 @@
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */,
FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */,
FD1936472ACE752F004BCF0F /* _005_AddJobUniqueHash.swift in Sources */,
FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */,
FD9AECA72AAAF5B0009B3406 /* Crypto+SessionUtilitiesKit.swift in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
@ -6239,6 +6246,7 @@
FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */,
FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */,
FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */,
FD1936452ACE6B4D004BCF0F /* DisplayPictureDownloadJob.swift in Sources */,
FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */,
FDD82C3F2A205D0A00425F05 /* ProcessResult.swift in Sources */,
FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */,

@ -60,6 +60,7 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API
.configurationSync: ConfigurationSyncJob.self,
.configMessageReceive: ConfigMessageReceiveJob.self,
.expirationUpdate: ExpirationUpdateJob.self,
.displayPictureDownload: DisplayPictureDownloadJob.self,
.getExpiration: GetExpirationJob.self
]

@ -1,4 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import Combine
@ -6,7 +8,6 @@ import SessionSnodeKit
import SessionUtilitiesKit
public enum FileServerAPI {
// MARK: - Settings
public static let oldServer = "http://88.99.175.227"
@ -24,26 +25,6 @@ public enum FileServerAPI {
// MARK: - File Storage
public static func upload(
_ file: Data,
using dependencies: Dependencies = Dependencies()
) -> AnyPublisher<FileUploadResponse, Error> {
let request = Request(
method: .post,
server: server,
endpoint: Endpoint.file,
headers: [
.contentDisposition: "attachment",
.contentType: "application/octet-stream"
],
x25519PublicKey: serverPublicKey,
body: Array(file)
)
return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileUploadTimeout, using: dependencies)
.decoded(as: FileUploadResponse.self)
}
public static func preparedUpload(
_ file: Data,
using dependencies: Dependencies = Dependencies()
@ -66,72 +47,45 @@ public enum FileServerAPI {
)
}
public static func download(
_ fileId: String,
public static func preparedDownload(
fileId: String,
useOldServer: Bool,
using dependencies: Dependencies = Dependencies()
) -> AnyPublisher<Data, Error> {
let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey)
let request = Request<NoBody, Endpoint>(
server: (useOldServer ? oldServer : server),
endpoint: .fileIndividual(fileId: fileId),
x25519PublicKey: serverPublicKey
) throws -> HTTP.PreparedRequest<Data> {
return try prepareRequest(
request: Request<NoBody, Endpoint>(
server: (useOldServer ? oldServer : server),
endpoint: .fileIndividual(fileId: fileId),
x25519PublicKey: (useOldServer ? oldServerPublicKey : serverPublicKey)
),
responseType: Data.self,
timeout: FileServerAPI.fileDownloadTimeout,
using: dependencies
)
return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileDownloadTimeout, using: dependencies)
}
public static func getVersion(
public static func preparedGetVersion(
_ platform: String,
using dependencies: Dependencies = Dependencies()
) -> AnyPublisher<String, Error> {
let request = Request<NoBody, Endpoint>(
server: server,
endpoint: .sessionVersion,
queryParameters: [
.platform: platform
],
x25519PublicKey: serverPublicKey
) throws -> HTTP.PreparedRequest<String> {
return try prepareRequest(
request: Request<NoBody, Endpoint>(
server: server,
endpoint: Endpoint.sessionVersion,
queryParameters: [
.platform: platform
],
x25519PublicKey: serverPublicKey
),
responseType: VersionResponse.self,
timeout: HTTP.defaultTimeout,
using: dependencies
)
return send(request, serverPublicKey: serverPublicKey, timeout: HTTP.defaultTimeout, using: dependencies)
.decoded(as: VersionResponse.self)
.map { response in response.version }
.eraseToAnyPublisher()
.map { _, response in response.version }
}
// MARK: - Convenience
private static func send<T: Encodable>(
_ request: Request<T, Endpoint>,
serverPublicKey: String,
timeout: TimeInterval,
using dependencies: Dependencies
) -> AnyPublisher<Data, Error> {
let preparedRequest: HTTP.PreparedRequest<Data?>
do {
preparedRequest = try prepareRequest(
request: request,
responseType: Data?.self,
timeout: timeout,
using: dependencies
)
}
catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
return preparedRequest.send(using: dependencies)
.tryMap { _, response -> Data in
guard let response: Data = response else { throw HTTPError.parsingFailed }
return response
}
.eraseToAnyPublisher()
}
private static func prepareRequest<T: Encodable, R: Decodable>(
request: Request<T, Endpoint>,
responseType: R.Type,

@ -95,32 +95,28 @@ public enum AttachmentDownloadJob: JobExecutor {
else { throw AttachmentDownloadError.invalidUrl }
return dependencies[singleton: .storage]
.readPublisher { db -> HTTP.PreparedRequest<Data>? in
try OpenGroup.fetchOne(db, id: threadId)
.map { openGroup in
try OpenGroupAPI
.readPublisher { db -> HTTP.PreparedRequest<Data> in
switch try OpenGroup.fetchOne(db, id: threadId) {
case .some(let openGroup):
return try OpenGroupAPI
.preparedDownloadFile(
db,
fileId: fileId,
from: openGroup.roomToken,
on: openGroup.server
)
}
}
.flatMap { maybePreparedRequest -> AnyPublisher<Data, Error> in
guard let preparedRequest: HTTP.PreparedRequest<Data> = maybePreparedRequest else {
return FileServerAPI
.download(
fileId,
useOldServer: downloadUrl.contains(FileServerAPI.oldServer)
)
.eraseToAnyPublisher()
case .none:
return try FileServerAPI
.preparedDownload(
fileId: fileId,
useOldServer: downloadUrl.contains(FileServerAPI.oldServer),
using: dependencies
)
}
return preparedRequest.send(using: dependencies)
.map { _, data in data }
.eraseToAnyPublisher()
}
.flatMap { $0.send(using: dependencies) }
.map { _, data in data }
.eraseToAnyPublisher()
}
.subscribe(on: queue)

@ -93,7 +93,7 @@ public enum AttachmentUploadJob: JobExecutor {
MessageSender.handleFailedMessageSend(
db,
message: details.message,
with: .other("[AttachmentUploadJob] Failed", error),
with: MessageSenderError.other("[AttachmentUploadJob] Failed", error),
interactionId: interactionId,
isSyncMessage: details.isSyncMessage,
using: dependencies

@ -0,0 +1,155 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import Combine
import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
public enum DisplayPictureDownloadJob: JobExecutor {
public static var maxFailureCount: Int = 1
public static var requiresThreadId: Bool = false
public static var requiresInteractionId: Bool = false
public static func run(
_ job: Job,
queue: DispatchQueue,
success: @escaping (Job, Bool, Dependencies) -> (),
failure: @escaping (Job, Error?, Bool, Dependencies) -> (),
deferred: @escaping (Job, Dependencies) -> (),
using dependencies: Dependencies
) {
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData),
let preparedDownload: HTTP.PreparedRequest<Data> = try? {
switch details.target {
case .profile(_, let url, let encryptionKey):
guard
!url.isEmpty,
let fileId: String = Attachment.fileId(for: url),
encryptionKey.count == ProfileManager.avatarAES256KeyByteLength
else { return nil }
return try FileServerAPI.preparedDownload(
fileId: fileId,
useOldServer: url.contains(FileServerAPI.oldServer),
using: dependencies
)
}
}()
else {
SNLog("[DisplayPictureDownloadJob] Failing due to missing details")
failure(job, JobRunnerError.missingRequiredDetails, true, dependencies)
return
}
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
preparedDownload
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .background), using: dependencies)
.receive(on: DispatchQueue.global(qos: .background), using: dependencies)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: success(job, false, dependencies)
case .failure(let error): failure(job, error, true, dependencies)
}
},
receiveValue: { _, data in
switch details.target {
case .profile(let id, let url, let encryptionKey):
guard let latestProfile: Profile = dependencies[singleton: .storage].read({ db in try Profile.fetchOne(db, id: id) }) else {
return
}
// Check to make sure this download matches the profile settings
guard
details.timestamp >= (latestProfile.lastProfilePictureUpdate ?? 0) || (
encryptionKey == latestProfile.profileEncryptionKey &&
url == latestProfile.profilePictureUrl
)
else { return }
guard let decryptedData: Data = ProfileManager.decryptData(data: data, key: encryptionKey) else {
SNLog("[DisplayPictureDownloadJob] Failed to decrypt display picture for \(id)")
failure(job, ProfileManagerError.avatarWriteFailed, true, dependencies)
return
}
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
guard UIImage(contentsOfFile: filePath) != nil else {
SNLog("[DisplayPictureDownloadJob] Failed to load display picture for \(id)")
failure(job, ProfileManagerError.avatarWriteFailed, true, dependencies)
return
}
// Update the cache first (in case the DBWrite thread is blocked, this way other threads
// can retrieve from the cache and avoid triggering a download)
ProfileManager.cache(fileName: fileName, avatarData: decryptedData)
// Store the updated 'profilePictureFileName'
dependencies[singleton: .storage].write { db in
_ = try? Profile
.filter(id: id)
.updateAllAndConfig(
db,
Profile.Columns.profilePictureUrl.set(to: url),
Profile.Columns.profileEncryptionKey.set(to: encryptionKey),
Profile.Columns.profilePictureFileName.set(to: fileName),
Profile.Columns.lastProfilePictureUpdate.set(to: details.timestamp)
)
}
}
}
)
}
}
// MARK: - DisplayPictureDownloadJob.Details
extension DisplayPictureDownloadJob {
public enum Target: Codable, Hashable {
case profile(id: String, url: String, encryptionKey: Data)
}
public struct Details: Codable, Hashable {
public let target: Target
public let timestamp: TimeInterval
public init?(target: Target, timestamp: TimeInterval) {
switch target {
case .profile(_, let url, let encryptionKey):
guard
!url.isEmpty,
Attachment.fileId(for: url) != nil,
encryptionKey.count == ProfileManager.avatarAES256KeyByteLength
else { return nil }
break
}
self.target = target
self.timestamp = timestamp
}
public init?(profile: Profile) {
guard
let url: String = profile.profilePictureUrl,
let key: Data = profile.profileEncryptionKey,
let details: Details = Details(
target: .profile(id: profile.id, url: url, encryptionKey: key),
timestamp: (profile.lastProfilePictureUpdate ?? 0)
)
else { return nil }
self = details
}
}
}

@ -48,7 +48,7 @@ public final class VisibleMessage: Message {
// default logic, otherwise we want to check
guard !isValid || attachmentIds.isEmpty else { return isValid }
return (dataMessageHasAttachments == false)
return (dataMessageHasAttachments == true)
}
// MARK: - Initialization

@ -141,7 +141,7 @@ extension MessageSender {
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .groupInviteMemberJob,
variant: .groupInviteMember,
threadId: thread.id,
details: GroupInviteMemberJob.Details(
memberSubkey: Data(),

@ -331,16 +331,13 @@ internal extension SessionUtil {
contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession()
contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession()
// Download the profile picture if needed (this can be triggered within
// database reads/writes so dispatch the download to a separate queue to
// prevent blocking)
// Attempts retrieval of the profile picture (will schedule a download if
// needed via a throttled subscription on another thread to prevent blocking)
if
oldAvatarUrl != (updatedProfile.profilePictureUrl ?? "") ||
oldAvatarKey != (updatedProfile.profileEncryptionKey ?? Data(repeating: 0, count: ProfileManager.avatarAES256KeyByteLength))
{
DispatchQueue.global(qos: .background).async {
ProfileManager.downloadAvatar(for: updatedProfile)
}
ProfileManager.profileAvatar(profile: updatedProfile)
}
// Store the updated contact (needs to happen before variables go out of scope)

@ -5,6 +5,7 @@ import CryptoKit
import Combine
import GRDB
import SignalCoreKit
import SessionSnodeKit
import SessionUtilitiesKit
public struct ProfileManager {
@ -15,6 +16,11 @@ public struct ProfileManager {
case updateTo(url: String, key: Data, fileName: String?)
}
private struct DownloadInfo: Hashable {
let profile: Profile
let currentFileInvalid: Bool
}
// The max bytes for a user's profile name, encoded in UTF8.
// Before encrypting and submitting we NULL pad the name data to this length.
public static let maxAvatarDiameter: CGFloat = 640
@ -26,6 +32,10 @@ public struct ProfileManager {
private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:])
private static var currentAvatarDownloads: Atomic<Set<String>> = Atomic([])
private static var downloadsToSchedule: Atomic<Set<DownloadInfo>> = Atomic([])
private static var scheduleDownloadsPublisher: AnyPublisher<Void, Never>?
private static let scheduleDownloadsTrigger: PassthroughSubject<(), Never> = PassthroughSubject()
// MARK: - Functions
public static func isToLong(profileName: String) -> Bool {
@ -49,7 +59,7 @@ public struct ProfileManager {
return profileAvatar(profile: profile)
}
public static func profileAvatar(
@discardableResult public static func profileAvatar(
profile: Profile,
using dependencies: Dependencies = Dependencies()
) -> Data? {
@ -58,10 +68,7 @@ public struct ProfileManager {
}
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
dependencies[singleton: .jobRunner].afterBlockingQueue {
ProfileManager.downloadAvatar(for: profile)
}
scheduleDownload(for: profile, currentFileInvalid: false, using: dependencies)
}
return nil
@ -81,24 +88,8 @@ public struct ProfileManager {
let data: Data = loadProfileData(with: fileName),
data.isValidImage
else {
// If we can't load the avatar or it's an invalid/corrupted image then clear out
// the 'profilePictureFileName' and try to re-download
dependencies[singleton: .storage].writeAsync(
updates: { db in
_ = try? Profile
.filter(id: profile.id)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
},
completion: { _, _ in
// Try to re-download the avatar if it has a URL
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
dependencies[singleton: .jobRunner].afterBlockingQueue {
ProfileManager.downloadAvatar(for: profile)
}
}
}
)
// If we can't load the avatar or it's an invalid/corrupted image then clear it out and re-download
scheduleDownload(for: profile, currentFileInvalid: true, using: dependencies)
return nil
}
@ -119,9 +110,66 @@ public struct ProfileManager {
return try? Data(contentsOf: URL(fileURLWithPath: filePath))
}
public static func cache(fileName: String, avatarData: Data) {
profileAvatarCache.mutate { $0[fileName] = avatarData }
}
private static func scheduleDownload(
for profile: Profile,
currentFileInvalid invalid: Bool,
using dependencies: Dependencies
) {
downloadsToSchedule.mutate { $0 = $0.inserting(DownloadInfo(profile: profile, currentFileInvalid: invalid)) }
/// This method can be triggered very frequently when processing messages so we want to throttle the updates to 250ms (it's for starting
/// avatar downloads so that should definitely be fast enough)
if scheduleDownloadsPublisher == nil {
scheduleDownloadsPublisher = scheduleDownloadsTrigger
.throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
.handleEvents(
receiveOutput: { _ in
let pendingInfo: Set<DownloadInfo> = downloadsToSchedule.mutate {
let result: Set<DownloadInfo> = $0
$0.removeAll()
return result
}
dependencies[singleton: .storage].writeAsync(using: dependencies) { db in
pendingInfo.forEach { info in
// If the current file is invalid then clear out the 'profilePictureFileName'
// and try to re-download the file
if info.currentFileInvalid {
_ = try? Profile
.filter(id: profile.id)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
}
dependencies[singleton: .jobRunner].add(
db,
job: Job(
variant: .displayPictureDownload,
shouldBeUnique: true,
details: DisplayPictureDownloadJob.Details(profile: info.profile)
),
canStartJob: true,
using: dependencies
)
}
}
}
)
.map { _ in () }
.eraseToAnyPublisher()
scheduleDownloadsPublisher?.sinkUntilComplete()
}
scheduleDownloadsTrigger.send(())
}
// MARK: - Profile Encryption
private static func encryptData(data: Data, key: Data) -> Data? {
internal static func encryptData(data: Data, key: Data) -> Data? {
// The key structure is: nonce || ciphertext || authTag
guard
key.count == ProfileManager.avatarAES256KeyByteLength,
@ -138,7 +186,7 @@ public struct ProfileManager {
return encryptedContent
}
private static func decryptData(data: Data, key: Data) -> Data? {
internal static func decryptData(data: Data, key: Data) -> Data? {
guard key.count == ProfileManager.avatarAES256KeyByteLength else { return nil }
// The key structure is: nonce || ciphertext || authTag
@ -205,99 +253,6 @@ public struct ProfileManager {
try? FileManager.default.removeItem(atPath: ProfileManager.profileAvatarsDirPath)
}
// MARK: - Other Users' Profiles
public static func downloadAvatar(
for profile: Profile,
funcName: String = #function,
using dependencies: Dependencies = Dependencies()
) {
guard !currentAvatarDownloads.wrappedValue.contains(profile.id) else {
// Download already in flight; ignore
return
}
guard let profileUrlStringAtStart: String = profile.profilePictureUrl else {
SNLog("Skipping downloading avatar for \(profile.id) because url is not set")
return
}
guard
let fileId: String = Attachment.fileId(for: profileUrlStringAtStart),
let profileKeyAtStart: Data = profile.profileEncryptionKey,
profileKeyAtStart.count > 0
else {
return
}
let fileName: String = UUID().uuidString.appendingFileExtension("jpg")
let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName)
var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: funcName)
OWSLogger.verbose("downloading profile avatar: \(profile.id)")
currentAvatarDownloads.mutate { $0.insert(profile.id) }
let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer))
FileServerAPI
.download(fileId, useOldServer: useOldServer)
.subscribe(on: DispatchQueue.global(qos: .background), using: dependencies)
.receive(on: DispatchQueue.global(qos: .background), using: dependencies)
.sinkUntilComplete(
receiveCompletion: { _ in
currentAvatarDownloads.mutate { $0.remove(profile.id) }
// Redundant but without reading 'backgroundTask' it will warn that the variable
// isn't used
if backgroundTask != nil { backgroundTask = nil }
},
receiveValue: { data in
guard let latestProfile: Profile = dependencies[singleton: .storage].read({ db in try Profile.fetchOne(db, id: profile.id) }) else {
return
}
guard
let latestProfileKey: Data = latestProfile.profileEncryptionKey,
!latestProfileKey.isEmpty,
latestProfileKey == profileKeyAtStart
else {
OWSLogger.warn("Ignoring avatar download for obsolete user profile.")
return
}
guard profileUrlStringAtStart == latestProfile.profilePictureUrl else {
OWSLogger.warn("Avatar url has changed during download.")
if latestProfile.profilePictureUrl?.isEmpty == false {
self.downloadAvatar(for: latestProfile)
}
return
}
guard let decryptedData: Data = decryptData(data: data, key: profileKeyAtStart) else {
OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.")
return
}
try? decryptedData.write(to: URL(fileURLWithPath: filePath), options: [.atomic])
guard UIImage(contentsOfFile: filePath) != nil else {
OWSLogger.warn("Avatar image for \(profile.id) could not be loaded.")
return
}
// Update the cache first (in case the DBWrite thread is blocked, this way other threads
// can retrieve from the cache and avoid triggering a download)
profileAvatarCache.mutate { $0[fileName] = decryptedData }
// Store the updated 'profilePictureFileName'
dependencies[singleton: .storage].write { db in
_ = try? Profile
.filter(id: profile.id)
.updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName))
}
}
)
}
// MARK: - Current User Profile
public static func updateLocal(
@ -483,8 +438,14 @@ public struct ProfileManager {
}
// Upload the avatar to the FileServer
FileServerAPI
.upload(encryptedAvatarData)
guard let preparedUpload: HTTP.PreparedRequest<FileUploadResponse> = try? FileServerAPI.preparedUpload(encryptedAvatarData, using: dependencies) else {
SNLog("Updating service with profile failed.")
failure?(.avatarUploadFailed)
return
}
preparedUpload
.send(using: dependencies)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: queue)
.sinkUntilComplete(
@ -501,7 +462,7 @@ public struct ProfileManager {
)
}
},
receiveValue: { fileUploadResponse in
receiveValue: { _, fileUploadResponse in
let downloadUrl: String = "\(FileServerAPI.server)/file/\(fileUploadResponse.id)"
// Update the cached avatar image value
@ -544,9 +505,6 @@ public struct ProfileManager {
}
// Profile picture & profile key
var avatarNeedsDownload: Bool = false
var targetAvatarUrl: String? = nil
if sentTimestamp > (profile.lastProfilePictureUpdate ?? 0) || (isCurrentUser && calledFromConfigHandling) {
switch avatarUpdate {
case .none: break
@ -558,70 +516,48 @@ public struct ProfileManager {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil))
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
case .updateTo(let url, let key, let fileName):
case .updateTo(let url, let key, .some(let fileName)) where ProfileManager.hasProfileImageData(with: fileName):
// Update the 'lastProfilePictureUpdate' timestamp for either external or local changes
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName))
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
if url != profile.profilePictureUrl {
profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url))
avatarNeedsDownload = true
targetAvatarUrl = url
}
if key != profile.profileEncryptionKey && key.count == ProfileManager.avatarAES256KeyByteLength {
profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key))
}
// Profile filename (this isn't synchronized between devices)
if let fileName: String = fileName {
profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName))
// If we have already downloaded the image then no need to download it again
avatarNeedsDownload = (
avatarNeedsDownload &&
!ProfileManager.hasProfileImageData(with: fileName)
)
}
// Update the 'lastProfilePictureUpdate' timestamp for either external or local changes
profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp))
}
}
// Persist any changes
if !profileChanges.isEmpty {
try profile.save(db)
if calledFromConfigHandling {
try Profile
.filter(id: publicKey)
.updateAll( // Handling a config update so don't use `updateAllAndConfig`
case .updateTo(let url, let key, _):
dependencies[singleton: .jobRunner].add(
db,
profileChanges
)
}
else {
try Profile
.filter(id: publicKey)
.updateAllAndConfig(
db,
profileChanges,
calledFromConfig: calledFromConfigHandling,
job: Job(
variant: .displayPictureDownload,
shouldBeUnique: true,
details: DisplayPictureDownloadJob.Details(
target: .profile(id: profile.id, url: url, encryptionKey: key),
timestamp: sentTimestamp
)
),
canStartJob: true,
using: dependencies
)
}
}
// Download the profile picture if needed
guard avatarNeedsDownload else { return }
let dedupeIdentifier: String = "AvatarDownload-\(publicKey)-\(targetAvatarUrl ?? "remove")"
db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in
// Need to refetch to ensure the db changes have occurred
let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey)
// Persist any changes
if !profileChanges.isEmpty {
try profile.save(db)
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
dependencies[singleton: .jobRunner].afterBlockingQueue {
ProfileManager.downloadAvatar(for: targetProfile)
}
try Profile
.filter(id: publicKey)
.updateAllAndConfig(
db,
profileChanges,
calledFromConfig: calledFromConfigHandling,
using: dependencies
)
}
}
}

@ -23,7 +23,8 @@ public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API
[], // Other DB migrations
[], // Legacy DB removal
[
_004_AddJobPriority.self
_004_AddJobPriority.self,
_005_AddJobUniqueHash.self
]
]
)

@ -0,0 +1,22 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
enum _005_AddJobUniqueHash: Migration {
static let target: TargetMigrations.Identifier = .utilitiesKit
static let identifier: String = "AddJobUniqueHash" // stringlint:disable
static let needsConfigSync: Bool = false
static let minExpectedRunDuration: TimeInterval = 0.1
static let fetchedTables: [(TableRecord & FetchableRecord).Type] = []
static let createdOrAlteredTables: [(TableRecord & FetchableRecord).Type] = [Job.self]
static func migrate(_ db: Database, using dependencies: Dependencies) throws {
// Add `uniqueHashValue` to the job table
try db.alter(table: Job.self) { t in
t.add(.uniqueHashValue, .integer)
}
Storage.update(progress: 1, for: self, in: target, using: dependencies)
}
}

@ -38,6 +38,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
case threadId
case interactionId
case details
case uniqueHashValue
}
public enum Variant: Int, Codable, DatabaseValueConvertible, CaseIterable {
@ -125,9 +126,14 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
/// needs to get expiration from network
case getExpiration
/// This is a job which downloads a display picture for a user, group or community (it's separate from the
/// `attachmentDownloadJob` as these files are likely to be much smaller so we don't want them to be
/// blocked by larger attachment downloads
case displayPictureDownload
/// This is a job which sends an invitation to a member of a group asynchronously so the admin doesn't need to
/// wait during group creation
case groupInviteMemberJob
case groupInviteMember
}
public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable {
@ -201,6 +207,12 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
/// JSON encoded data required for the job
public let details: Data?
/// When initalizing with `shouldBeUnique` set to `true` this value will be populated with a hash constructed by
/// combining the `variant`, `threadId`, `interactionId` and `details` and if this value is populated
/// adding/inserting a `Job` will fail if there is already a job with the same `uniqueHashValue` in the database or
/// in the `JobRunner`
public let uniqueHashValue: Int?
/// The other jobs which this job is dependant on
///
/// **Note:** When completing a job the dependencies **MUST** be cleared before the job is
@ -226,6 +238,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
variant: Variant,
behaviour: Behaviour,
shouldBlock: Bool,
shouldBeUnique: Bool,
shouldSkipLaunchBecomeActive: Bool,
nextRunTimestamp: TimeInterval,
threadId: String?,
@ -249,6 +262,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
self.threadId = threadId
self.interactionId = interactionId
self.details = details
self.uniqueHashValue = Job.createUniqueHash(
shouldBeUnique: shouldBeUnique,
variant: variant,
threadId: threadId,
interactionId: interactionId,
detailsData: details
)
}
public init(
@ -257,6 +277,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
variant: Variant,
behaviour: Behaviour = .runOnce,
shouldBlock: Bool = false,
shouldBeUnique: Bool = false,
shouldSkipLaunchBecomeActive: Bool = false,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
@ -278,6 +299,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
self.threadId = threadId
self.interactionId = interactionId
self.details = nil
self.uniqueHashValue = Job.createUniqueHash(
shouldBeUnique: shouldBeUnique,
variant: variant,
threadId: threadId,
interactionId: interactionId,
detailsData: nil
)
}
public init?<T: Encodable>(
@ -286,6 +314,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
variant: Variant,
behaviour: Behaviour = .runOnce,
shouldBlock: Bool = false,
shouldBeUnique: Bool = false,
shouldSkipLaunchBecomeActive: Bool = false,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
@ -316,6 +345,13 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
self.threadId = threadId
self.interactionId = interactionId
self.details = detailsData
self.uniqueHashValue = Job.createUniqueHash(
shouldBeUnique: shouldBeUnique,
variant: variant,
threadId: threadId,
interactionId: interactionId,
detailsData: detailsData
)
}
fileprivate static func ensureValidBehaviour(
@ -335,6 +371,26 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord,
)
}
private static func createUniqueHash(
shouldBeUnique: Bool,
variant: Variant,
threadId: String?,
interactionId: Int64?,
detailsData: Data?
) -> Int? {
// Only generate a unique hash if the Job should actually be unique (we don't want to prevent
// all duplicate jobs, just the ones explicitly marked as unique)
guard shouldBeUnique else { return nil }
var hasher: Hasher = Hasher()
variant.hash(into: &hasher)
threadId?.hash(into: &hasher)
interactionId?.hash(into: &hasher)
detailsData?.hash(into: &hasher)
return hasher.finalize()
}
// MARK: - Custom Database Interaction
public mutating func didInsert(_ inserted: InsertionSuccess) {
@ -400,6 +456,7 @@ public extension Job {
variant: self.variant,
behaviour: self.behaviour,
shouldBlock: self.shouldBlock,
shouldBeUnique: (self.uniqueHashValue != nil),
shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive,
nextRunTimestamp: nextRunTimestamp,
threadId: self.threadId,
@ -422,6 +479,7 @@ public extension Job {
variant: self.variant,
behaviour: self.behaviour,
shouldBlock: self.shouldBlock,
shouldBeUnique: (self.uniqueHashValue != nil),
shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive,
nextRunTimestamp: self.nextRunTimestamp,
threadId: self.threadId,

@ -152,6 +152,7 @@ public final class JobRunner: JobRunnerType {
public let threadId: String?
public let interactionId: Int64?
public let detailsData: Data?
public let uniqueHashValue: Int?
public var debugDescription: String {
let dataDescription: String = detailsData
@ -163,12 +164,18 @@ public final class JobRunner: JobRunnerType {
"variant: \(variant),",
" threadId: \(threadId ?? "nil"),",
" interactionId: \(interactionId.map { "\($0)" } ?? "nil"),",
" detailsData: \(dataDescription)",
" detailsData: \(dataDescription),",
" uniqueHashValue: \(uniqueHashValue.map { "\($0)" } ?? "nil")",
")"
].joined()
}
}
private enum Validation {
case enqueueOnly
case persist
}
// MARK: - Variables
private let allowToExecuteJobs: Bool
@ -276,6 +283,18 @@ public final class JobRunner: JobRunnerType {
].compactMap { $0 }
),
// MARK: -- Display Picture Download Queue
JobQueue(
type: .displayPictureDownload,
executionType: .serial,
qos: .utility,
isTestingJobRunner: isTestingJobRunner,
jobVariants: [
jobVariants.remove(.displayPictureDownload)
].compactMap { $0 }
),
// MARK: -- General Queue
JobQueue(
@ -369,7 +388,8 @@ public final class JobRunner: JobRunnerType {
variant: job.variant,
threadId: job.threadId,
interactionId: job.interactionId,
detailsData: job.details
detailsData: job.details,
uniqueHashValue: job.uniqueHashValue
)
)
})
@ -609,15 +629,7 @@ public final class JobRunner: JobRunnerType {
canStartJob: Bool,
using dependencies: Dependencies
) -> 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 nil
}
guard !canStartJob || updatedJob.id != nil else {
SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id")
return nil
}
guard let updatedJob: Job = validatedJob(db, job: job, validation: .persist) else { return nil }
// Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded
// once the queue actually get started later)
@ -647,20 +659,21 @@ public final class JobRunner: JobRunnerType {
add(db, job: job, canStartJob: canStartJob, using: dependencies)
return
}
guard let updatedJob: Job = validatedJob(db, job: job, validation: .enqueueOnly) else { return }
// Don't add to the queue if the JobRunner isn't ready (it's been saved to the db so it'll be loaded
// once the queue actually get started later)
guard canAddToQueue(job) else { return }
guard canAddToQueue(updatedJob) else { return }
queues.wrappedValue[job.variant]?.upsert(db, job: job, canStartJob: canStartJob, using: dependencies)
queues.wrappedValue[updatedJob.variant]?
.upsert(db, job: updatedJob, canStartJob: canStartJob, using: dependencies)
// 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)") { [weak self] _ in
self?.queues.wrappedValue[job.variant]?.start(using: dependencies)
db.afterNextTransactionNestedOnce(dedupeId: "JobRunner-Start: \(updatedJob.variant)") { [weak self] _ in
self?.queues.wrappedValue[updatedJob.variant]?.start(using: dependencies)
}
}
@ -671,21 +684,16 @@ public final class JobRunner: JobRunnerType {
) -> (Int64, Job)? {
switch job?.behaviour {
case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch:
SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")")
SNLog("[JobRunner] Attempted to insert \(job?.variant) job before the current one even though it's behaviour is \(job?.behaviour)")
return nil
default: break
}
// 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 nil
}
guard let jobId: Int64 = updatedJob.id else {
SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id")
return nil
}
guard
let updatedJob: Job = validatedJob(db, job: job, validation: .persist),
let jobId: Int64 = updatedJob.id
else { return nil }
queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob)
@ -729,6 +737,43 @@ public final class JobRunner: JobRunnerType {
appHasBecomeActive.wrappedValue
)
}
private func validatedJob(_ db: Database, job: Job?, validation: Validation) -> Job? {
guard let job: Job = job else { return nil }
switch (validation, job.uniqueHashValue) {
case (.enqueueOnly, .none): return job
case (.enqueueOnly, .some(let uniqueHashValue)):
// Nothing currently running or sitting in a JobQueue
guard !allJobInfo().contains(where: { _, info -> Bool in info.uniqueHashValue == uniqueHashValue }) else {
SNLog("[JobRunner] Unable to add \(job.variant) job due to unique constraint")
return nil
}
return job
case (.persist, .some(let uniqueHashValue)):
guard
// Nothing currently running or sitting in a JobQueue
!allJobInfo().contains(where: { _, info -> Bool in info.uniqueHashValue == uniqueHashValue }) &&
// Nothing in the database
!Job.filter(Job.Columns.uniqueHashValue == uniqueHashValue).isNotEmpty(db)
else {
SNLog("[JobRunner] Unable to add \(job.variant) job due to unique constraint")
return nil
}
fallthrough // Validation passed so try to persist the job
case (.persist, .none):
guard let updatedJob: Job = try? job.inserted(db), updatedJob.id != nil else {
SNLog("[JobRunner] Unable to add \(job.variant) job\(job.id == nil ? " due to missing id" : "")")
return nil
}
return updatedJob
}
}
}
// MARK: - JobQueue
@ -740,6 +785,7 @@ public final class JobQueue: Hashable {
case messageSend
case messageReceive
case attachmentDownload
case displayPictureDownload
case expirationUpdate
var name: String {
@ -749,6 +795,7 @@ public final class JobQueue: Hashable {
case .messageSend: return "MessageSend"
case .messageReceive: return "MessageReceive"
case .attachmentDownload: return "AttachmentDownload"
case .displayPictureDownload: return "DisplayPictureDownload"
case .expirationUpdate: return "ExpirationUpdate"
}
}
@ -1303,7 +1350,8 @@ public final class JobQueue: Hashable {
variant: nextJob.variant,
threadId: nextJob.threadId,
interactionId: nextJob.interactionId,
detailsData: nextJob.details
detailsData: nextJob.details,
uniqueHashValue: nextJob.uniqueHashValue
)
)
}
@ -1707,3 +1755,15 @@ public final class JobQueue: Hashable {
}
}
}
// MARK: - Formatting
extension String.StringInterpolation {
mutating func appendInterpolation(_ variant: Job.Variant?) {
appendLiteral(variant.map { "\($0)" } ?? "unknown") // stringlint:disable
}
mutating func appendInterpolation(_ behaviour: Job.Behaviour?) {
appendLiteral(behaviour.map { "\($0)" } ?? "unknown") // stringlint:disable
}
}

@ -98,8 +98,7 @@ class SessionIdSpec: QuickSpec {
// MARK: ------ throws with the wrong length
it("throws with the wrong length") {
expect(try SessionId.Prefix(from: "05\(String(TestConstants.publicKey.prefix(10)))"))
.to(throwError(SessionIdError.invalidLength))
expect(try SessionId.Prefix(from: "0")).to(throwError(SessionIdError.invalidLength))
}
// MARK: ------ throws with an invalid prefix

@ -18,6 +18,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -30,6 +31,7 @@ class JobRunnerSpec: QuickSpec {
variant: .attachmentUpload,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -79,6 +81,7 @@ class JobRunnerSpec: QuickSpec {
variant: .getSnodePool,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -173,6 +176,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -229,6 +233,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -243,6 +248,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -276,7 +282,8 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: job1.details
detailsData: job1.details,
uniqueHashValue: nil
)
]))
expect(jobRunner.jobInfoFor(jobs: [job2]))
@ -285,7 +292,8 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
threadId: nil,
interactionId: nil,
detailsData: nil
detailsData: nil,
uniqueHashValue: nil
)
]))
}
@ -298,6 +306,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -312,6 +321,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -348,7 +358,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
.encode(TestDetails(completeTime: 1)),
uniqueHashValue: nil
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
@ -362,6 +373,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -376,6 +388,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -412,7 +425,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
.encode(TestDetails(completeTime: 1)),
uniqueHashValue: nil
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
@ -450,7 +464,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 2))
.encode(TestDetails(completeTime: 2)),
uniqueHashValue: nil
)
]))
expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101]))
@ -479,7 +494,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
.encode(TestDetails(completeTime: 1)),
uniqueHashValue: nil
)
]))
}
@ -492,6 +508,7 @@ class JobRunnerSpec: QuickSpec {
variant: .attachmentUpload,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -520,7 +537,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(completeTime: 1))
.encode(TestDetails(completeTime: 1)),
uniqueHashValue: nil
)
]))
}
@ -560,6 +578,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -574,6 +593,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageReceive,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -636,6 +656,7 @@ class JobRunnerSpec: QuickSpec {
variant: .attachmentUpload,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -749,6 +770,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -793,6 +815,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .recurringOnActive,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -807,6 +830,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -874,6 +898,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .recurringOnActive,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -888,6 +913,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -933,6 +959,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: true,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -987,6 +1014,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnceNextLaunch,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1034,6 +1062,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1063,6 +1092,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1092,6 +1122,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1122,6 +1153,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1152,6 +1184,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .runOnce,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1183,6 +1216,7 @@ class JobRunnerSpec: QuickSpec {
variant: .messageSend,
behaviour: .recurringOnLaunch,
shouldBlock: false,
shouldBeUnique: false,
shouldSkipLaunchBecomeActive: false,
nextRunTimestamp: 0,
threadId: nil,
@ -1523,7 +1557,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 3))
.encode(TestDetails(result: .deferred, completeTime: 3)),
uniqueHashValue: nil
)
]))
dependencies.stepForwardInTime()
@ -1541,7 +1576,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 5))
.encode(TestDetails(result: .deferred, completeTime: 5)),
uniqueHashValue: nil
)
]))
dependencies.stepForwardInTime()
@ -1559,7 +1595,8 @@ class JobRunnerSpec: QuickSpec {
interactionId: nil,
detailsData: try! JSONEncoder()
.with(outputFormatting: .sortedKeys)
.encode(TestDetails(result: .deferred, completeTime: 7))
.encode(TestDetails(result: .deferred, completeTime: 7)),
uniqueHashValue: nil
)
]))
dependencies.stepForwardInTime()

Loading…
Cancel
Save