241 lines
8.3 KiB
Swift
241 lines
8.3 KiB
Swift
import UIKit
|
|
import UniformTypeIdentifiers
|
|
import AVFoundation
|
|
|
|
// Datenmodell muss byte-für-byte zu dem passen, was
|
|
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
|
|
private enum SharedMediaType: String, Codable {
|
|
case image, video, text, file, url
|
|
}
|
|
|
|
private struct SharedMediaFile: Codable {
|
|
let path: String
|
|
let mimeType: String?
|
|
let thumbnail: String?
|
|
let duration: Double?
|
|
let message: String?
|
|
let type: SharedMediaType
|
|
}
|
|
|
|
final class ShareViewController: UIViewController {
|
|
// Schlüssel sind die, die das Plugin liest.
|
|
private let userDefaultsKey = "ShareKey"
|
|
private let userDefaultsMessageKey = "ShareMessageKey"
|
|
private let urlSchemePrefix = "ShareMedia"
|
|
|
|
private var appGroupId = ""
|
|
private var hostBundleId = ""
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
resolveIds()
|
|
setupPlaceholderUI()
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
processAttachments()
|
|
}
|
|
|
|
private func resolveIds() {
|
|
let extBundleId = Bundle.main.bundleIdentifier ?? ""
|
|
if let lastDot = extBundleId.lastIndex(of: ".") {
|
|
hostBundleId = String(extBundleId[..<lastDot])
|
|
}
|
|
let custom = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
|
|
if let custom, !custom.isEmpty, !custom.contains("$(") {
|
|
appGroupId = custom
|
|
} else {
|
|
appGroupId = "group.\(hostBundleId)"
|
|
}
|
|
}
|
|
|
|
private func setupPlaceholderUI() {
|
|
view.backgroundColor = .systemBackground
|
|
let spinner = UIActivityIndicatorView(style: .medium)
|
|
spinner.startAnimating()
|
|
let label = UILabel()
|
|
label.text = "Wird geteilt …"
|
|
label.font = .preferredFont(forTextStyle: .footnote)
|
|
label.textColor = .secondaryLabel
|
|
let stack = UIStackView(arrangedSubviews: [spinner, label])
|
|
stack.axis = .vertical
|
|
stack.alignment = .center
|
|
stack.spacing = 8
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
])
|
|
}
|
|
|
|
private func processAttachments() {
|
|
let attachments: [NSItemProvider] = (extensionContext?.inputItems ?? [])
|
|
.compactMap { $0 as? NSExtensionItem }
|
|
.flatMap { $0.attachments ?? [] }
|
|
guard !attachments.isEmpty else {
|
|
finish()
|
|
return
|
|
}
|
|
let group = DispatchGroup()
|
|
var collected: [SharedMediaFile] = []
|
|
let lock = NSLock()
|
|
for provider in attachments {
|
|
group.enter()
|
|
handle(provider: provider) { file in
|
|
if let f = file {
|
|
lock.lock(); collected.append(f); lock.unlock()
|
|
}
|
|
group.leave()
|
|
}
|
|
}
|
|
group.notify(queue: .main) { [weak self] in
|
|
self?.saveAndRedirect(items: collected)
|
|
}
|
|
}
|
|
|
|
// Reihenfolge entspricht SharedMediaType.allCases im Plugin —
|
|
// spezifische Typen (image, video) vor generischen (file, url).
|
|
private func handle(provider: NSItemProvider,
|
|
completion: @escaping (SharedMediaFile?) -> Void) {
|
|
let order: [(String, SharedMediaType)] = [
|
|
(UTType.image.identifier, .image),
|
|
(UTType.movie.identifier, .video),
|
|
(UTType.text.identifier, .text),
|
|
(UTType.fileURL.identifier, .file),
|
|
(UTType.url.identifier, .url),
|
|
(UTType.data.identifier, .file),
|
|
]
|
|
for (typeId, kind) in order {
|
|
if provider.hasItemConformingToTypeIdentifier(typeId) {
|
|
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
|
guard let self else { completion(nil); return }
|
|
if error != nil { completion(nil); return }
|
|
completion(self.toSharedFile(data: data, kind: kind))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
completion(nil)
|
|
}
|
|
|
|
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
|
|
switch kind {
|
|
case .text:
|
|
guard let s = data as? String else { return nil }
|
|
return SharedMediaFile(path: s, mimeType: "text/plain",
|
|
thumbnail: nil, duration: nil, message: nil, type: .text)
|
|
case .url:
|
|
guard let u = data as? URL else { return nil }
|
|
return SharedMediaFile(path: u.absoluteString, mimeType: nil,
|
|
thumbnail: nil, duration: nil, message: nil, type: .url)
|
|
case .image:
|
|
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
|
|
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
|
}
|
|
if let img = data as? UIImage, let dst = writePng(image: img) {
|
|
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
|
}
|
|
return nil
|
|
case .video:
|
|
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
|
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
|
thumbnail: nil, duration: videoDurationMs(url: u),
|
|
message: nil, type: .video)
|
|
case .file:
|
|
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
|
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
|
thumbnail: nil, duration: nil, message: nil, type: .file)
|
|
}
|
|
}
|
|
|
|
private func copyIntoAppGroup(src: URL) -> URL? {
|
|
guard let container = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
|
return nil
|
|
}
|
|
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
|
let dst = container.appendingPathComponent(name)
|
|
do {
|
|
if FileManager.default.fileExists(atPath: dst.path) {
|
|
try FileManager.default.removeItem(at: dst)
|
|
}
|
|
try FileManager.default.copyItem(at: src, to: dst)
|
|
return dst
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func writePng(image: UIImage) -> URL? {
|
|
guard let container = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
|
|
let data = image.pngData() else { return nil }
|
|
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
|
|
return (try? data.write(to: dst)) != nil ? dst : nil
|
|
}
|
|
|
|
// Das Plugin macht `path.replacingOccurrences(of: "file://", with: "")`,
|
|
// also liefern wir absoluteString mit Prozent-Decoding — selbes Format wie
|
|
// im Original-RSIShareViewController.
|
|
private func pathString(for url: URL) -> String {
|
|
url.absoluteString.removingPercentEncoding ?? url.absoluteString
|
|
}
|
|
|
|
private func mime(for url: URL) -> String? {
|
|
UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
|
|
}
|
|
|
|
private func videoDurationMs(url: URL) -> Double {
|
|
(CMTimeGetSeconds(AVURLAsset(url: url).duration) * 1000).rounded()
|
|
}
|
|
|
|
private func saveAndRedirect(items: [SharedMediaFile]) {
|
|
guard !items.isEmpty else {
|
|
finish()
|
|
return
|
|
}
|
|
let defaults = UserDefaults(suiteName: appGroupId)
|
|
guard let encoded = try? JSONEncoder().encode(items) else {
|
|
finish()
|
|
return
|
|
}
|
|
defaults?.set(encoded, forKey: userDefaultsKey)
|
|
defaults?.removeObject(forKey: userDefaultsMessageKey)
|
|
|
|
let urlStr = "\(urlSchemePrefix)-\(hostBundleId):share"
|
|
guard let url = URL(string: urlStr) else {
|
|
finish()
|
|
return
|
|
}
|
|
|
|
// Apple-DTS says Share Extensions are not officially allowed to open
|
|
// URLs. Both `extensionContext.open` and the responder-chain trick
|
|
// succeed in some iOS versions and fail silently in others, so we fire
|
|
// both in parallel and let whichever Apple is honouring this release
|
|
// win the race.
|
|
extensionContext?.open(url, completionHandler: nil)
|
|
var responder: UIResponder? = self
|
|
while responder != nil {
|
|
if let app = responder as? UIApplication {
|
|
app.open(url, options: [:], completionHandler: nil)
|
|
break
|
|
}
|
|
responder = responder?.next
|
|
}
|
|
|
|
// Brief window so the open request reaches the system before we tear
|
|
// the extension down.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
self?.finish()
|
|
}
|
|
}
|
|
|
|
private func finish() {
|
|
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
}
|
|
}
|