import UIKit import UniformTypeIdentifiers // Share Extension — this is what puts "Biz Connect" in the iOS share sheet (Photos, Files, Safari, …). // An app CANNOT appear there any other way: the share sheet only lists app extensions, so this has to be a // second target inside the app bundle, with its own bundle id and provisioning profile. // // The picker itself lives in the APP, not here — the chat list, search and upload progress already exist // there, and re-implementing them natively would be a second, divergent copy (and the extension can't // reach the web session where contacts live). So this extension: // 1. copies the shared items into the App Group container (the only storage both processes can see), // 2. tries to open the app (bizconnect://share), and // 3. shows a small confirmation card — because a Share Extension opening its host app is unsupported on // modern iOS and often silently blocked, so the card gives the user a clear "Open Biz Connect" tap // and states the files are ready. Either way the app collects the staged files when next opened, so // nothing is ever lost. class ShareViewController: UIViewController { private let appGroup = "group.com.bizgaze.connect" private let urlScheme = "bizconnect" private let maxItems = 20 // Minimal confirmation card (built in code — no storyboard). private let card = UIView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let openButton = UIButton(type: .system) private let doneButton = UIButton(type: .system) private var staged = false override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.black.withAlphaComponent(0.35) buildCard() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) handleShare() } // MARK: - Ingest private func handleShare() { let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? []) .flatMap { $0.attachments ?? [] } .prefix(maxItems) guard !providers.isEmpty else { return finish() } var collected: [[String: Any]] = [] let lock = NSLock() let group = DispatchGroup() for provider in providers { group.enter() load(provider) { record in if let record = record { lock.lock(); collected.append(record); lock.unlock() } group.leave() } } group.notify(queue: .main) { [weak self] in guard let self = self else { return } if !collected.isEmpty { self.appendToManifest(collected) } self.staged = !collected.isEmpty self.openHostApp() // best-effort; usually blocked, hence the card below self.showReady(count: collected.count) } } // MARK: - Confirmation UI private func buildCard() { card.backgroundColor = .systemBackground card.layer.cornerRadius = 16 card.translatesAutoresizingMaskIntoConstraints = false view.addSubview(card) titleLabel.text = "Preparing…" titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) titleLabel.textColor = .label titleLabel.textAlignment = .center titleLabel.numberOfLines = 0 subtitleLabel.text = "" subtitleLabel.font = .systemFont(ofSize: 13) subtitleLabel.textColor = .secondaryLabel subtitleLabel.textAlignment = .center subtitleLabel.numberOfLines = 0 openButton.setTitle("Open Biz Connect", for: .normal) openButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) openButton.setTitleColor(.white, for: .normal) openButton.backgroundColor = UIColor(red: 0.12, green: 0.23, blue: 0.45, alpha: 1) // brand navy openButton.layer.cornerRadius = 10 openButton.addTarget(self, action: #selector(openTapped), for: .touchUpInside) openButton.isHidden = true doneButton.setTitle("Done", for: .normal) doneButton.titleLabel?.font = .systemFont(ofSize: 15) doneButton.addTarget(self, action: #selector(doneTapped), for: .touchUpInside) doneButton.isHidden = true let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, openButton, doneButton]) stack.axis = .vertical stack.spacing = 12 stack.alignment = .fill stack.setCustomSpacing(6, after: titleLabel) stack.setCustomSpacing(18, after: subtitleLabel) stack.translatesAutoresizingMaskIntoConstraints = false card.addSubview(stack) NSLayoutConstraint.activate([ card.centerXAnchor.constraint(equalTo: view.centerXAnchor), card.centerYAnchor.constraint(equalTo: view.centerYAnchor), card.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32), card.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32), card.widthAnchor.constraint(lessThanOrEqualToConstant: 340), stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 22), stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -18), stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 22), stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -22), openButton.heightAnchor.constraint(equalToConstant: 46) ]) } private func showReady(count: Int) { if !staged { titleLabel.text = "Couldn’t prepare the file" subtitleLabel.text = "Please try sharing again." } else { titleLabel.text = "✓ Ready to send" let noun = count == 1 ? "item" : "\(count) items" subtitleLabel.text = "Open Biz Connect to choose a chat and send your \(noun)." openButton.isHidden = false } doneButton.isHidden = false } @objc private func openTapped() { openHostApp() finish() } @objc private func doneTapped() { finish() } /// Resolve one attachment to a staged file (or a text/link record). private func load(_ provider: NSItemProvider, completion: @escaping ([String: Any]?) -> Void) { // Most specific first; .item is the catch-all for arbitrary documents. let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item] if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) { // loadFileRepresentation hands back a URL that is deleted the moment this closure returns, // so the copy has to happen synchronously inside it. provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { [weak self] url, _ in guard let self = self, let url = url else { return completion(nil) } completion(self.stage(url)) } return } if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in completion((item as? URL).map { ["kind": "text", "text": $0.absoluteString] }) } return } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in completion((item as? String).map { ["kind": "text", "text": $0] }) } return } completion(nil) } private func stage(_ src: URL) -> [String: Any]? { guard let dir = sharedDirectory() else { return nil } let name = src.lastPathComponent.isEmpty ? "shared-file" : src.lastPathComponent let dest = uniqueURL(in: dir, preferred: name) do { try FileManager.default.copyItem(at: src, to: dest) } catch { return nil } let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0 return [ "kind": "file", "name": dest.lastPathComponent, "path": dest.path, "mime": Self.mimeType(for: dest), "size": size ?? 0 ] } // MARK: - App Group storage private func sharedDirectory() -> URL? { guard let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { return nil } let dir = base.appendingPathComponent("Shared", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } private func uniqueURL(in dir: URL, preferred: String) -> URL { let ext = (preferred as NSString).pathExtension let stem = (preferred as NSString).deletingPathExtension var candidate = dir.appendingPathComponent(preferred) var i = 2 while FileManager.default.fileExists(atPath: candidate.path) { let next = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)" candidate = dir.appendingPathComponent(next) i += 1 } return candidate } /// APPEND, never overwrite: the user can share twice before opening the app, and the second share /// must not discard the first. private func appendToManifest(_ records: [[String: Any]]) { guard let dir = sharedDirectory() else { return } let manifest = dir.appendingPathComponent("manifest.json") var all: [[String: Any]] = [] if let data = try? Data(contentsOf: manifest), let existing = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { all = existing } all.append(contentsOf: records) if let data = try? JSONSerialization.data(withJSONObject: all) { try? data.write(to: manifest, options: .atomic) } } // MARK: - Hand-off /// Share extensions have no public API to launch their container app, so walk the responder chain to /// UIApplication and use openURL:. If it fails the files stay staged and the app collects them on next /// launch, so this is an optimisation, never a requirement. private func openHostApp() { guard let url = URL(string: "\(urlScheme)://share") else { return } let selector = NSSelectorFromString("openURL:") var responder: UIResponder? = self while let current = responder { if current.responds(to: selector) && current is UIApplication { _ = current.perform(selector, with: url) return } responder = current.next } } private func finish() { extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) } private static func mimeType(for url: URL) -> String { if let type = UTType(filenameExtension: url.pathExtension.lowercased()), let mime = type.preferredMIMEType { return mime } return "application/octet-stream" } }