finalized iOS setup
This commit is contained in:
@@ -1,8 +1,240 @@
|
||||
import UIKit
|
||||
import receive_sharing_intent
|
||||
import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
|
||||
class ShareViewController: RSIShareViewController {
|
||||
override func shouldAutoRedirect() -> Bool {
|
||||
return true
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user