import UIKit import AVFoundation import UniformTypeIdentifiers // Share Extension — puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way // Teams/WhatsApp do: pick one or more chats in the sheet, it uploads and sends, no app-open needed. // // How it can send without the app: the app writes a bearer token + API base into the App Group each launch // (via /api/share/token → ShareInbox.setAuth). This extension reads that token and calls the same server // API the native client uses — GET /api/messages/conversations, POST /api/messages/upload, POST // /api/messages. // // Manifest lifecycle (why the app doesn't also pop a "Send to" sheet): the App Group manifest represents an // UNSENT share. We only write it when this extension can't send (no token) or a send fails — so a // successful in-sheet send leaves nothing behind and the app never re-offers it. Cancel/success clear any // staged files too, so the App Group doesn't accumulate orphans. struct ShareChat { let kind: String // "dm" | "group" let id: String let name: String let avatar: String? let subtitle: String var key: String { kind + ":" + id } } struct ShareItem { let name: String let url: URL let mime: String let isText: Bool let text: String var isImage: Bool { mime.hasPrefix("image/") } var isVideo: Bool { mime.hasPrefix("video/") } } class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { private let appGroup = "group.com.bizgaze.connect" private let maxItems = 20 private let brandNavy = UIColor(red: 0x1F/255.0, green: 0x3B/255.0, blue: 0x73/255.0, alpha: 1) private var token = "" private var apiBase = "https://remote.bizgaze.com" private var items: [ShareItem] = [] private var chats: [ShareChat] = [] private var filtered: [ShareChat] = [] private var selected = Set() private var avatarCache: [String: UIImage] = [:] private var sending = false private let table = UITableView(frame: .zero, style: .grouped) private let search = UISearchBar() private let statusLabel = UILabel() private let spinner = UIActivityIndicatorView(style: .medium) private let previewStack = UIStackView() private let previewScroll = UIScrollView() private var sendButton: UIBarButtonItem! // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() readAuth() buildUI() ingest() } private func readAuth() { if let d = UserDefaults(suiteName: appGroup) { token = d.string(forKey: "bzc_token") ?? "" let b = d.string(forKey: "bzc_base") ?? "" if !b.isEmpty { apiBase = b } } } // MARK: - UI private func buildUI() { view.backgroundColor = .systemGroupedBackground let nav = UINavigationBar() nav.translatesAutoresizingMaskIntoConstraints = false let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = brandNavy appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)] nav.standardAppearance = appearance nav.scrollEdgeAppearance = appearance nav.tintColor = .white let navItem = UINavigationItem(title: "Share to Biz Connect") // Clean, light SF Symbols instead of a heavy grey X-circle / bold pill (thin xmark + paper-plane). let symCfg = UIImage.SymbolConfiguration(pointSize: 16, weight: .regular) navItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "xmark", withConfiguration: symCfg), style: .plain, target: self, action: #selector(cancelTapped)) let sendCfg = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold) sendButton = UIBarButtonItem(image: UIImage(systemName: "paperplane.fill", withConfiguration: sendCfg), style: .plain, target: self, action: #selector(sendTapped)) sendButton.isEnabled = false navItem.rightBarButtonItem = sendButton nav.setItems([navItem], animated: false) view.addSubview(nav) // Preview strip — thumbnails of what's being shared (like Teams' attachment row). previewScroll.translatesAutoresizingMaskIntoConstraints = false previewScroll.showsHorizontalScrollIndicator = false previewStack.axis = .horizontal previewStack.spacing = 8 previewStack.alignment = .center previewStack.translatesAutoresizingMaskIntoConstraints = false previewScroll.addSubview(previewStack) view.addSubview(previewScroll) search.placeholder = "Search for people or groups" search.delegate = self search.searchBarStyle = .minimal search.translatesAutoresizingMaskIntoConstraints = false view.addSubview(search) table.dataSource = self table.delegate = self table.rowHeight = 60 table.backgroundColor = .systemGroupedBackground table.translatesAutoresizingMaskIntoConstraints = false view.addSubview(table) statusLabel.font = .systemFont(ofSize: 15) statusLabel.textColor = .secondaryLabel statusLabel.textAlignment = .center statusLabel.numberOfLines = 0 statusLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(statusLabel) spinner.translatesAutoresizingMaskIntoConstraints = false spinner.hidesWhenStopped = true view.addSubview(spinner) NSLayoutConstraint.activate([ nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), nav.leadingAnchor.constraint(equalTo: view.leadingAnchor), nav.trailingAnchor.constraint(equalTo: view.trailingAnchor), previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10), previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), previewScroll.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), previewScroll.heightAnchor.constraint(equalToConstant: 56), previewStack.topAnchor.constraint(equalTo: previewScroll.topAnchor), previewStack.bottomAnchor.constraint(equalTo: previewScroll.bottomAnchor), previewStack.leadingAnchor.constraint(equalTo: previewScroll.leadingAnchor), previewStack.trailingAnchor.constraint(equalTo: previewScroll.trailingAnchor), previewStack.heightAnchor.constraint(equalTo: previewScroll.heightAnchor), search.topAnchor.constraint(equalTo: previewScroll.bottomAnchor, constant: 8), search.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6), search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2), table.leadingAnchor.constraint(equalTo: view.leadingAnchor), table.trailingAnchor.constraint(equalTo: view.trailingAnchor), table.bottomAnchor.constraint(equalTo: view.bottomAnchor), statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor), spinner.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 14) ]) } private func setStatus(_ text: String?, busy: Bool = false) { DispatchQueue.main.async { self.statusLabel.text = text self.statusLabel.isHidden = (text == nil) if busy { self.spinner.startAnimating() } else { self.spinner.stopAnimating() } } } private func updateSendButton() { // Icon-only paper-plane (like Teams); the radio checks show what's selected. Just enable/disable. sendButton.isEnabled = !selected.isEmpty && !sending } // MARK: - Preview thumbnails private func buildPreviews() { for f in items where !f.isText { let iv = UIImageView() iv.translatesAutoresizingMaskIntoConstraints = false iv.contentMode = .scaleAspectFill iv.clipsToBounds = true iv.layer.cornerRadius = 8 iv.backgroundColor = .tertiarySystemFill iv.tintColor = .secondaryLabel iv.widthAnchor.constraint(equalToConstant: 56).isActive = true iv.heightAnchor.constraint(equalToConstant: 56).isActive = true iv.image = UIImage(systemName: f.isVideo ? "video.fill" : "doc.fill") previewStack.addArrangedSubview(iv) thumbnail(for: f) { img in if let img = img { DispatchQueue.main.async { iv.image = img; iv.contentMode = .scaleAspectFill } } } } if items.contains(where: { $0.isText }) { let lbl = UILabel() lbl.text = "🔗 link" lbl.font = .systemFont(ofSize: 13) lbl.textColor = .secondaryLabel previewStack.addArrangedSubview(lbl) } } private func thumbnail(for item: ShareItem, completion: @escaping (UIImage?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { if item.isImage, let img = UIImage(contentsOfFile: item.url.path) { return completion(img) } if item.isVideo { let asset = AVURLAsset(url: item.url) let gen = AVAssetImageGenerator(asset: asset) gen.appliesPreferredTrackTransform = true gen.maximumSize = CGSize(width: 168, height: 168) if let cg = try? gen.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil) { return completion(UIImage(cgImage: cg)) } } completion(nil) } } // MARK: - Ingest private func ingest() { setStatus("Preparing…", busy: true) let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? []) .flatMap { $0.attachments ?? [] } .prefix(maxItems) guard !providers.isEmpty else { return finishFail("Nothing to share.") } var collected: [ShareItem] = [] let lock = NSLock() let group = DispatchGroup() for provider in providers { group.enter() load(provider) { item in if let item = item { lock.lock(); collected.append(item); lock.unlock() } group.leave() } } group.notify(queue: .main) { [weak self] in guard let self = self else { return } self.items = collected if collected.isEmpty { return self.finishFail("Couldn’t read the shared file.") } self.buildPreviews() self.loadChats() } } private func load(_ provider: NSItemProvider, completion: @escaping (ShareItem?) -> Void) { let fileTypes: [UTType] = [.movie, .image, .audio, .pdf, .item] if let type = fileTypes.first(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) { 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 if let u = item as? URL { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: u.absoluteString)) } else { completion(nil) } } return } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { item, _ in if let s = item as? String { completion(ShareItem(name: "", url: URL(fileURLWithPath: "/"), mime: "", isText: true, text: s)) } else { completion(nil) } } return } completion(nil) } private func stage(_ src: URL) -> ShareItem? { guard let dir = sharedDir() 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 } return ShareItem(name: dest.lastPathComponent, url: dest, mime: Self.mimeType(for: dest), isText: false, text: "") } // MARK: - Chat list private func loadChats() { guard !token.isEmpty else { writeManifest(items) // no token → let the app pick these up setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.") return } setStatus("Loading your chats…", busy: true) api("GET", "/api/messages/conversations") { [weak self] data, _ in guard let self = self else { return } guard let data = data, let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { self.writeManifest(self.items) self.setStatus("Couldn’t load your chats.\n\nOpen Biz Connect once, then try sharing again.") return } let parsed: [ShareChat] = arr.compactMap { row in guard let kind = row["kind"] as? String, let id = idString(row["id"]), let name = row["name"] as? String else { return nil } let sub = kind == "group" ? "Group" + ((row["members"] as? Int).map { " · \($0) members" } ?? "") : "Direct message" return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String, subtitle: sub) } DispatchQueue.main.async { self.chats = parsed self.filtered = parsed self.setStatus(parsed.isEmpty ? "No chats yet." : nil) self.table.reloadData() } } } // MARK: - Table func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count } func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { filtered.isEmpty ? nil : "Recent chats" } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat") let c = filtered[indexPath.row] cell.textLabel?.text = c.name cell.textLabel?.font = .systemFont(ofSize: 16) cell.detailTextLabel?.text = c.subtitle cell.detailTextLabel?.textColor = .secondaryLabel // Radio selector on the right (empty circle → filled navy check), always visible like Teams. let selImg = selected.contains(c.key) ? UIImage(systemName: "checkmark.circle.fill")?.withTintColor(brandNavy, renderingMode: .alwaysOriginal) : UIImage(systemName: "circle")?.withTintColor(.systemGray3, renderingMode: .alwaysOriginal) let selView = UIImageView(image: selImg) selView.frame = CGRect(x: 0, y: 0, width: 26, height: 26) cell.accessoryView = selView // Round avatar cell.imageView?.layer.cornerRadius = 20 cell.imageView?.layer.masksToBounds = true if let img = avatarCache[c.key] { cell.imageView?.image = img } else { cell.imageView?.image = initialsImage(c.name) loadAvatar(for: c) } return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard !sending else { return } let key = filtered[indexPath.row].key if selected.contains(key) { selected.remove(key) } else { selected.insert(key) } tableView.reloadRows(at: [indexPath], with: .none) updateSendButton() } // MARK: - Avatars private func loadAvatar(for chat: ShareChat) { guard let av = chat.avatar, !av.isEmpty else { return } if av.hasPrefix("data:") { if let comma = av.firstIndex(of: ","), let d = Data(base64Encoded: String(av[av.index(after: comma)...])), let img = Self.circularImage(from: d) { cache(img, for: chat) } return } let urlStr = av.hasPrefix("/") ? (apiBase + av) : av guard let url = URL(string: urlStr) else { return } var req = URLRequest(url: url) if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return } self.cache(img, for: chat) }.resume() } private func cache(_ img: UIImage, for chat: ShareChat) { DispatchQueue.main.async { self.avatarCache[chat.key] = img if let idx = self.filtered.firstIndex(where: { $0.key == chat.key }) { self.table.reloadRows(at: [IndexPath(row: idx, section: 0)], with: .none) } } } private func initialsImage(_ name: String, size: CGFloat = 40) -> UIImage { let parts = name.split(separator: " ") let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased() let bg = Self.color(for: name) let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { _ in bg.setFill() UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).fill() let para = NSMutableParagraphStyle(); para.alignment = .center let attrs: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: size * 0.4, weight: .semibold), .foregroundColor: UIColor.white, .paragraphStyle: para ] let s = (initials.isEmpty ? "?" : initials) as NSString let ts = s.size(withAttributes: attrs) s.draw(in: CGRect(x: 0, y: (size - ts.height) / 2, width: size, height: ts.height), withAttributes: attrs) } } private static func circularImage(from data: Data, size: CGFloat = 40) -> UIImage? { guard let img = UIImage(data: data) else { return nil } let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) return renderer.image { _ in UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip() let scale = max(size / img.size.width, size / img.size.height) let w = img.size.width * scale, h = img.size.height * scale img.draw(in: CGRect(x: (size - w) / 2, y: (size - h) / 2, width: w, height: h)) } } private static let palette: [UIColor] = [ UIColor(red: 0.20, green: 0.45, blue: 0.85, alpha: 1), UIColor(red: 0.85, green: 0.35, blue: 0.45, alpha: 1), UIColor(red: 0.30, green: 0.65, blue: 0.45, alpha: 1), UIColor(red: 0.75, green: 0.55, blue: 0.20, alpha: 1), UIColor(red: 0.50, green: 0.40, blue: 0.75, alpha: 1), UIColor(red: 0.25, green: 0.60, blue: 0.70, alpha: 1) ] private static func color(for name: String) -> UIColor { var h: UInt32 = 0 for c in name.unicodeScalars { h = h &* 31 &+ c.value } return palette[Int(h % UInt32(palette.count))] } // MARK: - Send @objc private func sendTapped() { guard !sending else { return } let targets = chats.filter { selected.contains($0.key) } guard !targets.isEmpty else { return } sending = true updateSendButton() search.isHidden = true table.isHidden = true previewScroll.isHidden = true let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats" setStatus("Sending to \(label)…", busy: true) DispatchQueue.global(qos: .userInitiated).async { let files = self.items.filter { !$0.isText } let texts = self.items.filter { $0.isText }.map { $0.text } var ok = true var attIds: [String] = [] for (i, f) in files.enumerated() { if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)…", busy: true) } guard let data = try? Data(contentsOf: f.url), let id = self.uploadSync(name: f.name, mime: f.mime, data: data) else { ok = false; break } attIds.append(id) } if ok { sendLoop: for chat in targets { for id in attIds { if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: id, text: nil)) { ok = false; break sendLoop } } if !texts.isEmpty { if !self.sendMessageSync(self.messageBody(chat: chat, attachmentId: nil, text: texts.joined(separator: "\n"))) { ok = false; break sendLoop } } } } DispatchQueue.main.async { if ok { self.clearStaged() // sent → leave nothing for the app to re-offer self.setStatus("✓ Sent to \(label)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() } } else { self.writeManifest(self.items) // failed → let the app pick these up self.sending = false self.search.isHidden = false self.table.isHidden = false self.previewScroll.isHidden = false self.updateSendButton() self.setStatus("Couldn’t send. Your file is saved — open Biz Connect to send it.") DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() } } } } } private func messageBody(chat: ShareChat, attachmentId: String?, text: String?) -> [String: Any] { var b: [String: Any] = [:] if chat.kind == "group" { b["group"] = chat.id } else { b["to"] = chat.id } if let a = attachmentId { b["attachmentId"] = a } if let t = text { b["body"] = t } return b } // MARK: - Networking private func uploadSync(name: String, mime: String, data: Data) -> String? { guard let url = URL(string: apiBase + "/api/messages/upload") else { return nil } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") req.setValue(mime.isEmpty ? "application/octet-stream" : mime, forHTTPHeaderField: "Content-Type") req.setValue(name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? name, forHTTPHeaderField: "X-Filename") req.httpBody = data req.timeoutInterval = 300 var out: String? = nil let sem = DispatchSemaphore(value: 0) URLSession.shared.dataTask(with: req) { d, resp, _ in defer { sem.signal() } guard let d = d, let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode), let obj = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] else { return } out = idString(obj["id"]) }.resume() sem.wait() return out } private func sendMessageSync(_ body: [String: Any]) -> Bool { guard let url = URL(string: apiBase + "/api/messages"), let json = try? JSONSerialization.data(withJSONObject: body) else { return false } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = json req.timeoutInterval = 60 var ok = false let sem = DispatchSemaphore(value: 0) URLSession.shared.dataTask(with: req) { _, resp, _ in if let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) { ok = true } sem.signal() }.resume() sem.wait() return ok } private func api(_ method: String, _ path: String, completion: @escaping (Data?, HTTPURLResponse?) -> Void) { guard let url = URL(string: apiBase + path) else { return completion(nil, nil) } var req = URLRequest(url: url) req.httpMethod = method req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") req.timeoutInterval = 30 URLSession.shared.dataTask(with: req) { d, resp, _ in completion(d, resp as? HTTPURLResponse) }.resume() } // MARK: - App Group storage private func sharedDir() -> 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 } // Written only when this extension can't send — it marks an UNSENT share for the app to pick up. private func writeManifest(_ its: [ShareItem]) { guard let dir = sharedDir() else { return } let records: [[String: Any]] = its.map { it in it.isText ? ["kind": "text", "text": it.text] : ["kind": "file", "name": it.name, "path": it.url.path, "mime": it.mime] } if let data = try? JSONSerialization.data(withJSONObject: records) { try? data.write(to: dir.appendingPathComponent("manifest.json"), options: .atomic) } } // Remove the staged files + any manifest, so the App Group doesn't accumulate and the app has nothing // to re-offer after a successful send or a cancel. private func clearStaged() { guard let dir = sharedDir() else { return } let fm = FileManager.default if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { for e in entries { try? fm.removeItem(at: e) } } } private static func mimeType(for url: URL) -> String { if let t = UTType(filenameExtension: url.pathExtension.lowercased()), let m = t.preferredMIMEType { return m } return "application/octet-stream" } // MARK: - Finish @objc private func cancelTapped() { clearStaged() // nothing sent → don't leave orphans or let the app re-offer extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0)) } private func finish() { extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) } private func finishFail(_ msg: String) { setStatus(msg) DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { self.finish() } } } private func idString(_ v: Any?) -> String? { if let s = v as? String { return s } if let n = v as? NSNumber { return n.stringValue } return nil } extension ShareViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() filtered = q.isEmpty ? chats : chats.filter { $0.name.lowercased().contains(q) } table.reloadData() } }