Files
BizGaze_Remote/mobile/ios-share/ShareViewController.swift
T

551 lines
24 KiB
Swift
Raw Normal View History

import UIKit
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. If there's no token (user never opened/signed in), we say so and still stage the files so
// opening the app later picks them up.
struct ShareChat {
let kind: String // "dm" | "group"
let id: String
let name: String
let avatar: String?
var key: String { kind + ":" + id }
}
struct ShareItem {
let name: String
let url: URL // staged copy in the App Group container
let mime: String
let isText: Bool
let text: String
}
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>() // chat keys chosen for multi-send
private var avatarCache: [String: UIImage] = [:]
private var sending = false
private let table = UITableView(frame: .zero, style: .plain)
private let search = UISearchBar()
private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel()
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 = .systemBackground
// Branded navigation bar (Biz Connect navy, white title + buttons).
let nav = UINavigationBar()
nav.translatesAutoresizingMaskIntoConstraints = false
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = brandNavy
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
nav.standardAppearance = appearance
nav.scrollEdgeAppearance = appearance
nav.tintColor = .white
let navItem = UINavigationItem(title: "Send to…")
navItem.prompt = "Biz Connect" // small brand line above the title
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)
subtitle.font = .systemFont(ofSize: 12)
subtitle.textColor = .secondaryLabel
subtitle.textAlignment = .center
subtitle.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(subtitle)
search.placeholder = "Search chats…"
search.delegate = self
search.searchBarStyle = .minimal
search.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(search)
table.dataSource = self
table.delegate = self
table.rowHeight = 56
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat")
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),
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 4),
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
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: - Ingest shared items
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
self.writeManifest(collected) // safety net: app can still collect if send here fails
if collected.isEmpty { return self.finishFail("Couldnt read the shared file.") }
let fileCount = collected.filter { !$0.isText }.count
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
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 {
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.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 }
return ShareChat(kind: kind, id: id, name: name, avatar: row["avatar"] as? String)
}
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, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath)
let c = filtered[indexPath.row]
cell.textLabel?.text = c.name
cell.textLabel?.font = .systemFont(ofSize: 16)
cell.accessoryType = selected.contains(c.key) ? .checkmark : .none
cell.tintColor = brandNavy
// Round avatar: cached image, else initials placeholder + async load.
cell.imageView?.layer.cornerRadius = 18
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 }
// Data URL → decode inline.
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") } // /files needs auth
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 = 36) -> 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 = 36) -> 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()
// Aspect-fill into the circle.
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 (to every selected chat)
@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
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
// Upload each file ONCE; reuse its attachment id for every selected chat (server allows the
// uploader to attach the same id repeatedly).
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.clearManifest()
self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else {
self.sending = false
self.search.isHidden = false
self.table.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
}
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)
}
}
private func clearManifest() {
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() {
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() }
}
}
// A JSON id may decode as String or NSNumber depending on the value — normalise to String.
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()
}
}