Files
BizGaze_Remote/mobile/ios-share/ShareViewController.swift
T
Sravan 2ec0a0c0cd feat(share-ext): confirmation card instead of a confusing blank flash
A Share Extension opening its host app is unsupported on modern iOS (restricted
~iOS 14), so the programmatic bizconnect://share open is silently blocked and the
user just saw a blank flash back to Photos — looking broken even though the files
staged fine.

The extension now shows a small native card after staging: "✓ Ready to send — Open
Biz Connect to choose a chat", with an "Open Biz Connect" button (user-initiated
open has the best chance of working) and a Done button. It still attempts the
auto-open first. Either way the app collects the staged files when next opened, so
the manual path that already works is unchanged — this just removes the "did it
even work?" confusion.

Renamed the local `staged` array to `collected` to free `staged` for the state
flag. Balance + selectors checked. NEEDS A NEW iOS BUILD (native change).

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

259 lines
11 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 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 = "Couldnt 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"
}
}