Add Apple push registration alongside message refresh for TestFlight

This commit is contained in:
Rafal Kolarz 2026-11-07 23:09:07 +00:00
parent 75f8850115
commit aab402f612
8 changed files with 78 additions and 6 deletions

View File

@ -292,12 +292,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; 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; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Kasztelan/Info.plist; INFOPLIST_FILE = Kasztelan/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0.9; MARKETING_VERSION = 1.0.10;
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -316,12 +318,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; 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; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Kasztelan/Info.plist; INFOPLIST_FILE = Kasztelan/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0.9; MARKETING_VERSION = 1.0.10;
PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios; PRODUCT_BUNDLE_IDENTIFIER = uk.co.kasztelan.app.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;

View File

@ -98,6 +98,16 @@ final class APIClient {
try await request("GET", path: "/notifications/latest", token: token) 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 { func wallet(token: String) async throws -> APIEnvelope {
try await request("GET", path: "/wallet", token: token) try await request("GET", path: "/wallet", token: token)
} }

View File

@ -149,6 +149,7 @@ final class AppModel: ObservableObject {
let wallet = DiscountCardStore() let wallet = DiscountCardStore()
await wallet.sync(using: self) await wallet.sync(using: self)
} }
await NotificationService.unregisterPush(session: currentToken)
clearSession() clearSession()
try? await api.logout(token: currentToken) try? await api.logout(token: currentToken)
} }
@ -175,6 +176,7 @@ final class AppModel: ObservableObject {
func refresh() async { func refresh() async {
guard isAuthenticated else { return } guard isAuthenticated else { return }
await NotificationService.syncPushRegistration()
do { do {
if let response = try await fetchAccount() { apply(response) } if let response = try await fetchAccount() { apply(response) }
} catch { } catch {
@ -271,6 +273,7 @@ final class AppModel: ObservableObject {
guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse } guard let token = response.token, !token.isEmpty else { throw APIError.invalidResponse }
try KeychainStore.save(token: token) try KeychainStore.save(token: token)
self.token = token self.token = token
Task { await NotificationService.requestPermission() }
apply(response) apply(response)
beginLiveRefresh() beginLiveRefresh()
Task { await refreshMessages(showNotifications: true) } Task { await refreshMessages(showNotifications: true) }

View File

@ -18,6 +18,8 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>KASZTELANPushEnvironment</key>
<string>$(APS_ENVIRONMENT)</string>
<key>KASZTELANAppKey</key> <key>KASZTELANAppKey</key>
<string>$(KASZTELAN_APP_KEY)</string> <string>$(KASZTELAN_APP_KEY)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>aps-environment</key><string>$(APS_ENVIRONMENT)</string></dict></plist>

View File

@ -37,6 +37,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
return true 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() { private func indexInSpotlight() {
let attributes = CSSearchableItemAttributeSet(contentType: .application) let attributes = CSSearchableItemAttributeSet(contentType: .application)
attributes.title = "Kasztelan" attributes.title = "Kasztelan"
@ -149,7 +157,10 @@ struct KasztelanApp: App {
} }
} }
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
if phase == .active { model.beginLiveRefresh() } if phase == .active {
model.beginLiveRefresh()
Task { await NotificationService.requestPermission() }
}
else { model.stopLiveRefresh() } else { model.stopLiveRefresh() }
} }
} }

View File

@ -1,17 +1,56 @@
import Foundation import Foundation
import UserNotifications import UserNotifications
import UIKit
enum NotificationService { enum NotificationService {
@MainActor
static func requestPermission() async { static func requestPermission() async {
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) _ = 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 { static func show(_ message: KasztelanMessage) async {
let settings = await UNUserNotificationCenter.current().notificationSettings() let settings = await UNUserNotificationCenter.current().notificationSettings()
guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return } 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() let content = UNMutableNotificationContent()
content.title = "KASZTELAN" content.title = "KASZTELAN"
content.subtitle = message.title content.subtitle = message.title
content.body = message.text
content.sound = .default content.sound = .default
content.threadIdentifier = "kasztelan-messages" content.threadIdentifier = "kasztelan-messages"
content.categoryIdentifier = "KASZTELAN_MESSAGE" content.categoryIdentifier = "KASZTELAN_MESSAGE"

View File

@ -41,5 +41,5 @@ workflows:
publishing: publishing:
app_store_connect: app_store_connect:
auth: integration auth: integration
submit_to_testflight: false submit_to_testflight: true
submit_to_app_store: false submit_to_app_store: false