2026-07-23 22:58:08 +05:30
|
|
|
|
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.
|
|
|
|
|
|
//
|
2026-07-24 16:24:15 +05:30
|
|
|
|
// 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.
|
2026-07-23 22:58:08 +05:30
|
|
|
|
class ShareViewController: UIViewController {
|
|
|
|
|
|
|
|
|
|
|
|
private let appGroup = "group.com.bizgaze.connect"
|
|
|
|
|
|
private let urlScheme = "bizconnect"
|
|
|
|
|
|
private let maxItems = 20
|
|
|
|
|
|
|
2026-07-24 16:24:15 +05:30
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 22:58:08 +05:30
|
|
|
|
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() }
|
|
|
|
|
|
|
2026-07-24 16:24:15 +05:30
|
|
|
|
var collected: [[String: Any]] = []
|
2026-07-23 22:58:08 +05:30
|
|
|
|
let lock = NSLock()
|
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
|
|
|
|
|
|
|
for provider in providers {
|
|
|
|
|
|
group.enter()
|
|
|
|
|
|
load(provider) { record in
|
2026-07-24 16:24:15 +05:30
|
|
|
|
if let record = record { lock.lock(); collected.append(record); lock.unlock() }
|
2026-07-23 22:58:08 +05:30
|
|
|
|
group.leave()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
group.notify(queue: .main) { [weak self] in
|
|
|
|
|
|
guard let self = self else { return }
|
2026-07-24 16:24:15 +05:30
|
|
|
|
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)
|
2026-07-23 22:58:08 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:24:15 +05:30
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 22:58:08 +05:30
|
|
|
|
/// 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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|