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>
This commit is contained in:
Trey t
2026-03-04 23:15:42 -06:00
parent bf5d60ca63
commit c5f2bee83f
22 changed files with 683 additions and 347 deletions

View File

@@ -3,6 +3,7 @@ import UserNotifications
import BackgroundTasks
import ComposeApp
@MainActor
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
@@ -19,20 +20,20 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
BackgroundTaskManager.shared.registerBackgroundTasks()
// Request notification permission
Task { @MainActor in
Task {
await PushNotificationManager.shared.requestNotificationPermission()
}
// Clear badge when app launches
Task { @MainActor in
PushNotificationManager.shared.clearBadge()
}
PushNotificationManager.shared.clearBadge()
// Initialize StoreKit and check for existing subscriptions
// This ensures we have the user's subscription status ready before they interact
Task {
_ = StoreKitManager.shared
print("✅ StoreKit initialized at app launch")
#if DEBUG
print("StoreKit initialized at app launch")
#endif
}
return true
@@ -42,9 +43,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
func applicationDidBecomeActive(_ application: UIApplication) {
// Clear badge when app becomes active
Task { @MainActor in
PushNotificationManager.shared.clearBadge()
}
PushNotificationManager.shared.clearBadge()
// Refresh StoreKit subscription status when app comes to foreground
// This ensures we have the latest subscription state if it changed while app was in background
@@ -59,31 +58,29 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
}
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Task { @MainActor in
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
}
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
}
// MARK: - UNUserNotificationCenterDelegate
// Called when notification is received while app is in foreground
func userNotificationCenter(
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
#if DEBUG
let userInfo = notification.request.content.userInfo
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
print("📬 Notification received in foreground. Keys: \(payloadKeys)")
print("Notification received in foreground. Keys: \(payloadKeys)")
#endif
// Passive mode in foreground: present banner/sound, but do not
// mutate read state or trigger navigation automatically.
@@ -91,7 +88,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
}
// Called when user taps on notification or selects an action
func userNotificationCenter(
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
@@ -99,9 +96,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
let userInfo = response.notification.request.content.userInfo
let actionIdentifier = response.actionIdentifier
print("👆 User interacted with notification - Action: \(actionIdentifier)")
#if DEBUG
print("User interacted with notification - Action: \(actionIdentifier)")
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
print(" Payload keys: \(payloadKeys)")
#endif
Task { @MainActor in
// Handle action buttons or default tap
@@ -109,8 +108,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// User tapped the notification body - navigate to task
PushNotificationManager.shared.handleNotificationTap(userInfo: userInfo)
} else if actionIdentifier == UNNotificationDismissActionIdentifier {
// User dismissed the notification
print("📤 Notification dismissed")
#if DEBUG
print("Notification dismissed")
#endif
} else {
// User selected an action button
PushNotificationManager.shared.handleNotificationAction(
@@ -118,8 +118,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
userInfo: userInfo
)
}
}
completionHandler()
completionHandler()
}
}
}

View File

