Files
BizGaze_Remote/mobile/ios-share/ShareViewController.swift
T
Sravan 0a2c7376b9 feat(share): "Biz Connect" in the iOS share sheet — share a photo/file into a chat
Adds the reverse direction: share FROM Photos/Files/Safari INTO a Biz Connect
conversation. An app can only appear in the iOS share sheet as an app-extension
target, so this is real native work, not a web change.

Pieces:
- mobile/ios-share/ShareViewController.swift: a UI-less Share Extension. It stages
  the shared items into the App Group container and opens bizconnect://share. It
  deliberately does NOT reimplement the chat picker — that lives in the app, which
  already has the chat list, search and upload progress. Appends to the manifest
  (never overwrites), so sharing twice before opening the app loses nothing.
- mobile/scripts/add-share-extension.rb: injects the extension target into the
  Capacitor-generated Xcode project on every CI build (Codemagic checks out fresh),
  using the xcodeproj gem that ships with CocoaPods. Embeds it, sets the bundle id
  <app>.share, and MERGES the App Group into the app's entitlements rather than
  clobbering them (push's aps-environment must survive). Idempotent.
- mobile/plugins/share-inbox: getPending()/clear() to read that manifest — the App
  Group container isn't one of Filesystem's known directories, so it needs a bridge.
- home.html: on bizconnect://share (and every resume, and cold-launch), read the
  inbox and show a "Send to…" picker over the chat list; chosen files run the SAME
  upload + /api/messages send as an in-app attachment. Reuses convertFileSrc to read
  the staged bytes with no base64 marshalling.
- ios-patch.sh registers the bizconnect URL scheme; codemagic.yaml fetches a profile
  for the .share bundle id too.

One-time manual gate (CI cannot toggle App capabilities): the App Group
group.com.bizgaze.connect must be created and enabled on both App IDs in the Apple
portal — documented in mobile/IOS_SETUP.md. Without it the two processes can't see
each other's files and sharing silently no-ops; everything else still works.

Validated cross-file: pod-name/jsName/method wiring for all three plugins, App
Group id identical in all 4 files, URL scheme consistent across extension/plist/web,
entitlement-merge preserves push. Needs a new iOS build (new targets + plugins).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:58:08 +05:30

164 lines
7.0 KiB
Swift

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.
//
// It deliberately has NO UI. Picking the conversation happens in the app, where the chat list, search and
// upload progress already exist — re-implementing all of that natively here would be a second, divergent
// copy of the app. So this extension only does the part that must happen inside it:
// 1. copy the shared items into the App Group container (the only storage both processes can see), and
// 2. bounce the user into the app, which picks them up and shows "Send to…".
// If step 2 is blocked, the files still sit staged and are collected the next time the app is opened —
// nothing is lost, the hand-off is just deferred.
class ShareViewController: UIViewController {
private let appGroup = "group.com.bizgaze.connect"
private let urlScheme = "bizconnect"
private let maxItems = 20
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 staged: [[String: Any]] = []
let lock = NSLock()
let group = DispatchGroup()
for provider in providers {
group.enter()
load(provider) { record in
if let record = record { lock.lock(); staged.append(record); lock.unlock() }
group.leave()
}
}
group.notify(queue: .main) { [weak self] in
guard let self = self else { return }
if !staged.isEmpty { self.appendToManifest(staged) }
self.openHostApp()
self.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"
}
}