/
githubmirror
/
client
Обзор
Документация
Войти
/
githubmirror
/
client
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
shared/ios/Keybase/ShareIntentDonatorImpl.swift
183 строки
7 KB
chrisnojima
android and ios fixes (#29326)
18 июн 2026, 19:41
Не верифицирован
18 июн 2026, 19:41
348632f
Код
Авторство
О чём код?
// // ShareIntentDonatorImpl.swift // Keybase // // Donates INSendMessageIntent for recent conversations to enable share sheet suggestions. // import Foundation import Intents import Keybasego import UIKit import os private let log = Logger(subsystem: "com.keybase.app", category: "share") private struct ShareConversation: Decodable { let convID: String let name: String let avatarURL: String let avatarURL2: String let lastSendTime: Double // Unix ms; used so we re-donate when send time changes enum CodingKeys: String, CodingKey { case convID = "ConvID" case name = "Name" case avatarURL = "AvatarURL" case avatarURL2 = "AvatarURL2" case lastSendTime = "LastSendTime" } } class ShareIntentDonatorImpl: NSObject, Keybasego.KeybaseShareIntentDonatorProtocol { // These protocol methods are invoked by Go over the gomobile cgo bridge, so they run on a // Go-runtime-managed thread, not a real NSThread/dispatch queue. JSONDecoder, CoreGraphics, // and Intents framework work must not run there; hop to a GCD queue first and return to Go // immediately. func deleteAllDonations() { DispatchQueue.global(qos: .utility).async { INInteraction.deleteAll { _ in } log.info("ShareIntentDonator: deleteAllDonations completed") } } func deleteDonation(_ conversationID: String?) { guard let id = conversationID, !id.isEmpty else { return } DispatchQueue.global(qos: .utility).async { INInteraction.delete(with: id, completion: nil) log.info("ShareIntentDonator: deleteDonation completed for \(id, privacy: .public)") } } func donateShareConversations(_ conversationsJSON: String?) { DispatchQueue.global(qos: .utility).async { [weak self] in guard let json = conversationsJSON, let data = json.data(using: .utf8) else { log.info("ShareIntentDonator: donateShareConversations: nil or invalid JSON") return } guard let conversations = try? JSONDecoder().decode([ShareConversation].self, from: data) else { log.info("ShareIntentDonator: donateShareConversations: JSON decode failed") return } guard !conversations.isEmpty else { log.info("ShareIntentDonator: donateShareConversations: empty conversations array") return } log.info("ShareIntentDonator: donateShareConversations: donating \(conversations.count) conversations") self?.donateConversations(conversations) } } private func donateConversations(_ conversations: [ShareConversation]) { for conv in conversations { let convID = conv.convID let name = conv.name let avatarURL = conv.avatarURL let avatarURL2 = conv.avatarURL2 let groupName = INSpeakableString(spokenPhrase: name.isEmpty ? "Keybase" : name) let intent = INSendMessageIntent( recipients: nil, outgoingMessageType: .outgoingMessageText, content: nil, speakableGroupName: groupName, conversationIdentifier: convID, serviceName: "Keybase", sender: nil, attachments: nil ) // Non-team multi-participant: composite AvatarURL + AvatarURL2; else single avatar // Apple requires a non-nil image for share sheet suggestions to appear. let urls = Self.avatarURLs(avatarURL: avatarURL, avatarURL2: avatarURL2) let onReady = { [weak self] in _ = self?.donateIntent(intent) } if urls.isEmpty { setFallbackImage(intent: intent) onReady() } else { loadAvatars(urls: urls, intent: intent, completion: onReady) } } } /// Returns avatar URL(s) for composite: two URLs if both present (multi-participant), else one. private static func avatarURLs(avatarURL: String, avatarURL2: String) -> [URL] { if !avatarURL2.isEmpty, let u1 = URL(string: avatarURL), let u2 = URL(string: avatarURL2) { return [u1, u2] } if !avatarURL.isEmpty, let u = URL(string: avatarURL) { return [u] } return [] } /// Loads avatar URL(s) and composites them. Apple requires a non-nil image for share sheet suggestions. private func loadAvatars(urls: [URL], intent: INSendMessageIntent, completion: @escaping () -> Void) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in let images = urls.compactMap { (try? Data(contentsOf: $0)).flatMap { UIImage(data: $0) } } if let combined = self?.compositeAvatarImages(images), let data = combined.pngData() { intent.setImage(INImage(imageData: data), forParameterNamed: \.speakableGroupName) } else { self?.setFallbackImage(intent: intent) } completion() } } /// Fallback image when avatar fetch fails. Apple requires a non-nil image for share sheet suggestions. private func setFallbackImage(intent: INSendMessageIntent) { let size: CGFloat = 64 let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) let image = renderer.image { ctx in UIColor.lightGray.setFill() ctx.fill(CGRect(origin: .zero, size: CGSize(width: size, height: size))) } if let data = image.pngData() { intent.setImage(INImage(imageData: data), forParameterNamed: \.speakableGroupName) } } /// Composites avatar images like frontend Avatars: two circles overlapping in a square. /// Single avatars fill the entire circle (full size, aspect fill); background is transparent. private func compositeAvatarImages(_ images: [UIImage]) -> UIImage? { guard !images.isEmpty else { return nil } let size: CGFloat = 192 let circleSize = size * 0.65 let leftRect = CGRect(origin: .zero, size: CGSize(width: circleSize, height: circleSize)) let rightRect = CGRect(x: size - circleSize, y: size - circleSize, width: circleSize, height: circleSize) let fullRect = CGRect(origin: .zero, size: CGSize(width: size, height: size)) let format = UIGraphicsImageRendererFormat() format.opaque = false let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size), format: format) return renderer.image { ctx in let cgContext = ctx.cgContext if images.count == 1 { let img = images[0] let imgSize = img.size let scale = max(size / imgSize.width, size / imgSize.height) let scaledW = imgSize.width * scale let scaledH = imgSize.height * scale let drawRect = CGRect(x: (size - scaledW) / 2, y: (size - scaledH) / 2, width: scaledW, height: scaledH) self.drawImageInCircle(img, in: fullRect, drawRect: drawRect, context: cgContext) } else { self.drawImageInCircle(images[0], in: leftRect, drawRect: leftRect, context: cgContext) self.drawImageInCircle(images[1], in: rightRect, drawRect: rightRect, context: cgContext) } } } /// Draws an image clipped to an oval. Uses aspect fill when drawRect differs from clip rect. private func drawImageInCircle(_ image: UIImage, in clipRect: CGRect, drawRect: CGRect, context: CGContext) { context.saveGState() UIBezierPath(ovalIn: clipRect).addClip() image.draw(in: drawRect) context.restoreGState() } private func donateIntent(_ intent: INSendMessageIntent) { let interaction = INInteraction(intent: intent, response: nil) interaction.donate { error in if let error = error { log.error("ShareIntentDonator: donateIntent failed for \(intent.conversationIdentifier ?? "?", privacy: .public): \(error.localizedDescription, privacy: .public)") } } } }