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