BusinessCard/BusinessCard/Services/WatchConnectivityService.swift

272 lines
10 KiB
Swift

import Foundation
import WatchConnectivity
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit
import Bedrock
/// Manages WatchConnectivity session and sends card data to Apple Watch
@MainActor
final class WatchConnectivityService: NSObject {
static let shared = WatchConnectivityService()
private var session: WCSession?
private var isActivated = false
private var pendingCards: [BusinessCard]?
private override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
Design.debugLog("WatchConnectivity: Session activating...")
} else {
Design.debugLog("WatchConnectivity: Not supported on this device")
}
}
/// Syncs the given cards to the paired Apple Watch
func syncCards(_ cards: [BusinessCard]) {
// If not yet activated, store cards to send after activation
guard isActivated else {
Design.debugLog("WatchConnectivity: Session not ready, queuing \(cards.count) cards")
pendingCards = cards
return
}
guard let session = session else {
Design.debugLog("WatchConnectivity: No session available")
return
}
// In DEBUG, isWatchAppInstalled may be false even when running from Xcode
// So we only require isPaired and isReachable or just try to send anyway
#if DEBUG
guard session.isPaired else {
Design.debugLog("WatchConnectivity: Watch not paired")
return
}
Design.debugLog("WatchConnectivity: DEBUG mode - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
#else
guard session.isPaired, session.isWatchAppInstalled else {
Design.debugLog("WatchConnectivity: Watch not available (paired: \(session.isPaired), installed: \(session.isWatchAppInstalled))")
return
}
#endif
sendCardsToWatch(cards)
}
private func sendCardsToWatch(_ cards: [BusinessCard]) {
guard let session = session else { return }
Design.debugLog("WatchConnectivity: Syncing \(cards.count) cards to Watch (reachable: \(session.isReachable))")
let syncableCards = cards.map { card in
createSyncableCard(from: card)
}
do {
let encoded = try JSONEncoder().encode(syncableCards)
let message: [String: Any] = ["cards": encoded]
// ALWAYS update application context - this persists and will be available
// when watch app launches, even if not currently reachable
do {
try session.updateApplicationContext(message)
Design.debugLog("WatchConnectivity: Updated application context with \(encoded.count) bytes")
} catch {
Design.debugLog("WatchConnectivity: updateApplicationContext failed: \(error)")
}
// Also try immediate delivery if reachable
if session.isReachable {
session.sendMessage(message, replyHandler: nil) { error in
Task { @MainActor in
Design.debugLog("WatchConnectivity: sendMessage failed: \(error)")
}
}
Design.debugLog("WatchConnectivity: Also sent via sendMessage (reachable)")
}
} catch {
Design.debugLog("WatchConnectivity: ERROR - Failed to encode: \(error)")
}
}
fileprivate func handleActivation() {
isActivated = true
Design.debugLog("WatchConnectivity: Activation complete - paired: \(session?.isPaired ?? false), installed: \(session?.isWatchAppInstalled ?? false), reachable: \(session?.isReachable ?? false)")
// Send any pending cards that were queued before activation
if let cards = pendingCards {
Design.debugLog("WatchConnectivity: Sending \(cards.count) queued cards after activation")
pendingCards = nil
syncCards(cards)
}
}
/// Force a retry - call this after watch app is confirmed running
func retrySyncIfNeeded() {
guard let session = session else { return }
Design.debugLog("WatchConnectivity: Manual retry - paired: \(session.isPaired), installed: \(session.isWatchAppInstalled), reachable: \(session.isReachable)")
if let cards = pendingCards {
pendingCards = nil
syncCards(cards)
}
}
private func createSyncableCard(from card: BusinessCard) -> SyncableCard {
// Get first email, phone, website, location from contact fields
let email = card.firstContactField(ofType: "email")?.value ?? ""
let phone = card.firstContactField(ofType: "phone")?.value ?? ""
let website = card.firstContactField(ofType: "website")?.value ?? ""
let linkedIn = card.firstContactField(ofType: "linkedIn")?.value ?? ""
let twitter = card.firstContactField(ofType: "twitter")?.value ?? ""
let instagram = card.firstContactField(ofType: "instagram")?.value ?? ""
// Format address for display (decode from stored JSON/legacy format)
let addressValue = card.firstContactField(ofType: "address")?.value ?? ""
let location = PostalAddress.decode(from: addressValue)?.singleLineString ?? addressValue
// Build vCard payload for QR code generation (use vCardName for compatibility)
let vCardPayload = buildVCardPayload(
displayName: card.vCardName,
company: card.company,
role: card.role,
phone: phone,
email: email,
website: website,
location: location,
bio: card.bio,
linkedIn: linkedIn,
twitter: twitter,
instagram: instagram
)
// Generate QR code image data on iOS (CoreImage not available on watchOS)
let qrImageData = generateQRCodePNGData(from: vCardPayload)
// Use fullName - the single source of truth for display names
let syncDisplayName = card.fullName
Design.debugLog("WatchConnectivity: Syncing card '\(syncDisplayName)'")
return SyncableCard(
id: card.id,
fullName: syncDisplayName,
role: card.role,
company: card.company,
email: email,
phone: phone,
website: website,
location: location,
isDefault: card.isDefault,
pronouns: card.pronouns,
bio: card.bio,
linkedIn: linkedIn,
twitter: twitter,
instagram: instagram,
qrCodeImageData: qrImageData
)
}
private func buildVCardPayload(
displayName: String,
company: String,
role: String,
phone: String,
email: String,
website: String,
location: String,
bio: String,
linkedIn: String,
twitter: String,
instagram: String
) -> String {
var lines = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:\(displayName)",
"ORG:\(company)",
"TITLE:\(role)"
]
if !phone.isEmpty {
lines.append("TEL;TYPE=work:\(phone)")
}
if !email.isEmpty {
lines.append("EMAIL;TYPE=work:\(email)")
}
if !website.isEmpty {
lines.append("URL:\(website)")
}
if !location.isEmpty {
lines.append("ADR;TYPE=work:;;\(location)")
}
if !bio.isEmpty {
lines.append("NOTE:\(bio)")
}
if !linkedIn.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=linkedin:\(linkedIn)")
}
if !twitter.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=twitter:\(twitter)")
}
if !instagram.isEmpty {
lines.append("X-SOCIALPROFILE;TYPE=instagram:\(instagram)")
}
lines.append("END:VCARD")
return lines.joined(separator: "\n")
}
private func generateQRCodePNGData(from payload: String) -> Data? {
let context = CIContext()
let data = Data(payload.utf8)
let filter = CIFilter.qrCodeGenerator()
filter.setValue(data, forKey: "inputMessage")
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
// Scale up for better quality on watch display
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
// Convert to PNG data for syncing
let uiImage = UIImage(cgImage: cgImage)
return uiImage.pngData()
}
}
// MARK: - WCSessionDelegate
extension WatchConnectivityService: WCSessionDelegate {
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
Task { @MainActor in
if let error = error {
Design.debugLog("WatchConnectivity: Activation failed: \(error)")
} else {
Design.debugLog("WatchConnectivity: Activated with state: \(activationState.rawValue)")
WatchConnectivityService.shared.handleActivation()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {
Task { @MainActor in
Design.debugLog("WatchConnectivity: Session became inactive")
}
}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in
Design.debugLog("WatchConnectivity: Session deactivated, reactivating...")
WCSession.default.activate()
}
}
}