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>
This commit is contained in:
2026-07-24 18:25:15 +05:30
parent 4d446a5425
commit 854bc86a6e
+123 -47
View File
@@ -1,4 +1,5 @@
import UIKit import UIKit
import AVFoundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
// Share Extension puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way // Share Extension puts "Biz Connect" in the iOS share sheet AND does the whole send right here, the way
@@ -7,23 +8,30 @@ import UniformTypeIdentifiers
// How it can send without the app: the app writes a bearer token + API base into the App Group each launch // 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 // (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 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 // /api/messages.
// opening the app later picks them up. //
// 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 { struct ShareChat {
let kind: String // "dm" | "group" let kind: String // "dm" | "group"
let id: String let id: String
let name: String let name: String
let avatar: String? let avatar: String?
let subtitle: String
var key: String { kind + ":" + id } var key: String { kind + ":" + id }
} }
struct ShareItem { struct ShareItem {
let name: String let name: String
let url: URL // staged copy in the App Group container let url: URL
let mime: String let mime: String
let isText: Bool let isText: Bool
let text: String let text: String
var isImage: Bool { mime.hasPrefix("image/") }
var isVideo: Bool { mime.hasPrefix("video/") }
} }
class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { class ShareViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@@ -37,15 +45,16 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private var items: [ShareItem] = [] private var items: [ShareItem] = []
private var chats: [ShareChat] = [] private var chats: [ShareChat] = []
private var filtered: [ShareChat] = [] private var filtered: [ShareChat] = []
private var selected = Set<String>() // chat keys chosen for multi-send private var selected = Set<String>()
private var avatarCache: [String: UIImage] = [:] private var avatarCache: [String: UIImage] = [:]
private var sending = false private var sending = false
private let table = UITableView(frame: .zero, style: .plain) private let table = UITableView(frame: .zero, style: .grouped)
private let search = UISearchBar() private let search = UISearchBar()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let spinner = UIActivityIndicatorView(style: .medium) private let spinner = UIActivityIndicatorView(style: .medium)
private let subtitle = UILabel() private let previewStack = UIStackView()
private let previewScroll = UIScrollView()
private var sendButton: UIBarButtonItem! private var sendButton: UIBarButtonItem!
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -68,21 +77,18 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
// MARK: - UI // MARK: - UI
private func buildUI() { private func buildUI() {
view.backgroundColor = .systemBackground view.backgroundColor = .systemGroupedBackground
// Branded navigation bar (Biz Connect navy, white title + buttons).
let nav = UINavigationBar() let nav = UINavigationBar()
nav.translatesAutoresizingMaskIntoConstraints = false nav.translatesAutoresizingMaskIntoConstraints = false
let appearance = UINavigationBarAppearance() let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground() appearance.configureWithOpaqueBackground()
appearance.backgroundColor = brandNavy appearance.backgroundColor = brandNavy
appearance.titleTextAttributes = [.foregroundColor: UIColor.white] appearance.titleTextAttributes = [.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 17, weight: .bold)]
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
nav.standardAppearance = appearance nav.standardAppearance = appearance
nav.scrollEdgeAppearance = appearance nav.scrollEdgeAppearance = appearance
nav.tintColor = .white nav.tintColor = .white
let navItem = UINavigationItem(title: "Send to…") let navItem = UINavigationItem(title: "Share to Biz Connect")
navItem.prompt = "Biz Connect" // small brand line above the title
navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) navItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped)) sendButton = UIBarButtonItem(title: "Send", style: .done, target: self, action: #selector(sendTapped))
sendButton.isEnabled = false sendButton.isEnabled = false
@@ -90,13 +96,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
nav.setItems([navItem], animated: false) nav.setItems([navItem], animated: false)
view.addSubview(nav) view.addSubview(nav)
subtitle.font = .systemFont(ofSize: 12) // Preview strip thumbnails of what's being shared (like Teams' attachment row).
subtitle.textColor = .secondaryLabel previewScroll.translatesAutoresizingMaskIntoConstraints = false
subtitle.textAlignment = .center previewScroll.showsHorizontalScrollIndicator = false
subtitle.translatesAutoresizingMaskIntoConstraints = false previewStack.axis = .horizontal
view.addSubview(subtitle) previewStack.spacing = 8
previewStack.alignment = .center
previewStack.translatesAutoresizingMaskIntoConstraints = false
previewScroll.addSubview(previewStack)
view.addSubview(previewScroll)
search.placeholder = "Search chats…" search.placeholder = "Search for people or groups"
search.delegate = self search.delegate = self
search.searchBarStyle = .minimal search.searchBarStyle = .minimal
search.translatesAutoresizingMaskIntoConstraints = false search.translatesAutoresizingMaskIntoConstraints = false
@@ -104,8 +114,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
table.dataSource = self table.dataSource = self
table.delegate = self table.delegate = self
table.rowHeight = 56 table.rowHeight = 60
table.register(UITableViewCell.self, forCellReuseIdentifier: "chat") table.backgroundColor = .systemGroupedBackground
table.translatesAutoresizingMaskIntoConstraints = false table.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(table) view.addSubview(table)
@@ -124,16 +134,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), nav.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
nav.leadingAnchor.constraint(equalTo: view.leadingAnchor), nav.leadingAnchor.constraint(equalTo: view.leadingAnchor),
nav.trailingAnchor.constraint(equalTo: view.trailingAnchor), nav.trailingAnchor.constraint(equalTo: view.trailingAnchor),
subtitle.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 4),
subtitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), previewScroll.topAnchor.constraint(equalTo: nav.bottomAnchor, constant: 10),
subtitle.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), previewScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
search.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4), 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.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), search.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2), table.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 2),
table.leadingAnchor.constraint(equalTo: view.leadingAnchor), table.leadingAnchor.constraint(equalTo: view.leadingAnchor),
table.trailingAnchor.constraint(equalTo: view.trailingAnchor), table.trailingAnchor.constraint(equalTo: view.trailingAnchor),
table.bottomAnchor.constraint(equalTo: view.bottomAnchor), table.bottomAnchor.constraint(equalTo: view.bottomAnchor),
statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
@@ -157,7 +177,51 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
sendButton.isEnabled = n > 0 && !sending sendButton.isEnabled = n > 0 && !sending
} }
// MARK: - Ingest shared items // 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() { private func ingest() {
setStatus("Preparing…", busy: true) setStatus("Preparing…", busy: true)
@@ -179,10 +243,8 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
group.notify(queue: .main) { [weak self] in group.notify(queue: .main) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.items = collected 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.") } if collected.isEmpty { return self.finishFail("Couldnt read the shared file.") }
let fileCount = collected.filter { !$0.isText }.count self.buildPreviews()
self.subtitle.text = fileCount > 0 ? "Sharing \(fileCount) file\(fileCount == 1 ? "" : "s")" : "Sharing a link"
self.loadChats() self.loadChats()
} }
} }
@@ -225,6 +287,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func loadChats() { private func loadChats() {
guard !token.isEmpty else { 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.") setStatus("Open Biz Connect and sign in first, then share again.\n\nYour file is saved and will be waiting in the app.")
return return
} }
@@ -233,12 +296,14 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
guard let self = self else { return } guard let self = self else { return }
guard let data = data, guard let data = data,
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else { 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.") self.setStatus("Couldnt load your chats.\n\nOpen Biz Connect once, then try sharing again.")
return return
} }
let parsed: [ShareChat] = arr.compactMap { row in 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 } 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) 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 { DispatchQueue.main.async {
self.chats = parsed self.chats = parsed
@@ -253,15 +318,26 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filtered.count } 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 { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "chat", for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: "chat") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "chat")
let c = filtered[indexPath.row] let c = filtered[indexPath.row]
cell.textLabel?.text = c.name cell.textLabel?.text = c.name
cell.textLabel?.font = .systemFont(ofSize: 16) cell.textLabel?.font = .systemFont(ofSize: 16)
cell.accessoryType = selected.contains(c.key) ? .checkmark : .none cell.detailTextLabel?.text = c.subtitle
cell.tintColor = brandNavy cell.detailTextLabel?.textColor = .secondaryLabel
// Round avatar: cached image, else initials placeholder + async load. // Radio selector on the right (empty circle filled navy check), always visible like Teams.
cell.imageView?.layer.cornerRadius = 18 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 cell.imageView?.layer.masksToBounds = true
if let img = avatarCache[c.key] { if let img = avatarCache[c.key] {
cell.imageView?.image = img cell.imageView?.image = img
@@ -285,7 +361,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
private func loadAvatar(for chat: ShareChat) { private func loadAvatar(for chat: ShareChat) {
guard let av = chat.avatar, !av.isEmpty else { return } guard let av = chat.avatar, !av.isEmpty else { return }
// Data URL decode inline.
if av.hasPrefix("data:") { if av.hasPrefix("data:") {
if let comma = av.firstIndex(of: ","), if let comma = av.firstIndex(of: ","),
let d = Data(base64Encoded: String(av[av.index(after: comma)...])), let d = Data(base64Encoded: String(av[av.index(after: comma)...])),
@@ -297,7 +372,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let urlStr = av.hasPrefix("/") ? (apiBase + av) : av let urlStr = av.hasPrefix("/") ? (apiBase + av) : av
guard let url = URL(string: urlStr) else { return } guard let url = URL(string: urlStr) else { return }
var req = URLRequest(url: url) var req = URLRequest(url: url)
if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") } // /files needs auth if !token.isEmpty { req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") }
URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in URLSession.shared.dataTask(with: req) { [weak self] d, _, _ in
guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return } guard let self = self, let d = d, let img = Self.circularImage(from: d) else { return }
self.cache(img, for: chat) self.cache(img, for: chat)
@@ -313,7 +388,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
private func initialsImage(_ name: String, size: CGFloat = 36) -> UIImage { private func initialsImage(_ name: String, size: CGFloat = 40) -> UIImage {
let parts = name.split(separator: " ") let parts = name.split(separator: " ")
let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased() let initials = parts.prefix(2).compactMap { $0.first }.map { String($0) }.joined().uppercased()
let bg = Self.color(for: name) let bg = Self.color(for: name)
@@ -333,12 +408,11 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
private static func circularImage(from data: Data, size: CGFloat = 36) -> UIImage? { private static func circularImage(from data: Data, size: CGFloat = 40) -> UIImage? {
guard let img = UIImage(data: data) else { return nil } guard let img = UIImage(data: data) else { return nil }
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
return renderer.image { _ in return renderer.image { _ in
UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: size, height: size)).addClip() 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 scale = max(size / img.size.width, size / img.size.height)
let w = img.size.width * scale, h = img.size.height * scale 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)) img.draw(in: CGRect(x: (size - w) / 2, y: (size - h) / 2, width: w, height: h))
@@ -359,7 +433,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return palette[Int(h % UInt32(palette.count))] return palette[Int(h % UInt32(palette.count))]
} }
// MARK: - Send (to every selected chat) // MARK: - Send
@objc private func sendTapped() { @objc private func sendTapped() {
guard !sending else { return } guard !sending else { return }
@@ -369,6 +443,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
updateSendButton() updateSendButton()
search.isHidden = true search.isHidden = true
table.isHidden = true table.isHidden = true
previewScroll.isHidden = true
let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats" let label = targets.count == 1 ? targets[0].name : "\(targets.count) chats"
setStatus("Sending to \(label)", busy: true) setStatus("Sending to \(label)", busy: true)
@@ -376,9 +451,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
let files = self.items.filter { !$0.isText } let files = self.items.filter { !$0.isText }
let texts = self.items.filter { $0.isText }.map { $0.text } let texts = self.items.filter { $0.isText }.map { $0.text }
var ok = true 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] = [] var attIds: [String] = []
for (i, f) in files.enumerated() { for (i, f) in files.enumerated() {
if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)", busy: true) } if files.count > 1 { self.setStatus("Uploading \(i + 1) of \(files.count)", busy: true) }
@@ -395,16 +467,17 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
} }
DispatchQueue.main.async { DispatchQueue.main.async {
if ok { if ok {
self.clearManifest() self.clearStaged() // sent leave nothing for the app to re-offer
self.setStatus("✓ Sent to \(label)") self.setStatus("✓ Sent to \(label)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.finish() }
} else { } else {
self.writeManifest(self.items) // failed let the app pick these up
self.sending = false self.sending = false
self.search.isHidden = false self.search.isHidden = false
self.table.isHidden = false self.table.isHidden = false
self.previewScroll.isHidden = false
self.updateSendButton() self.updateSendButton()
self.setStatus("Couldnt send. Your file is saved — open Biz Connect to send it.") self.setStatus("Couldnt send. Your file is saved — open Biz Connect to send it.")
DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() } DispatchQueue.main.asyncAfter(deadline: .now() + 2.4) { self.finish() }
@@ -494,6 +567,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
return candidate 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]) { private func writeManifest(_ its: [ShareItem]) {
guard let dir = sharedDir() else { return } guard let dir = sharedDir() else { return }
let records: [[String: Any]] = its.map { it in let records: [[String: Any]] = its.map { it in
@@ -505,7 +579,9 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
private func clearManifest() { // 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 } guard let dir = sharedDir() else { return }
let fm = FileManager.default let fm = FileManager.default
if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { if let entries = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
@@ -521,6 +597,7 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
// MARK: - Finish // MARK: - Finish
@objc private func cancelTapped() { @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)) extensionContext?.cancelRequest(withError: NSError(domain: "share", code: 0))
} }
@@ -534,7 +611,6 @@ class ShareViewController: UIViewController, UITableViewDataSource, UITableViewD
} }
} }
// A JSON id may decode as String or NSNumber depending on the value normalise to String.
private func idString(_ v: Any?) -> String? { private func idString(_ v: Any?) -> String? {
if let s = v as? String { return s } if let s = v as? String { return s }
if let n = v as? NSNumber { return n.stringValue } if let n = v as? NSNumber { return n.stringValue }