import Foundation import UIKit import SwiftSignalKit import Postbox import SyncCore import TelegramCore import Display import CoreSpotlight import MobileCoreServices private let roundCorners = { () -> UIImage in let diameter: CGFloat = 60.0 UIGraphicsBeginImageContextWithOptions(CGSize(width: diameter, height: diameter), false, 0.0) let context = UIGraphicsGetCurrentContext()! context.setBlendMode(.copy) context.setFillColor(UIColor.black.cgColor) context.fill(CGRect(origin: CGPoint(), size: CGSize(width: diameter, height: diameter))) context.setFillColor(UIColor.clear.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: diameter, height: diameter))) let image = UIGraphicsGetImageFromCurrentImageContext()!.stretchableImage(withLeftCapWidth: Int(diameter / 2.0), topCapHeight: Int(diameter / 2.0)) UIGraphicsEndImageContext() return image }() private struct SpotlightIndexStorageItem: Codable, Equatable { var firstName: String var lastName: String var avatarSourcePath: String? } private final class SpotlightIndexStorage { private let appBasePath: String private let basePath: String private var items: [PeerId: SpotlightIndexStorageItem] = [:] init(appBasePath: String, basePath: String) { self.appBasePath = appBasePath self.basePath = basePath let _ = try? FileManager.default.createDirectory(atPath: basePath, withIntermediateDirectories: true, attributes: nil) self.reload() if self.items.isEmpty { CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: ["telegram-contacts"], completionHandler: { _ in }) } } private func path(peerId: PeerId) -> String { return self.basePath + "/p:\(UInt64(bitPattern: peerId.toInt64()))" } private func reload() { self.items.removeAll() guard let enumerator = FileManager.default.enumerator(at: URL(fileURLWithPath: self.basePath), includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsSubdirectoryDescendants], errorHandler: nil) else { return } while let item = enumerator.nextObject() { guard let url = item as? NSURL else { continue } guard let resourceValues = try? url.resourceValues(forKeys: [.isDirectoryKey]) else { continue } if let value = resourceValues[.isDirectoryKey] as? Bool, !value { continue } if let path = url.path, let directoryName = url.lastPathComponent, directoryName.hasPrefix("p:") { let peerIdString = directoryName[directoryName.index(directoryName.startIndex, offsetBy: 2)...] if let peerIdValue = UInt64(peerIdString) { let peerId = PeerId(Int64(bitPattern: peerIdValue)) let item: SpotlightIndexStorageItem if let itemData = try? Data(contentsOf: URL(fileURLWithPath: path + "/data.json")), let decodedItem = try? JSONDecoder().decode(SpotlightIndexStorageItem.self, from: itemData) { item = decodedItem } else { let _ = try? FileManager.default.removeItem(atPath: path + "/data.json") let _ = try? FileManager.default.removeItem(atPath: path + "/avatar.png") item = SpotlightIndexStorageItem(firstName: "", lastName: "", avatarSourcePath: nil) } self.items[peerId] = item } } } } func update(items: [PeerId: SpotlightIndexStorageItem]) { let validPeerIds = Set(items.keys) var removePeerIds: [PeerId] = [] for (peerId, item) in self.items { if !validPeerIds.contains(peerId) { removePeerIds.append(peerId) } } if !removePeerIds.isEmpty { for peerId in removePeerIds { let _ = try? FileManager.default.removeItem(atPath: self.path(peerId: peerId)) self.items.removeValue(forKey: peerId) } CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: removePeerIds.map { peerId in return "contact-\(peerId.toInt64())" }) } var addToIndexItems: [CSSearchableItem] = [] for (peerId, item) in items { let previousItem = self.items[peerId] if previousItem != item { var updatedAvatarSourcePath: String? if let avatarSourcePath = item.avatarSourcePath, let _ = fileSize(self.appBasePath + "/" + avatarSourcePath) { updatedAvatarSourcePath = avatarSourcePath } var encodeItem = item encodeItem.avatarSourcePath = updatedAvatarSourcePath if encodeItem == previousItem { continue } print("Spotlight: updating \(item.firstName) \(item.lastName)") let path = self.path(peerId: peerId) let _ = try? FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) var resolvedAvatarPath: String? if previousItem?.avatarSourcePath != updatedAvatarSourcePath { let avatarPath = path + "/avatar.png" let _ = try? FileManager.default.removeItem(atPath: avatarPath) if let updatedAvatarSourcePathValue = updatedAvatarSourcePath, let avatarData = try? Data(contentsOf: URL(fileURLWithPath: self.appBasePath + "/" + updatedAvatarSourcePathValue)), let image = UIImage(data: avatarData) { let size = CGSize(width: 120.0, height: 120.0) let context = DrawingContext(size: size, scale: 1.0, clear: true) context.withFlippedContext { c in c.draw(image.cgImage!, in: CGRect(origin: CGPoint(), size: size)) c.setBlendMode(.destinationOut) c.draw(roundCorners.cgImage!, in: CGRect(origin: CGPoint(), size: size)) } if let resultImage = context.generateImage(), let resultData = resultImage.pngData(), let _ = try? resultData.write(to: URL(fileURLWithPath: avatarPath)) { resolvedAvatarPath = avatarPath } else { updatedAvatarSourcePath = nil } } } let itemDataPath = path + "/data.json" let attributeSet = CSSearchableItemAttributeSet(itemContentType: kUTTypeText as String) attributeSet.version = "\(UInt64.random(in: 0 ..< UInt64.max))" if !item.firstName.isEmpty && !item.lastName.isEmpty { attributeSet.title = "\(item.firstName) \(item.lastName)" } else if !item.firstName.isEmpty { attributeSet.title = item.firstName } else { attributeSet.title = item.lastName } attributeSet.thumbnailURL = resolvedAvatarPath.flatMap(URL.init(fileURLWithPath:)) let indexItem = CSSearchableItem(uniqueIdentifier: "contact-\(peerId.toInt64())", domainIdentifier: "telegram-contacts", attributeSet: attributeSet) addToIndexItems.append(indexItem) encodeItem.avatarSourcePath = updatedAvatarSourcePath if let data = try? JSONEncoder().encode(encodeItem) { let _ = try? data.write(to: URL(fileURLWithPath: itemDataPath), options: [.atomic]) } self.items[peerId] = item } } if !addToIndexItems.isEmpty { CSSearchableIndex.default().indexSearchableItems(addToIndexItems, completionHandler: { error in if let error = error { Logger.shared.log("CSSearchableIndex", "indexSearchableItems error: \(error)") } }) } } } private func manageableSpotlightContacts(appBasePath: String, accounts: Signal<[Account], NoError>) -> Signal<[PeerId: SpotlightIndexStorageItem], NoError> { let queue = Queue() return accounts |> mapToSignal { accounts -> Signal<[[PeerId: SpotlightIndexStorageItem]], NoError> in return combineLatest(queue: queue, accounts.map { account -> Signal<[PeerId: SpotlightIndexStorageItem], NoError> in return account.postbox.contactPeersView(accountPeerId: account.peerId, includePresences: false) |> map { view -> [PeerId: SpotlightIndexStorageItem] in var result: [PeerId: SpotlightIndexStorageItem] = [:] for peer in view.peers { if let user = peer as? TelegramUser { let avatarSourcePath = smallestImageRepresentation(user.photo).flatMap { representation -> String? in let resourcePath = account.postbox.mediaBox.resourcePath(representation.resource) if resourcePath.hasPrefix(appBasePath + "/") { return String(resourcePath[resourcePath.index(resourcePath.startIndex, offsetBy: appBasePath.count + 1)...]) } else { return resourcePath } } result[user.id] = SpotlightIndexStorageItem(firstName: user.firstName ?? "", lastName: user.lastName ?? "", avatarSourcePath: avatarSourcePath) } } return result } |> distinctUntilChanged }) } |> map { accountContacts -> [PeerId: SpotlightIndexStorageItem] in var result: [PeerId: SpotlightIndexStorageItem] = [:] for singleAccountContacts in accountContacts { for (peerId, contact) in singleAccountContacts { if result[peerId] == nil { result[peerId] = contact } } } return result } } private final class SpotlightDataContextImpl { private let queue: Queue private let appBasePath: String private let accountManager: AccountManager private let indexStorage: SpotlightIndexStorage private var listDisposable: Disposable? init(queue: Queue, appBasePath: String, accountManager: AccountManager, accounts: Signal<[Account], NoError>) { self.queue = queue self.appBasePath = appBasePath self.accountManager = accountManager self.indexStorage = SpotlightIndexStorage(appBasePath: appBasePath, basePath: accountManager.basePath + "/spotlight") self.listDisposable = (manageableSpotlightContacts(appBasePath: appBasePath, accounts: accounts |> map { accounts in return accounts.sorted(by: { $0.id < $1.id }) } |> distinctUntilChanged(isEqual: { lhs, rhs in if lhs.count != rhs.count { return false } for i in 0 ..< lhs.count { if lhs[i] !== rhs[i] { return false } } return true })) |> deliverOn(self.queue)).start(next: { [weak self] items in guard let strongSelf = self else { return } strongSelf.updateContacts(items: items) }) } private func updateContacts(items: [PeerId: SpotlightIndexStorageItem]) { self.indexStorage.update(items: items) } } public final class SpotlightDataContext { private let impl: QueueLocalObject public init(appBasePath: String, accountManager: AccountManager, accounts: Signal<[Account], NoError>) { let queue = Queue() self.impl = QueueLocalObject(queue: queue, generate: { return SpotlightDataContextImpl(queue: queue, appBasePath: appBasePath, accountManager: accountManager, accounts: accounts) }) } }