Files
rely/ios/Share Extension/ShareViewController.swift
2026-05-18 20:33:54 +02:00

316 lines
8.8 KiB
Swift

import MobileCoreServices
import Social
import UniformTypeIdentifiers
import UIKit
private let schemePrefix = "ShareMedia"
private let userDefaultsKey = "ShareKey"
private let userDefaultsMessageKey = "ShareMessageKey"
private let appGroupIdKey = "AppGroupId"
private struct SharedMediaFile: Codable {
let path: String
let mimeType: String?
let thumbnail: String?
let duration: Double?
let message: String?
let type: SharedMediaType
}
private enum SharedMediaType: String, Codable, CaseIterable {
case image
case video
case text
case file
case url
var typeIdentifier: String {
if #available(iOS 14.0, *) {
switch self {
case .image:
return UTType.image.identifier
case .video:
return UTType.movie.identifier
case .text:
return UTType.text.identifier
case .file:
return UTType.fileURL.identifier
case .url:
return UTType.url.identifier
}
}
switch self {
case .image:
return kUTTypeImage as String
case .video:
return kUTTypeMovie as String
case .text:
return kUTTypeText as String
case .file:
return kUTTypeFileURL as String
case .url:
return kUTTypeURL as String
}
}
}
final class ShareViewController: SLComposeServiceViewController {
private var hostAppBundleIdentifier = ""
private var appGroupId = ""
private var sharedMedia: [SharedMediaFile] = []
private let group = DispatchGroup()
private var didFinishLoadingAttachments = false
override func viewDidLoad() {
super.viewDidLoad()
loadIdentifiers()
}
override func isContentValid() -> Bool {
true
}
override func didSelectPost() {
appendContentTextIfNeeded()
saveAndRedirect(message: contentText)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didFinishLoadingAttachments else {
return
}
didFinishLoadingAttachments = true
loadAttachments()
}
override func configurationItems() -> [Any]! {
[]
}
private func loadIdentifiers() {
let shareExtensionIdentifier = Bundle.main.bundleIdentifier ?? ""
if let lastDotIndex = shareExtensionIdentifier.lastIndex(of: ".") {
hostAppBundleIdentifier = String(shareExtensionIdentifier[..<lastDotIndex])
} else {
hostAppBundleIdentifier = shareExtensionIdentifier
}
let defaultAppGroupId = "group.\(hostAppBundleIdentifier)"
appGroupId =
Bundle.main.object(forInfoDictionaryKey: appGroupIdKey) as? String
?? defaultAppGroupId
}
private func loadAttachments() {
guard
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = inputItem.attachments
else {
appendContentTextIfNeeded()
saveAndRedirect(message: contentText)
return
}
NSLog("RelationshipSaverShare: loading %d attachment(s)", attachments.count)
for attachment in attachments {
loadAttachment(attachment)
}
group.notify(queue: .main) { [weak self] in
guard let self else {
return
}
self.appendContentTextIfNeeded()
if !self.sharedMedia.isEmpty {
self.saveAndRedirect()
} else {
NSLog("RelationshipSaverShare: no shareable media or text found")
}
}
}
private func loadAttachment(_ attachment: NSItemProvider) {
for type in SharedMediaType.allCases where attachment.hasItemConformingToTypeIdentifier(type.typeIdentifier) {
group.enter()
attachment.loadItem(forTypeIdentifier: type.typeIdentifier, options: nil) { [weak self] item, _ in
defer {
self?.group.leave()
}
self?.appendSharedItem(item, type: type)
}
return
}
}
private func appendSharedItem(_ item: NSSecureCoding?, type: SharedMediaType) {
switch (item, type) {
case let (text as String, .text):
appendLiteral(text, type: .text, mimeType: "text/plain")
case let (url as URL, .url):
appendLiteral(url.absoluteString, type: .url, mimeType: nil)
case let (url as URL, _):
appendFile(url, type: type)
case let (image as UIImage, .image):
appendImage(image)
default:
NSLog("RelationshipSaverShare: unsupported item for type %@", type.rawValue)
break
}
}
private func appendContentTextIfNeeded() {
let trimmed = contentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
guard !sharedMedia.contains(where: { $0.type == .text && $0.path == trimmed }) else {
return
}
NSLog("RelationshipSaverShare: using compose contentText fallback length=%d", trimmed.count)
appendLiteral(trimmed, type: .text, mimeType: "text/plain")
}
private func appendLiteral(_ value: String, type: SharedMediaType, mimeType: String?) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return
}
sharedMedia.append(
SharedMediaFile(
path: trimmed,
mimeType: mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
}
private func appendFile(_ sourceUrl: URL, type: SharedMediaType) {
guard let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return
}
let fileName = sourceUrl.lastPathComponent.isEmpty ? UUID().uuidString : sourceUrl.lastPathComponent
let destinationUrl = containerUrl.appendingPathComponent(fileName)
do {
if FileManager.default.fileExists(atPath: destinationUrl.path) {
try FileManager.default.removeItem(at: destinationUrl)
}
try FileManager.default.copyItem(at: sourceUrl, to: destinationUrl)
sharedMedia.append(
SharedMediaFile(
path: destinationUrl.absoluteString.removingPercentEncoding ?? destinationUrl.absoluteString,
mimeType: sourceUrl.mimeType,
thumbnail: nil,
duration: nil,
message: nil,
type: type
)
)
} catch {
return
}
}
private func appendImage(_ image: UIImage) {
guard
let containerUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let imageData = image.pngData()
else {
return
}
let destinationUrl = containerUrl.appendingPathComponent("\(UUID().uuidString).png")
do {
try imageData.write(to: destinationUrl)
sharedMedia.append(
SharedMediaFile(
path: destinationUrl.absoluteString.removingPercentEncoding ?? destinationUrl.absoluteString,
mimeType: "image/png",
thumbnail: nil,
duration: nil,
message: nil,
type: .image
)
)
} catch {
return
}
}
private func saveAndRedirect(message: String? = nil) {
guard let encodedData = try? JSONEncoder().encode(sharedMedia) else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
let userDefaults = UserDefaults(suiteName: appGroupId)
userDefaults?.set(encodedData, forKey: userDefaultsKey)
userDefaults?.set(message, forKey: userDefaultsMessageKey)
userDefaults?.synchronize()
NSLog("RelationshipSaverShare: saved %d shared item(s), appGroup=%@", sharedMedia.count, appGroupId)
redirectToHostApp()
}
private func redirectToHostApp() {
loadIdentifiers()
guard let url = URL(string: "\(schemePrefix)-\(hostAppBundleIdentifier):share") else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
var responder: UIResponder? = self
let selectorOpenUrl = sel_registerName("openURL:")
if #available(iOS 18.0, *) {
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
} else {
while responder != nil {
if responder?.responds(to: selectorOpenUrl) == true {
_ = responder?.perform(selectorOpenUrl, with: url)
break
}
responder = responder?.next
}
}
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
private extension URL {
var mimeType: String {
if #available(iOS 14.0, *),
let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType
{
return mimeType
}
guard
let identifier = UTTypeCreatePreferredIdentifierForTag(
kUTTagClassFilenameExtension,
pathExtension as NSString,
nil
)?.takeRetainedValue(),
let mimeType = UTTypeCopyPreferredTagWithClass(identifier, kUTTagClassMIMEType)?.takeRetainedValue()
else {
return "application/octet-stream"
}
return mimeType as String
}
}