From aab402f612e6cb320c63390e16746b77f5fa3652 Mon Sep 17 00:00:00 2001 From: Rafal Kolarz Date: Sat, 7 Nov 2026 23:09:07 +0000 Subject: [PATCH] Add Apple push registration alongside message refresh for TestFlight --- Kasztelan.xcodeproj/project.pbxproj | 12 ++++++--- Kasztelan/APIClient.swift | 10 ++++++++ Kasztelan/AppModel.swift | 3 +++ Kasztelan/Info.plist | 2 ++ Kasztelan/Kasztelan.entitlements | 3 +++ Kasztelan/KasztelanApp.swift | 13 +++++++++- Kasztelan/NotificationService.swift | 39 +++++++++++++++++++++++++++++ codemagic.yaml | 2 +- 8 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 Kasztelan/Kasztelan.entitlements diff --git a/Kasztelan.xcodeproj/project.pbxproj b/Kasztelan.xcodeproj/project.pbxproj index e06660e..264956d 100644 --- a/Kasztelan.xcodeproj/project.pbxproj +++ b/Kasztelan.xcodeproj/project.pbxproj @@ -292,12 +292,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CODE_SIGN_ENTITLEMENTS = Kasztelan/Kasztelan.entitlements; + APS_ENVIRONMENT = development; + CURRENT_PROJECT_VERSION = 17; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Kasztelan/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.10; PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -316,12 +318,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CODE_SIGN_ENTITLEMENTS = Kasztelan/Kasztelan.entitlements; + APS_ENVIRONMENT = production; + CURRENT_PROJECT_VERSION = 17; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Kasztelan/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0.9; + MARKETING_VERSION = 1.0.10; PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/Kasztelan/APIClient.swift b/Kasztelan/APIClient.swift index 1475607..fab7029 100644 --- a/Kasztelan/APIClient.swift +++ b/Kasztelan/APIClient.swift @@ -98,6 +98,16 @@ final class APIClient { try await request("GET", path: "/notifications/latest", token: token) } + func registerPush(deviceToken: String, environment: String, token: String) async throws { + let _: APIEnvelope = try await request("POST", path: "/push/device", token: token, body: [ + "device_token": deviceToken, "environment": environment + ]) + } + + func unregisterPush(deviceToken: String, token: String) async throws { + let _: APIEnvelope = try await request("DELETE", path: "/push/device", token: token, body: ["device_token": deviceToken]) + } + func wallet(token: String) async throws -> APIEnvelope { try await request("GET", path: "/wallet", token: token) } diff --git a/Kasztelan/AppModel.swift b/Kasztelan/AppModel.swift index 5cef220..988138a 100644 --- a/Kasztelan/AppModel.swift +++ b/Kasztelan/AppModel.swift @@ -149,6 +149,7 @@ final class AppModel: ObservableObject { let wallet = DiscountCardStore() await wallet.sync(using: self) } + await NotificationService.unregisterPush(session: currentToken) clearSession() try? await api.logout(token: currentToken) } @@ -175,6 +176,7 @@ final class AppModel: ObservableObject { func refresh() async { guard isAuthenticated else { return } + await NotificationService.syncPushRegistration() do { if let response = try await fetchAccount() { apply(response) } } catch { @@ -271,6 +273,7 @@ final class AppModel: ObservableObject { guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse } try KeychainStore.save(token: token) self.token = token + Task { await NotificationService.requestPermission() } apply(response) beginLiveRefresh() Task { await refreshMessages(showNotifications: true) } diff --git a/Kasztelan/Info.plist b/Kasztelan/Info.plist index 8cfe6b0..835c885 100644 --- a/Kasztelan/Info.plist +++ b/Kasztelan/Info.plist @@ -18,6 +18,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + KASZTELANPushEnvironment + $(APS_ENVIRONMENT) KASZTELANAppKey $(KASZTELAN_APP_KEY) LSRequiresIPhoneOS diff --git a/Kasztelan/Kasztelan.entitlements b/Kasztelan/Kasztelan.entitlements new file mode 100644 index 0000000..1f440f8 --- /dev/null +++ b/Kasztelan/Kasztelan.entitlements @@ -0,0 +1,3 @@ + + +aps-environment$(APS_ENVIRONMENT) diff --git a/Kasztelan/KasztelanApp.swift b/Kasztelan/KasztelanApp.swift index a6d9a1c..01bbb73 100644 --- a/Kasztelan/KasztelanApp.swift +++ b/Kasztelan/KasztelanApp.swift @@ -37,6 +37,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent return true } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { await NotificationService.receivedDeviceToken(deviceToken) } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + // APNs registration is attempted again on the next activation. + } + private func indexInSpotlight() { let attributes = CSSearchableItemAttributeSet(contentType: .application) attributes.title = "Kasztelan" @@ -149,7 +157,10 @@ struct KasztelanApp: App { } } .onChange(of: scenePhase) { phase in - if phase == .active { model.beginLiveRefresh() } + if phase == .active { + model.beginLiveRefresh() + Task { await NotificationService.requestPermission() } + } else { model.stopLiveRefresh() } } } diff --git a/Kasztelan/NotificationService.swift b/Kasztelan/NotificationService.swift index c1e0d78..9ee3c8a 100644 --- a/Kasztelan/NotificationService.swift +++ b/Kasztelan/NotificationService.swift @@ -1,17 +1,56 @@ import Foundation import UserNotifications +import UIKit enum NotificationService { + @MainActor static func requestPermission() async { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + UIApplication.shared.registerForRemoteNotifications() + } + + @MainActor private static var deviceToken: String? + @MainActor private static var registeredSession: String? + + @MainActor + static func receivedDeviceToken(_ data: Data) async { + deviceToken = data.map { String(format: "%02x", $0) }.joined() + registeredSession = nil + await syncPushRegistration() + } + + @MainActor + static func syncPushRegistration() async { + let session = KeychainStore.load() + guard !session.isEmpty, let deviceToken, registeredSession != session else { return } + let environment = Bundle.main.object(forInfoDictionaryKey: "KASZTELANPushEnvironment") as? String ?? "production" + do { + try await APIClient().registerPush(deviceToken: deviceToken, environment: environment, token: session) + // The account may have changed while the request was in flight. + if KeychainStore.load() == session { registeredSession = session } + } catch { + // Retry when the app next refreshes; keep account access working offline. + } + } + + @MainActor + static func unregisterPush(session: String) async { + registeredSession = nil + guard let deviceToken else { return } + try? await APIClient().unregisterPush(deviceToken: deviceToken, token: session) } static func show(_ message: KasztelanMessage) async { let settings = await UNUserNotificationCenter.current().notificationSettings() guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return } + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + guard !delivered.contains(where: { + ($0.request.content.userInfo["message_id"] as? Int) == message.id + }) else { return } let content = UNMutableNotificationContent() content.title = "KASZTELAN" content.subtitle = message.title + content.body = message.text content.sound = .default content.threadIdentifier = "kasztelan-messages" content.categoryIdentifier = "KASZTELAN_MESSAGE" diff --git a/codemagic.yaml b/codemagic.yaml index f9abf47..51d1e60 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -41,5 +41,5 @@ workflows: publishing: app_store_connect: auth: integration - submit_to_testflight: false + submit_to_testflight: true submit_to_app_store: false