@@ -31,7 +31,9 @@ struct NotificationCategories {
static func registerCategories() {
let categories = createAllCategories()
UNUserNotificationCenter.current().setNotificationCategories(categories)
#if DEBUG
print("Registered \(categories.count) notification categories")
#endif
}
/// Creates all notification categories for the app

View File

@@ -3,8 +3,9 @@ import UIKit
import UserNotifications
import ComposeApp
@MainActor
class PushNotificationManager: NSObject, ObservableObject {
@MainActor static let shared = PushNotificationManager()
static let shared = PushNotificationManager()
@Published var deviceToken: String?
@Published var notificationPermissionGranted = false
@@ -43,18 +44,24 @@ class PushNotificationManager: NSObject, ObservableObject {
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
}
}
@@ -65,16 +72,21 @@ class PushNotificationManager: NSObject, ObservableObject {
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 {
await registerDeviceWithBackend(token: tokenString)
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
@@ -82,30 +94,38 @@ class PushNotificationManager: NSObject, ObservableObject {
/// 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 {
await registerDeviceWithBackend(token: token, force: false)
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 {
await registerDeviceWithBackend(token: token, force: false)
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
}
@@ -116,12 +136,16 @@ class PushNotificationManager: NSObject, ObservableObject {
// 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
}
}
@@ -145,55 +169,73 @@ class PushNotificationManager: NSObject, ObservableObject {
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 {
await markNotificationAsRead(notificationId: notificationId)
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 {
await markNotificationAsRead(notificationId: notificationId)
Task { [weak self] in
guard let self else { return }
await self.markNotificationAsRead(notificationId: notificationId)
}
}
@@ -204,7 +246,9 @@ class PushNotificationManager: NSObject, ObservableObject {
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
@@ -224,12 +268,15 @@ class PushNotificationManager: NSObject, ObservableObject {
/// 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 {
await markNotificationAsRead(notificationId: notificationId)
Task { [weak self] in
guard let self else { return }
await self.markNotificationAsRead(notificationId: notificationId)
}
}
@@ -248,7 +295,9 @@ class PushNotificationManager: NSObject, ObservableObject {
// Extract task ID
guard let taskId = intValue(for: "task_id", in: userInfo) else {
#if DEBUG
print("❌ No task_id found in notification")
#endif
return
}
@@ -273,7 +322,9 @@ class PushNotificationManager: NSObject, ObservableObject {
navigateToEditTask(taskId: taskId)
default:
#if DEBUG
print("⚠️ Unknown action: \(actionIdentifier)")
#endif
navigateToTask(taskId: taskId)
}
}
@@ -282,38 +333,53 @@ class PushNotificationManager: NSObject, ObservableObject {
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")
Task {
#endif
Task { [weak self] in
guard let _ = self else { return }
do {
// Quick complete without photos/notes
let request = TaskCompletionCreateRequest(
@@ -327,84 +393,125 @@ class PushNotificationManager: NSObject, ObservableObject {
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")
Task {
#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")
Task {
#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")
Task {
#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
}
}
}
@@ -412,7 +519,9 @@ class PushNotificationManager: NSObject, ObservableObject {
// 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(
@@ -436,7 +545,9 @@ class PushNotificationManager: NSObject, ObservableObject {
}
private func navigateToEditTask(taskId: Int) {
#if DEBUG
print("✏️ Navigating to edit task \(taskId)")
#endif
NotificationCenter.default.post(
name: .navigateToEditTask,
object: nil,
@@ -445,7 +556,9 @@ class PushNotificationManager: NSObject, ObservableObject {
}
private func navigateToResidence(residenceId: Int) {
#if DEBUG
print("🏠 Navigating to residence \(residenceId)")
#endif
pendingNavigationResidenceId = residenceId
NotificationCenter.default.post(
name: .navigateToResidence,
@@ -459,7 +572,9 @@ class PushNotificationManager: NSObject, ObservableObject {
}
private func navigateToDocument(documentId: Int) {
#if DEBUG
print("📄 Navigating to document \(documentId)")
#endif
pendingNavigationDocumentId = documentId
NotificationCenter.default.post(
name: .navigateToDocument,
@@ -473,7 +588,9 @@ class PushNotificationManager: NSObject, ObservableObject {
}
private func navigateToHome() {
#if DEBUG
print("🏠 Navigating to home")
#endif
pendingNavigationTaskId = nil
pendingNavigationResidenceId = nil
pendingNavigationDocumentId = nil
@@ -519,14 +636,22 @@ class PushNotificationManager: NSObject, ObservableObject {
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
}
}
@@ -534,7 +659,9 @@ class PushNotificationManager: NSObject, ObservableObject {
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
guard TokenStorage.shared.getToken() != nil else {
#if DEBUG
print("⚠️ No auth token available")
#endif
return false
}
@@ -542,23 +669,33 @@ class PushNotificationManager: NSObject, ObservableObject {
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
}
@@ -568,13 +705,19 @@ class PushNotificationManager: NSObject, ObservableObject {
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
}
}