mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			476 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			476 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Swift
		
	
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | 
						|
 | 
						|
import UIKit
 | 
						|
import Combine
 | 
						|
import GRDB
 | 
						|
import SessionSnodeKit
 | 
						|
import SessionUtilitiesKit
 | 
						|
 | 
						|
// MARK: - Singleton
 | 
						|
 | 
						|
public extension Singleton {
 | 
						|
    static let displayPictureManager: SingletonConfig<DisplayPictureManager> = Dependencies.create(
 | 
						|
        identifier: "displayPictureManager",
 | 
						|
        createInstance: { dependencies in DisplayPictureManager(using: dependencies) }
 | 
						|
    )
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - Log.Category
 | 
						|
 | 
						|
public extension Log.Category {
 | 
						|
    static let displayPictureManager: Log.Category = .create("DisplayPictureManager", defaultLevel: .info)
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - DisplayPictureManager
 | 
						|
 | 
						|
public class DisplayPictureManager {
 | 
						|
    public typealias UploadResult = (downloadUrl: String, fileName: String, encryptionKey: Data)
 | 
						|
    
 | 
						|
    public enum Update {
 | 
						|
        case none
 | 
						|
        
 | 
						|
        case contactRemove
 | 
						|
        case contactUpdateTo(url: String, key: Data, fileName: String?)
 | 
						|
        
 | 
						|
        case currentUserRemove
 | 
						|
        case currentUserUploadImageData(Data)
 | 
						|
        case currentUserUpdateTo(url: String, key: Data, fileName: String?)
 | 
						|
        
 | 
						|
        case groupRemove
 | 
						|
        case groupUploadImageData(Data)
 | 
						|
        case groupUpdateTo(url: String, key: Data, fileName: String?)
 | 
						|
    }
 | 
						|
    
 | 
						|
    public static let maxBytes: UInt = (5 * 1000 * 1000)
 | 
						|
    public static let maxDiameter: CGFloat = 640
 | 
						|
    public static let aes256KeyByteLength: Int = 32
 | 
						|
    internal static let nonceLength: Int = 12
 | 
						|
    internal static let tagLength: Int = 16
 | 
						|
    
 | 
						|
    private let dependencies: Dependencies
 | 
						|
    private let scheduleDownloads: PassthroughSubject<(), Never> = PassthroughSubject()
 | 
						|
    private var scheduleDownloadsCancellable: AnyCancellable?
 | 
						|
    
 | 
						|
    // MARK: - Initalization
 | 
						|
    
