Files
honeyDueKMP/iosApp/iosApp/PushNotifications/PushNotificationManager.swift
Trey t c5f2bee83f Harden iOS app: fix concurrency, animations, formatters, privacy, and logging
- Eliminate NumberFormatters shared singleton data race; use local formatters
- Add reduceMotion checks to empty-state animations in 3 list views
- Wrap 68+ print() statements in #if DEBUG across push notification code
- Remove redundant .receive(on: DispatchQueue.main) in SubscriptionCache
- Remove redundant initializeLookups() call from iOSApp.init()
- Clean up StoreKitManager Task capture in listenForTransactions()
- Add memory warning observer to AuthenticatedImage cache
- Cache parseContent result in UpgradePromptView init
- Add DiskSpace and FileTimestamp API declarations to Privacy Manifest
- Add FIXME for analytics debug/production API key separation
- Use static formatter in PropertyHeaderCard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:15:42 -06:00

735 lines
26 KiB
Swift

import Foundation
import UIKit
import UserNotifications
import ComposeApp
@MainActor
class PushNotificationManager: NSObject, ObservableObject {
static let shared = PushNotificationManager()
@Published var deviceToken: String?
@Published var notificationPermissionGranted = false
/// Pending task ID to navigate to (set when notification is tapped before app is ready)
@Published var pendingNavigationTaskId: Int?
@Published var pendingNavigationResidenceId: Int?
@Published var pendingNavigationDocumentId: Int?
private let registeredTokenKey = "com.casera.registeredDeviceToken"
private let registeredUserIdKey = "com.casera.registeredUserId"
/// The last token that was successfully registered with the backend
private var lastRegisteredToken: String? {
get { UserDefaults.standard.string(forKey: registeredTokenKey) }
set { UserDefaults.standard.set(newValue, forKey: registeredTokenKey) }
}
/// The user ID associated with the last successful registration
private var lastRegisteredUserId: String? {
get { UserDefaults.standard.string(forKey: registeredUserIdKey) }
set { UserDefaults.standard.set(newValue, forKey: registeredUserIdKey) }
}
override init() {
super.init()
}
// MARK: - Permission Request
func requestNotificationPermission() async -> Bool {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
notificationPermissionGranted = granted
if granted {
#if DEBUG
print("✅ Notification permission granted")
#endif
// Register for remote notifications on main thread
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
} else {
#if DEBUG
print("❌ Notification permission denied")
#endif
}
return granted
} catch {
#if DEBUG
print("❌ Error requesting notification permission: \(error)")
#endif
return false
}
}
// MARK: - Token Management
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
self.deviceToken = tokenString
let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))"
#if DEBUG
print("📱 APNs device token: \(redactedToken)")
#endif
// Register with backend
Task { [weak self] in
guard let self else { return }
await self.registerDeviceWithBackend(token: tokenString)
}
}
func didFailToRegisterForRemoteNotifications(withError error: Error) {
#if DEBUG
print("❌ Failed to register for remote notifications: \(error)")
#endif
}
// MARK: - Backend Registration
/// Call this after login to register any pending device token
func registerDeviceAfterLogin() {
guard let token = deviceToken else {
#if DEBUG
print("⚠️ No device token available for registration")
#endif
return
}
Task { [weak self] in
guard let self else { return }
await self.registerDeviceWithBackend(token: token, force: false)
}
}
/// Call this when app returns from background to check and register if needed
func checkAndRegisterDeviceIfNeeded() {
guard let token = deviceToken else {
#if DEBUG
print("⚠️ No device token available for registration check")
#endif
return
}
Task { [weak self] in
guard let self else { return }
await self.registerDeviceWithBackend(token: token, force: false)
}
}
private func registerDeviceWithBackend(token: String, force: Bool = false) async {
guard TokenStorage.shared.getToken() != nil else {
#if DEBUG
print("⚠️ No auth token available, will register device after login")
#endif
return
}
let currentUserId = await MainActor.run {
DataManagerObservable.shared.currentUser.map { String($0.id) }
}
// Skip only if both token and user identity match.
if !force, token == lastRegisteredToken {
if let currentUserId, currentUserId == lastRegisteredUserId {
#if DEBUG
print("📱 Device token already registered for current user, skipping")
#endif
return
}
if currentUserId == nil, lastRegisteredUserId == nil {
#if DEBUG
print("📱 Device token already registered, skipping")
#endif
return
}
}
// Get unique device identifier
let deviceId = await MainActor.run {
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
}
let deviceName = await MainActor.run {
UIDevice.current.name
}
let request = DeviceRegistrationRequest(
deviceId: deviceId,
registrationId: token,
platform: "ios",
name: deviceName
)
do {
let result = try await APILayer.shared.registerDevice(request: request)
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
#if DEBUG
if success.data != nil {
print("✅ Device registered successfully")
} else {
print("✅ Device registration acknowledged")
}
#endif
// Cache the token on successful registration
await MainActor.run {
self.lastRegisteredToken = token
self.lastRegisteredUserId = currentUserId
}
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to register device: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result type from device registration")
#endif
}
} catch {
#if DEBUG
print("❌ Error registering device: \(error.localizedDescription)")
#endif
}
}
// MARK: - Handle Notifications
func handleNotification(userInfo: [AnyHashable: Any]) {
#if DEBUG
print("📬 Received notification: \(redactedPayloadSummary(userInfo: userInfo))")
#endif
// Extract notification data
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
#if DEBUG
print("Notification ID: \(notificationId)")
#endif
// Mark as read when user taps notification
Task { [weak self] in
guard let self else { return }
await self.markNotificationAsRead(notificationId: notificationId)
}
}
if let type = userInfo["type"] as? String {
#if DEBUG
print("Notification type: \(type)")
#endif
handleNotificationType(type: type, userInfo: userInfo)
}
}
/// Called when user taps the notification body (not an action button)
func handleNotificationTap(userInfo: [AnyHashable: Any]) {
#if DEBUG
print("📬 Handling notification tap")
#endif
// Mark as read
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
Task { [weak self] in
guard let self else { return }
await self.markNotificationAsRead(notificationId: notificationId)
}
}
// Check subscription status to determine navigation
// Allow navigation if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canNavigateToTask = isPremium || !limitationsEnabled
#if DEBUG
print("📬 Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")")
#endif
if canNavigateToTask {
// Navigate to task detail
if let taskId = intValue(for: "task_id", in: userInfo) {
navigateToTask(taskId: taskId)
} else if let type = userInfo["type"] as? String {
// Fall back to type-specific routing for non-task notifications.
handleNotificationType(type: type, userInfo: userInfo)
} else {
navigateToHome()
}
} else {
// Free user with limitations enabled - navigate to home
navigateToHome()
}
}
/// Called when user selects an action button on the notification
func handleNotificationAction(actionIdentifier: String, userInfo: [AnyHashable: Any]) {
#if DEBUG
print("🔘 Handling notification action: \(actionIdentifier)")
#endif
// Mark as read
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
Task { [weak self] in
guard let self else { return }
await self.markNotificationAsRead(notificationId: notificationId)
}
}
// Check subscription status
// Allow actions if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet
let subscription = SubscriptionCacheWrapper.shared.currentSubscription
let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro"
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
let canPerformAction = isPremium || !limitationsEnabled
guard canPerformAction else {
// Free user with limitations enabled shouldn't see actions, but if they somehow do, go to home
navigateToHome()
return
}
// Extract task ID
guard let taskId = intValue(for: "task_id", in: userInfo) else {
#if DEBUG
print("❌ No task_id found in notification")
#endif
return
}
// Handle action
switch actionIdentifier {
case NotificationActionID.viewTask.rawValue:
navigateToTask(taskId: taskId)
case NotificationActionID.completeTask.rawValue:
performCompleteTask(taskId: taskId)
case NotificationActionID.markInProgress.rawValue:
performMarkInProgress(taskId: taskId)
case NotificationActionID.cancelTask.rawValue:
performCancelTask(taskId: taskId)
case NotificationActionID.uncancelTask.rawValue:
performUncancelTask(taskId: taskId)
case NotificationActionID.editTask.rawValue:
navigateToEditTask(taskId: taskId)
default:
#if DEBUG
print("⚠️ Unknown action: \(actionIdentifier)")
#endif
navigateToTask(taskId: taskId)
}
}
private func handleNotificationType(type: String, userInfo: [AnyHashable: Any]) {
switch type {
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
if let taskId = intValue(for: "task_id", in: userInfo) {
#if DEBUG
print("Task notification for task ID: \(taskId)")
#endif
navigateToTask(taskId: taskId)
}
case "residence_shared":
if let residenceId = intValue(for: "residence_id", in: userInfo) {
#if DEBUG
print("Residence shared notification for residence ID: \(residenceId)")
#endif
navigateToResidence(residenceId: residenceId)
} else {
#if DEBUG
print("Residence shared notification without residence ID")
#endif
navigateToResidencesTab()
}
case "warranty_expiring":
if let documentId = intValue(for: "document_id", in: userInfo) {
#if DEBUG
print("Warranty expiring notification for document ID: \(documentId)")
#endif
navigateToDocument(documentId: documentId)
} else {
#if DEBUG
print("Warranty expiring notification without document ID")
#endif
navigateToDocumentsTab()
}
default:
#if DEBUG
print("Unknown notification type: \(type)")
#endif
}
}
// MARK: - Task Actions
private func performCompleteTask(taskId: Int) {
#if DEBUG
print("✅ Completing task \(taskId) from notification action")
#endif
Task { [weak self] in
guard let _ = self else { return }
do {
// Quick complete without photos/notes
let request = TaskCompletionCreateRequest(
taskId: Int32(taskId),
completedAt: nil,
notes: nil,
actualCost: nil,
rating: nil,
imageUrls: nil
)
let result = try await APILayer.shared.createTaskCompletion(request: request)
if result is ApiResultSuccess<TaskCompletionResponse> {
#if DEBUG
print("✅ Task \(taskId) completed successfully")
#endif
// Post notification for UI refresh
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to complete task: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result while completing task \(taskId)")
#endif
}
} catch {
#if DEBUG
print("❌ Error completing task: \(error.localizedDescription)")
#endif
}
}
}
private func performMarkInProgress(taskId: Int) {
#if DEBUG
print("🔄 Marking task \(taskId) as in progress from notification action")
#endif
Task { [weak self] in
guard let _ = self else { return }
do {
let result = try await APILayer.shared.markInProgress(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
#if DEBUG
print("✅ Task \(taskId) marked as in progress")
#endif
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to mark task in progress: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result while marking task \(taskId) in progress")
#endif
}
} catch {
#if DEBUG
print("❌ Error marking task in progress: \(error.localizedDescription)")
#endif
}
}
}
private func performCancelTask(taskId: Int) {
#if DEBUG
print("🚫 Cancelling task \(taskId) from notification action")
#endif
Task { [weak self] in
guard let _ = self else { return }
do {
let result = try await APILayer.shared.cancelTask(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
#if DEBUG
print("✅ Task \(taskId) cancelled")
#endif
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to cancel task: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result while cancelling task \(taskId)")
#endif
}
} catch {
#if DEBUG
print("❌ Error cancelling task: \(error.localizedDescription)")
#endif
}
}
}
private func performUncancelTask(taskId: Int) {
#if DEBUG
print("↩️ Uncancelling task \(taskId) from notification action")
#endif
Task { [weak self] in
guard let _ = self else { return }
do {
let result = try await APILayer.shared.uncancelTask(taskId: Int32(taskId))
if result is ApiResultSuccess<TaskResponse> {
#if DEBUG
print("✅ Task \(taskId) uncancelled")
#endif
await MainActor.run {
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
}
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to uncancel task: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result while uncancelling task \(taskId)")
#endif
}
} catch {
#if DEBUG
print("❌ Error uncancelling task: \(error.localizedDescription)")
#endif
}
}
}
// MARK: - Navigation
private func navigateToTask(taskId: Int) {
#if DEBUG
print("📱 Navigating to task \(taskId)")
#endif
// Store pending navigation in case MainTabView isn't ready yet
pendingNavigationTaskId = taskId
NotificationCenter.default.post(
name: .navigateToTask,
object: nil,
userInfo: ["taskId": taskId]
)
}
/// Clear pending navigation after it has been handled
func clearPendingNavigation() {
pendingNavigationTaskId = nil
pendingNavigationResidenceId = nil
pendingNavigationDocumentId = nil
}
/// Clear device registration cache (call on logout/account switch).
func clearRegistrationCache() {
lastRegisteredToken = nil
lastRegisteredUserId = nil
}
private func navigateToEditTask(taskId: Int) {
#if DEBUG
print("✏️ Navigating to edit task \(taskId)")
#endif
NotificationCenter.default.post(
name: .navigateToEditTask,
object: nil,
userInfo: ["taskId": taskId]
)
}
private func navigateToResidence(residenceId: Int) {
#if DEBUG
print("🏠 Navigating to residence \(residenceId)")
#endif
pendingNavigationResidenceId = residenceId
NotificationCenter.default.post(
name: .navigateToResidence,
object: nil,
userInfo: ["residenceId": residenceId]
)
}
private func navigateToResidencesTab() {
NotificationCenter.default.post(name: .navigateToResidence, object: nil)
}
private func navigateToDocument(documentId: Int) {
#if DEBUG
print("📄 Navigating to document \(documentId)")
#endif
pendingNavigationDocumentId = documentId
NotificationCenter.default.post(
name: .navigateToDocument,
object: nil,
userInfo: ["documentId": documentId]
)
}
private func navigateToDocumentsTab() {
NotificationCenter.default.post(name: .navigateToDocument, object: nil)
}
private func navigateToHome() {
#if DEBUG
print("🏠 Navigating to home")
#endif
pendingNavigationTaskId = nil
pendingNavigationResidenceId = nil
pendingNavigationDocumentId = nil
NotificationCenter.default.post(name: .navigateToHome, object: nil)
}
private func stringValue(for key: String, in userInfo: [AnyHashable: Any]) -> String? {
if let value = userInfo[key] as? String {
return value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
if let value = userInfo[key] as? NSNumber {
return value.stringValue
}
if let value = userInfo[key] as? Int {
return String(value)
}
return nil
}
private func intValue(for key: String, in userInfo: [AnyHashable: Any]) -> Int? {
guard let raw = stringValue(for: key, in: userInfo) else {
return nil
}
return Int(raw)
}
private func redactedPayloadSummary(userInfo: [AnyHashable: Any]) -> String {
let type = stringValue(for: "type", in: userInfo) ?? "unknown"
let notificationId = stringValue(for: "notification_id", in: userInfo) ?? "missing"
let taskId = stringValue(for: "task_id", in: userInfo) ?? "none"
let residenceId = stringValue(for: "residence_id", in: userInfo) ?? "none"
let documentId = stringValue(for: "document_id", in: userInfo) ?? "none"
return "type=\(type), notification_id=\(notificationId), task_id=\(taskId), residence_id=\(residenceId), document_id=\(documentId)"
}
private func markNotificationAsRead(notificationId: String) async {
guard TokenStorage.shared.getToken() != nil,
let notificationIdInt = Int32(notificationId) else {
return
}
do {
let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt)
if result is ApiResultSuccess<MessageResponse> {
#if DEBUG
print("✅ Notification marked as read")
#endif
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to mark notification as read: \(error.message)")
#endif
} else {
#if DEBUG
print("⚠️ Unexpected result while marking notification read")
#endif
}
} catch {
#if DEBUG
print("❌ Error marking notification as read: \(error.localizedDescription)")
#endif
}
}
// MARK: - Notification Preferences
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
guard TokenStorage.shared.getToken() != nil else {
#if DEBUG
print("⚠️ No auth token available")
#endif
return false
}
do {
let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
if result is ApiResultSuccess<NotificationPreference> {
#if DEBUG
print("✅ Notification preferences updated")
#endif
return true
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to update preferences: \(error.message)")
#endif
return false
}
#if DEBUG
print("⚠️ Unexpected result while updating notification preferences")
#endif
return false
} catch {
#if DEBUG
print("❌ Error updating notification preferences: \(error.localizedDescription)")
#endif
return false
}
}
func getNotificationPreferences() async -> NotificationPreference? {
guard TokenStorage.shared.getToken() != nil else {
#if DEBUG
print("⚠️ No auth token available")
#endif
return nil
}
do {
let result = try await APILayer.shared.getNotificationPreferences()
if let success = result as? ApiResultSuccess<NotificationPreference> {
return success.data
} else if let error = ApiResultBridge.error(from: result) {
#if DEBUG
print("❌ Failed to get preferences: \(error.message)")
#endif
return nil
}
#if DEBUG
print("⚠️ Unexpected result while loading notification preferences")
#endif
return nil
} catch {
#if DEBUG
print("❌ Error getting notification preferences: \(error.localizedDescription)")
#endif
return nil
}
}
// MARK: - Badge Management
func clearBadge() {
UNUserNotificationCenter.current().setBadgeCount(0)
}
func setBadge(count: Int) {
UNUserNotificationCenter.current().setBadgeCount(count)
}
}