Add Apple push registration alongside message refresh for TestFlight
This commit is contained in:
parent
75f8850115
commit
aab402f612
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
3
Kasztelan/Kasztelan.entitlements
Normal file
3
Kasztelan/Kasztelan.entitlements
Normal 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>
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user