Files
BizGaze_Remote/mobile/ios-share/ShareViewController.swift
T
Sravan 854bc86a6e feat(share-ext): fix double "Send to", Teams-style layout, clearer branding
Two things from testing (the in-sheet picker with avatars + multi-select works):

1. Double "Send to": after the extension sent, opening the app ALSO popped the web
   "Send to" modal for the same file. Cause: the extension wrote a safety-net
   manifest up front, which the app then picked up. Now the manifest represents an
   UNSENT share only — written solely when the extension can't send (no token) or a
   send fails. A successful in-sheet send clears the staged files and leaves nothing,
   so the app never re-offers it. Cancel also clears staged files (no orphans).

2. Layout aligned to the Teams reference:
   - Preview strip of thumbnails for what's being shared (image → the image, video →
     first frame via AVAssetImageGenerator, else a doc icon).
   - Radio selectors on the right — an always-visible empty circle that fills to a
     navy check when selected (clearer multi-select than an appear-on-select tick).
   - "Recent chats" section header; subtitle under each name (Direct message /
     Group · N members).
   - Clearer branding: bold white "Share to Biz Connect" on the navy bar.

Native-only — NEEDS A NEW iOS BUILD. Balance + selectors checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:25:15 +05:30

627 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>()
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")
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
sendButton = UIBarButtonItem(title: "Send", style: .done, 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() {
let n = selected.count
sendButton.title = n > 0 ? "Send (\(n))" : "Send"
sendButton.isEnabled = n > 0 && !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("Couldnt 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("Couldnt 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("Couldnt 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()
}
}