 | 
						|
    init(using dependencies: Dependencies) {
 | 
						|
        self.dependencies = dependencies
 | 
						|
        
 | 
						|
        setupThrottledDownloading()
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - General
 | 
						|
    
 | 
						|
    public static func isTooLong(profileUrl: String) -> Bool {
 | 
						|
        /// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer.
 | 
						|
        /// When the string is exactly 100 bytes String.utf8CString.count will be 101.
 | 
						|
        /// However in LibSession, the Contact C API supports 101 characters in order to account for
 | 
						|
        /// the null terminator - char name[101]. So it is OK to use String.utf8.count
 | 
						|
        return (profileUrl.utf8CString.count > LibSession.sizeMaxProfileUrlBytes)
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func sharedDataDisplayPictureDirPath() -> String {
 | 
						|
        let path: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath)
 | 
						|
            .appendingPathComponent("ProfileAvatars")   // stringlint:ignore
 | 
						|
            .path
 | 
						|
        try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path)
 | 
						|
        
 | 
						|
        return path
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Loading
 | 
						|
    
 | 
						|
    public func displayPicture(_ db: Database, id: OwnerId) -> Data? {
 | 
						|
        let maybeOwner: Owner? = {
 | 
						|
            switch id {
 | 
						|
                case .user(let id): return try? Profile.fetchOne(db, id: id).map { Owner.user($0) }
 | 
						|
                case .group(let id): return try? ClosedGroup.fetchOne(db, id: id).map { Owner.group($0) }
 | 
						|
                case .community(let id): return try? OpenGroup.fetchOne(db, id: id).map { Owner.community($0) }
 | 
						|
            }
 | 
						|
        }()
 | 
						|
        
 | 
						|
        guard let owner: Owner = maybeOwner else { return nil }
 | 
						|
        
 | 
						|
        return displayPicture(owner: owner)
 | 
						|
    }
 | 
						|
    
 | 
						|
    @discardableResult public func displayPicture(owner: Owner) -> Data? {
 | 
						|
        switch (owner.fileName, owner.canDownloadImage) {
 | 
						|
            case (.some(let fileName), _):
 | 
						|
                return loadDisplayPicture(for: fileName, owner: owner)
 | 
						|
                
 | 
						|
            case (_, true):
 | 
						|
                scheduleDownload(for: owner, currentFileInvalid: false)
 | 
						|
                return nil
 | 
						|
                
 | 
						|
            default: return nil
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    private func loadDisplayPicture(for fileName: String, owner: Owner) -> Data? {
 | 
						|
        if let cachedImageData: Data = dependencies[cache: .displayPicture].imageData[fileName] {
 | 
						|
            return cachedImageData
 | 
						|
        }
 | 
						|
        
 | 
						|
        guard
 | 
						|
            !fileName.isEmpty,
 | 
						|
            let data: Data = loadDisplayPictureFromDisk(for: fileName),
 | 
						|
            data.isValidImage
 | 
						|
        else {
 | 
						|
            // If we can't load the avatar or it's an invalid/corrupted image then clear it out and re-download
 | 
						|
            scheduleDownload(for: owner, currentFileInvalid: true)
 | 
						|
            return nil
 | 
						|
        }
 | 
						|
        
 | 
						|
        dependencies.mutate(cache: .displayPicture) { $0.imageData[fileName] = data }
 | 
						|
        return data
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func loadDisplayPictureFromDisk(for fileName: String) -> Data? {
 | 
						|
        guard let filePath: String = try? filepath(for: fileName) else { return nil }
 | 
						|
 | 
						|
        return try? Data(contentsOf: URL(fileURLWithPath: filePath))
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - File Paths
 | 
						|
    
 | 
						|
    public func profileAvatarFilepath(
 | 
						|
        _ db: Database? = nil,
 | 
						|
        id: String
 | 
						|
    ) -> String? {
 | 
						|
        guard let db: Database = db else {
 | 
						|
            return dependencies[singleton: .storage].read { [weak self] db in
 | 
						|
                self?.profileAvatarFilepath(db, id: id)
 | 
						|
            }
 | 
						|
        }
 | 
						|
        
 | 
						|
        let maybeFileName: String? = try? Profile
 | 
						|
            .filter(id: id)
 | 
						|
            .select(.profilePictureFileName)
 | 
						|
            .asRequest(of: String.self)
 | 
						|
            .fetchOne(db)
 | 
						|
        
 | 
						|
        return maybeFileName.map { try? filepath(for: $0) }
 | 
						|
    }
 | 
						|
    
 | 
						|
    /// **Note:** Generally the url we get won't have an extension and we don't want to make assumptions until we have the actual
 | 
						|
    /// image data so generate a name for the file and then determine the extension separately
 | 
						|
    public func generateFilenameWithoutExtension(for url: String) -> String {
 | 
						|
        return (dependencies[singleton: .crypto]
 | 
						|
            .generate(.hash(message: url.bytes))?
 | 
						|
            .toHexString())
 | 
						|
            .defaulting(to: UUID().uuidString)
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func generateFilename(format: ImageFormat = .jpeg) -> String {
 | 
						|
        return dependencies[singleton: .crypto]
 | 
						|
            .generate(.uuid())
 | 
						|
            .defaulting(to: UUID())
 | 
						|
            .uuidString
 | 
						|
            .appendingFileExtension(format.fileExtension)
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func filepath(for filename: String) throws -> String {
 | 
						|
        guard !filename.isEmpty else { throw DisplayPictureError.invalidCall }
 | 
						|
        
 | 
						|
        return URL(fileURLWithPath: sharedDataDisplayPictureDirPath())
 | 
						|
            .appendingPathComponent(filename)
 | 
						|
            .path
 | 
						|
    }
 | 
						|
    
 | 
						|
    public func resetStorage() {
 | 
						|
        try? dependencies[singleton: .fileManager].removeItem(
 | 
						|
            atPath: sharedDataDisplayPictureDirPath()
 | 
						|
        )
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Downloading
 | 
						|
    
 | 
						|
    /// Profile picture downloads 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)
 | 
						|
    private func setupThrottledDownloading() {
 | 
						|
        scheduleDownloadsCancellable = scheduleDownloads
 | 
						|
            .throttle(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
 | 
						|
            .sink(
 | 
						|
                receiveValue: { [dependencies] _ in
 | 
						|
                    let pendingInfo: Set<DownloadInfo> = dependencies.mutate(cache: .displayPicture) { cache in
 | 
						|
                        let result: Set<DownloadInfo> = cache.downloadsToSchedule
 | 
						|
                        cache.downloadsToSchedule.removeAll()
 | 
						|
                        return result
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    dependencies[singleton: .storage].writeAsync { 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 {
 | 
						|
                                info.owner.clearCurrentFile(db)
 | 
						|
                            }
 | 
						|
                            
 | 
						|
                            dependencies[singleton: .jobRunner].add(
 | 
						|
                                db,
 | 
						|
                                job: Job(
 | 
						|
                                    variant: .displayPictureDownload,
 | 
						|
                                    shouldBeUnique: true,
 | 
						|
                                    details: DisplayPictureDownloadJob.Details(owner: info.owner)
 | 
						|
                                ),
 | 
						|
                                canStartJob: true
 | 
						|
                            )
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            )
 | 
						|
    }
 | 
						|
    
 | 
						|
    private func scheduleDownload(for owner: Owner, currentFileInvalid invalid: Bool) {
 | 
						|
        dependencies.mutate(cache: .displayPicture) { cache in
 | 
						|
            cache.downloadsToSchedule.insert(DownloadInfo(owner: owner, currentFileInvalid: invalid))
 | 
						|
        }
 | 
						|
        scheduleDownloads.send(())
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: - Uploading
 | 
						|
    
 | 
						|
    public func prepareAndUploadDisplayPicture(imageData: Data) -> AnyPublisher<UploadResult, DisplayPictureError> {
 | 
						|
        return Just(())
 | 
						|
            .setFailureType(to: DisplayPictureError.self)
 | 
						|
            .tryMap { [weak self, dependencies] _ -> (Network.PreparedRequest<FileUploadResponse>, String, Data, Data) in
 | 
						|
                // If the profile avatar was updated or removed then encrypt with a new profile key
 | 
						|
                // to ensure that other users know that our profile picture was updated
 | 
						|
                let newEncryptionKey: Data
 | 
						|
                let finalImageData: Data
 | 
						|
                let fileExtension: String
 | 
						|
                let guessedFormat: ImageFormat = imageData.guessedImageFormat
 | 
						|
                
 | 
						|
                finalImageData = try {
 | 
						|
                    switch guessedFormat {
 | 
						|
                        case .gif, .webp:
 | 
						|
                            // Animated images can't be resized so if the data is too large we should error
 | 
						|
                            guard imageData.count <= DisplayPictureManager.maxBytes else {
 | 
						|
                                // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
 | 
						|
                                // be able to fit our profile photo (eg. generating pure noise at our resolution
 | 
						|
                                // compresses to ~200k)
 | 
						|
                                Log.error(.displayPictureManager, "Updating service with profile failed: \(DisplayPictureError.uploadMaxFileSizeExceeded).")
 | 
						|
                                throw DisplayPictureError.uploadMaxFileSizeExceeded
 | 
						|
                            }
 | 
						|
                            
 | 
						|
                            return imageData
 | 
						|
                            
 | 
						|
                        default: break
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    // Process the image to ensure it meets our standards for size and compress it to
 | 
						|
                    // standardise the formwat and remove any metadata
 | 
						|
                    guard var image: UIImage = UIImage(data: imageData) else {
 | 
						|
                        throw DisplayPictureError.invalidCall
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    if image.size.width != DisplayPictureManager.maxDiameter || image.size.height != DisplayPictureManager.maxDiameter {
 | 
						|
                        // To help ensure the user is being shown the same cropping of their avatar as
 | 
						|
                        // everyone else will see, we want to be sure that the image was resized before this point.
 | 
						|
                        Log.verbose(.displayPictureManager, "Avatar image should have been resized before trying to upload.")
 | 
						|
                        image = image.resized(toFillPixelSize: CGSize(width: DisplayPictureManager.maxDiameter, height: DisplayPictureManager.maxDiameter))
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    guard let data: Data = image.jpegData(compressionQuality: 0.95) else {
 | 
						|
                        Log.error(.displayPictureManager, "Updating service with profile failed.")
 | 
						|
                        throw DisplayPictureError.writeFailed
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    guard data.count <= DisplayPictureManager.maxBytes else {
 | 
						|
                        // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't
 | 
						|
                        // be able to fit our profile photo (eg. generating pure noise at our resolution
 | 
						|
                        // compresses to ~200k)
 | 
						|
                        Log.verbose(.displayPictureManager, "Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)")
 | 
						|
                        Log.error(.displayPictureManager, "Updating service with profile failed.")
 | 
						|
                        throw DisplayPictureError.uploadMaxFileSizeExceeded
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    return data
 | 
						|
                }()
 | 
						|
                
 | 
						|
                newEncryptionKey = try dependencies[singleton: .crypto]
 | 
						|
                    .tryGenerate(.randomBytes(DisplayPictureManager.aes256KeyByteLength))
 | 
						|
                fileExtension = {
 | 
						|
                    switch guessedFormat {
 | 
						|
                        case .gif: return "gif"     // stringlint:ignore
 | 
						|
                        case .webp: return "webp"   // stringlint:ignore
 | 
						|
                        default: return "jpg"       // stringlint:ignore
 | 
						|
                    }
 | 
						|
                }()
 | 
						|
                
 | 
						|
                // If we have a new avatar image, we must first:
 | 
						|
                //
 | 
						|
                // * Write it to disk.
 | 
						|
                // * Encrypt it
 | 
						|
                // * Upload it to asset service
 | 
						|
                // * Send asset service info to Signal Service
 | 
						|
                Log.verbose(.displayPictureManager, "Updating local profile on service with new avatar.")
 | 
						|
                
 | 
						|
                let fileName: String = dependencies[singleton: .crypto].generate(.uuid())
 | 
						|
                    .defaulting(to: UUID())
 | 
						|
                    .uuidString
 | 
						|
                    .appendingFileExtension(fileExtension)
 | 
						|
                
 | 
						|
                guard let filePath: String = try? self?.filepath(for: fileName) else {
 | 
						|
                    throw DisplayPictureError.invalidFilename
 | 
						|
                }
 | 
						|
                
 | 
						|
                // Write the avatar to disk
 | 
						|
                do { try finalImageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) }
 | 
						|
                catch {
 | 
						|
                    Log.error(.displayPictureManager, "Updating service with profile failed.")
 | 
						|
                    throw DisplayPictureError.writeFailed
 | 
						|
                }
 | 
						|
                
 | 
						|
                // Encrypt the avatar for upload
 | 
						|
                guard
 | 
						|
                    let encryptedData: Data = dependencies[singleton: .crypto].generate(
 | 
						|
                        .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey, using: dependencies)
 | 
						|
                    )
 | 
						|
                else {
 | 
						|
                    Log.error(.displayPictureManager, "Updating service with profile failed.")
 | 
						|
                    throw DisplayPictureError.encryptionFailed
 | 
						|
                }
 | 
						|
                
 | 
						|
                // Upload the avatar to the FileServer
 | 
						|
                guard
 | 
						|
                    let preparedUpload: Network.PreparedRequest<FileUploadResponse> = try? Network.preparedUpload(
 | 
						|
                        data: encryptedData,
 | 
						|
                        requestAndPathBuildTimeout: Network.fileUploadTimeout,
 | 
						|
                        using: dependencies
 | 
						|
                    )
 | 
						|
                else {
 | 
						|
                    Log.error(.displayPictureManager, "Updating service with profile failed.")
 | 
						|
                    throw DisplayPictureError.uploadFailed
 | 
						|
                }
 | 
						|
                
 | 
						|
                return (preparedUpload, fileName, newEncryptionKey, finalImageData)
 | 
						|
            }
 | 
						|
            .flatMap { [dependencies] preparedUpload, fileName, newEncryptionKey, finalImageData -> AnyPublisher<(FileUploadResponse, String, Data, Data), Error> in
 | 
						|
                preparedUpload.send(using: dependencies)
 | 
						|
                    .map { _, response -> (FileUploadResponse, String, Data, Data) in
 | 
						|
                        (response, fileName, newEncryptionKey, finalImageData)
 | 
						|
                    }
 | 
						|
                    .eraseToAnyPublisher()
 | 
						|
            }
 | 
						|
            .mapError { error in
 | 
						|
                Log.error(.displayPictureManager, "Updating service with profile failed with error: \(error).")
 | 
						|
                
 | 
						|
                switch error {
 | 
						|
                    case NetworkError.maxFileSizeExceeded: return DisplayPictureError.uploadMaxFileSizeExceeded
 | 
						|
                    case let displayPictureError as DisplayPictureError: return displayPictureError
 | 
						|
                    default: return DisplayPictureError.uploadFailed
 | 
						|
                }
 | 
						|
            }
 | 
						|
            .map { [dependencies] fileUploadResponse, fileName, newEncryptionKey, finalImageData -> UploadResult in
 | 
						|
                let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id)
 | 
						|
                
 | 
						|
                // Update the cached avatar image value
 | 
						|
                dependencies.mutate(cache: .displayPicture) {
 | 
						|
                    $0.imageData[fileName] = finalImageData
 | 
						|
                }
 | 
						|
                
 | 
						|
                Log.verbose(.displayPictureManager, "Successfully uploaded avatar image.")
 | 
						|
                return (downloadUrl, fileName, newEncryptionKey)
 | 
						|
            }
 | 
						|
            .eraseToAnyPublisher()
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - DisplayPictureManager.Owner
 | 
						|
 | 
						|
public extension DisplayPictureManager {
 | 
						|
    enum OwnerId: Hashable {
 | 
						|
        case user(String)
 | 
						|
        case group(String)
 | 
						|
        case community(String)
 | 
						|
    }
 | 
						|
    
 | 
						|
    enum Owner: Hashable {
 | 
						|
        case user(Profile)
 | 
						|
        case group(ClosedGroup)
 | 
						|
        case community(OpenGroup)
 | 
						|
        case file(String)
 | 
						|
        
 | 
						|
        var fileName: String? {
 | 
						|
            switch self {
 | 
						|
                case .user(let profile): return profile.profilePictureFileName
 | 
						|
                case .group(let group): return group.displayPictureFilename
 | 
						|
                case .community(let openGroup): return openGroup.displayPictureFilename
 | 
						|
                case .file(let name): return name
 | 
						|
            }
 | 
						|
        }
 | 
						|
        
 | 
						|
        var canDownloadImage: Bool {
 | 
						|
            switch self {
 | 
						|
                case .user(let profile): return (profile.profilePictureUrl?.isEmpty == false)
 | 
						|
                case .group(let group): return (group.displayPictureUrl?.isEmpty == false)
 | 
						|
                case .community(let openGroup): return (openGroup.imageId?.isEmpty == false)
 | 
						|
                case .file: return false
 | 
						|
            }
 | 
						|
        }
 | 
						|
        
 | 
						|
        fileprivate func clearCurrentFile(_ db: Database) {
 | 
						|
            switch self {
 | 
						|
                case .user(let profile):
 | 
						|
                    _ = try? Profile
 | 
						|
                        .filter(id: profile.id)
 | 
						|
                        .updateAll(db, Profile.Columns.profilePictureFileName.set(to: nil))
 | 
						|
                    
 | 
						|
                case .group(let group):
 | 
						|
                    _ = try? ClosedGroup
 | 
						|
                        .filter(id: group.id)
 | 
						|
                        .updateAll(db, ClosedGroup.Columns.displayPictureFilename.set(to: nil))
 | 
						|
                    
 | 
						|
                case .community(let openGroup):
 | 
						|
                    _ = try? OpenGroup
 | 
						|
                        .filter(id: openGroup.id)
 | 
						|
                        .updateAll(db, OpenGroup.Columns.displayPictureFilename.set(to: nil))
 | 
						|
                    
 | 
						|
                case .file: return
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - DisplayPictureManager.DownloadInfo
 | 
						|
 | 
						|
public extension DisplayPictureManager {
 | 
						|
    struct DownloadInfo: Hashable {
 | 
						|
        let owner: Owner
 | 
						|
        let currentFileInvalid: Bool
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - DisplayPicture Cache
 | 
						|
 | 
						|
public extension DisplayPictureManager {
 | 
						|
    class Cache: DisplayPictureCacheType {
 | 
						|
        public var imageData: [String: Data] = [:]
 | 
						|
        public var downloadsToSchedule: Set<DisplayPictureManager.DownloadInfo> = []
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
public extension Cache {
 | 
						|
    static let displayPicture: CacheConfig<DisplayPictureCacheType, DisplayPictureImmutableCacheType> = Dependencies.create(
 | 
						|
        identifier: "displayPicture",
 | 
						|
        createInstance: { _ in DisplayPictureManager.Cache() },
 | 
						|
        mutableInstance: { $0 },
 | 
						|
        immutableInstance: { $0 }
 | 
						|
    )
 | 
						|
}
 | 
						|
 | 
						|
// MARK: - DisplayPictureCacheType
 | 
						|
 | 
						|
/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way
 | 
						|
public protocol DisplayPictureImmutableCacheType: ImmutableCacheType {
 | 
						|
    var imageData: [String: Data] { get }
 | 
						|
    var downloadsToSchedule: Set<DisplayPictureManager.DownloadInfo> { get }
 | 
						|
}
 | 
						|
 | 
						|
public protocol DisplayPictureCacheType: DisplayPictureImmutableCacheType, MutableCacheType {
 | 
						|
    var imageData: [String: Data] { get set }
 | 
						|
    var downloadsToSchedule: Set<DisplayPictureManager.DownloadInfo> { get set }
 | 
						|
}
 |