Add push notifications support

This commit is contained in:
Thomas Ricouard 2023-01-08 10:22:52 +01:00
parent a1a6c3091e
commit 424cd475ad
16 changed files with 768 additions and 16 deletions

View file

@ -13,6 +13,12 @@
9F2A540A29699705009B2D7C /* ReceiptParser in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A540929699705009B2D7C /* ReceiptParser */; };
9F2A540C29699705009B2D7C /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A540B29699705009B2D7C /* RevenueCat */; };
9F2A540E2969A0B0009B2D7C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F2A540D2969A0B0009B2D7C /* StoreKit.framework */; };
9F2A5411296A1429009B2D7C /* PushNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */; };
9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A5418296AB631009B2D7C /* NotificationService.swift */; };
9F2A541D296AB631009B2D7C /* IceCubesNotifications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9F2A5424296AB67A009B2D7C /* Env in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5423296AB67A009B2D7C /* Env */; };
9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5425296AB67E009B2D7C /* KeychainSwift */; };
9F2A5428296AB683009B2D7C /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 9F2A5427296AB683009B2D7C /* Models */; };
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; };
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; };
9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; };
@ -46,6 +52,30 @@
9FE151A6293C90F900E9683D /* IconSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FE151A5293C90F900E9683D /* IconSelectorView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
9F2A541B296AB631009B2D7C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 9FBFE631292A715500C250E9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9F2A5415296AB631009B2D7C;
remoteInfo = IceCubesNotifications;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9F2A5421296AB631009B2D7C /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
9F2A541D296AB631009B2D7C /* IceCubesNotifications.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
9F24EEB729360C330042359D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
9F29553D292B67B600E0E81B /* Network */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Network; path = Packages/Network; sourceTree = "<group>"; };
@ -53,6 +83,11 @@
9F2A5404296995FB009B2D7C /* QuickLookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookUI.framework; path = System/Library/Frameworks/QuickLookUI.framework; sourceTree = SDKROOT; };
9F2A540629699698009B2D7C /* SupportAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportAppView.swift; sourceTree = "<group>"; };
9F2A540D2969A0B0009B2D7C /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsView.swift; sourceTree = "<group>"; };
9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = IceCubesNotifications.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9F2A5418296AB631009B2D7C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
9F2A541A296AB631009B2D7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesNotifications.entitlements; sourceTree = "<group>"; };
9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; };
9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = "<group>"; };
9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = "<group>"; };
@ -89,6 +124,16 @@
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
9F2A5413296AB631009B2D7C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9F2A5428296AB683009B2D7C /* Models in Frameworks */,
9F2A5426296AB67E009B2D7C /* KeychainSwift in Frameworks */,
9F2A5424296AB67A009B2D7C /* Env in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
9FBFE636292A715500C250E9 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -115,6 +160,16 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
9F2A5417296AB631009B2D7C /* IceCubesNotifications */ = {
isa = PBXGroup;
children = (
9F2A5422296AB64B009B2D7C /* IceCubesNotifications.entitlements */,
9F2A5418296AB631009B2D7C /* NotificationService.swift */,
9F2A541A296AB631009B2D7C /* Info.plist */,
);
path = IceCubesNotifications;
sourceTree = "<group>";
};
9F398AB429360A5800A889F2 /* App */ = {
isa = PBXGroup;
children = (
@ -172,6 +227,7 @@
isa = PBXGroup;
children = (
9FBFE63B292A715500C250E9 /* IceCubesApp */,
9F2A5417296AB631009B2D7C /* IceCubesNotifications */,
9FBFE63A292A715500C250E9 /* Products */,
9FBFE64C292A72BD00C250E9 /* Frameworks */,
9F398AAC2936005300A889F2 /* Account */,
@ -192,6 +248,7 @@
isa = PBXGroup;
children = (
9FBFE639292A715500C250E9 /* IceCubesApp.app */,
9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */,
);
name = Products;
sourceTree = "<group>";
@ -228,6 +285,7 @@
9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */,
9F7335F82968576500AFF0BA /* DisplaySettingsView.swift */,
9F2A540629699698009B2D7C /* SupportAppView.swift */,
9F2A5410296A1429009B2D7C /* PushNotificationsView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -235,6 +293,28 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
9F2A5415296AB631009B2D7C /* IceCubesNotifications */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9F2A541E296AB631009B2D7C /* Build configuration list for PBXNativeTarget "IceCubesNotifications" */;
buildPhases = (
9F2A5412296AB631009B2D7C /* Sources */,
9F2A5413296AB631009B2D7C /* Frameworks */,
9F2A5414296AB631009B2D7C /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = IceCubesNotifications;
packageProductDependencies = (
9F2A5423296AB67A009B2D7C /* Env */,
9F2A5425296AB67E009B2D7C /* KeychainSwift */,
9F2A5427296AB683009B2D7C /* Models */,
);
productName = IceCubesNotifications;
productReference = 9F2A5416296AB631009B2D7C /* IceCubesNotifications.appex */;
productType = "com.apple.product-type.app-extension";
};
9FBFE638292A715500C250E9 /* IceCubesApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9FBFE648292A715600C250E9 /* Build configuration list for PBXNativeTarget "IceCubesApp" */;
@ -242,10 +322,12 @@
9FBFE635292A715500C250E9 /* Sources */,
9FBFE636292A715500C250E9 /* Frameworks */,
9FBFE637292A715500C250E9 /* Resources */,
9F2A5421296AB631009B2D7C /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
9F2A541C296AB631009B2D7C /* PBXTargetDependency */,
);
name = IceCubesApp;
packageProductDependencies = (
@ -274,9 +356,12 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1410;
LastSwiftUpdateCheck = 1420;
LastUpgradeCheck = 1420;
TargetAttributes = {
9F2A5415296AB631009B2D7C = {
CreatedOnToolsVersion = 14.2;
};
9FBFE638292A715500C250E9 = {
CreatedOnToolsVersion = 14.1;
};
@ -300,11 +385,19 @@
projectRoot = "";
targets = (
9FBFE638292A715500C250E9 /* IceCubesApp */,
9F2A5415296AB631009B2D7C /* IceCubesNotifications */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
9F2A5414296AB631009B2D7C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
9FBFE637292A715500C250E9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -317,6 +410,14 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
9F2A5412296AB631009B2D7C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9F2A5419296AB631009B2D7C /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
9FBFE635292A715500C250E9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -340,12 +441,82 @@
9F7335F72968274500AFF0BA /* AppAccountsSelectorView.swift in Sources */,
9FAE4AD129379AD600772766 /* AppAccount.swift in Sources */,
9F55C68D2955968700F94077 /* ExploreTab.swift in Sources */,
9F2A5411296A1429009B2D7C /* PushNotificationsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
9F2A541C296AB631009B2D7C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 9F2A5415296AB631009B2D7C /* IceCubesNotifications */;
targetProxy = 9F2A541B296AB631009B2D7C /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
9F2A541F296AB631009B2D7C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = IceCubesNotifications/IceCubesNotifications.entitlements;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesNotifications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7;
PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.IceCubesApp.IceCubesNotifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
9F2A5420296AB631009B2D7C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = IceCubesNotifications/IceCubesNotifications.entitlements;
CODE_SIGN_IDENTITY = "-";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 730;
DEVELOPMENT_TEAM = Z6P74P6T99;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = IceCubesNotifications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = IceCubesNotifications;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.7;
PRODUCT_BUNDLE_IDENTIFIER = com.thomasricouard.IceCubesApp.IceCubesNotifications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
9FBFE646292A715600C250E9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -460,6 +631,7 @@
9FBFE649292A715600C250E9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate1 AppIconAlternate2 AppIconAlternate3 AppIconAlternate4 AppIconAlternate5 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate9 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -509,6 +681,7 @@
9FBFE64A292A715600C250E9 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIconAlternate1 AppIconAlternate2 AppIconAlternate3 AppIconAlternate4 AppIconAlternate5 AppIconAlternate6 AppIconAlternate7 AppIconAlternate8 AppIconAlternate9 AppIconAlternate10 AppIconAlternate11 AppIconAlternate12 AppIconAlternate13 AppIconAlternate14 AppIconAlternate15 AppIconAlternate16 AppIconAlternate17 AppIconAlternate19 AppIconAlternate18";
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -558,6 +731,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
9F2A541E296AB631009B2D7C /* Build configuration list for PBXNativeTarget "IceCubesNotifications" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9F2A541F296AB631009B2D7C /* Debug */,
9F2A5420296AB631009B2D7C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
9FBFE634292A715500C250E9 /* Build configuration list for PBXProject "IceCubesApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@ -612,6 +794,19 @@
package = 9F2A540829699705009B2D7C /* XCRemoteSwiftPackageReference "purchases-ios" */;
productName = RevenueCat;
};
9F2A5423296AB67A009B2D7C /* Env */ = {
isa = XCSwiftPackageProductDependency;
productName = Env;
};
9F2A5425296AB67E009B2D7C /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = 9FAE4ACC29379A5A00772766 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
9F2A5427296AB683009B2D7C /* Models */ = {
isa = XCSwiftPackageProductDependency;
productName = Models;
};
9F35DB43294F9A7D00B3281A /* Status */ = {
isa = XCSwiftPackageProductDependency;
productName = Status;

View file

@ -3,6 +3,7 @@ import Timeline
import Network
import KeychainSwift
import Models
import CryptoKit
struct AppAccount: Codable, Identifiable {
let server: String
@ -20,6 +21,11 @@ struct AppAccount: Codable, Identifiable {
}
}
internal init(server: String, oauthToken: OauthToken? = nil) {
self.server = server
self.oauthToken = oauthToken
}
func save() throws {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
@ -31,15 +37,16 @@ struct AppAccount: Codable, Identifiable {
KeychainSwift().delete(key)
}
static func retrieveAll() throws -> [AppAccount] {
static func retrieveAll() -> [AppAccount] {
let keychain = KeychainSwift()
let decoder = JSONDecoder()
let keys = keychain.allKeys
var accounts: [AppAccount] = []
for key in keys {
if let data = keychain.getData(key) {
let account = try decoder.decode(AppAccount.self, from: data)
accounts.append(account)
if let account = try? decoder.decode(AppAccount.self, from: data) {
accounts.append(account)
}
}
}
return accounts

View file

@ -1,5 +1,6 @@
import SwiftUI
import Network
import Env
class AppAccountsManager: ObservableObject {
@AppStorage("latestCurrentAccountKey") static public var latestCurrentAccountKey: String = ""
@ -14,18 +15,21 @@ class AppAccountsManager: ObservableObject {
@Published var availableAccounts: [AppAccount]
@Published var currentClient: Client
init() {
var pushAccounts: [PushNotifications.PushAccounts] {
availableAccounts.filter{ $0.oauthToken != nil}
.map{ .init(server: $0.server, token: $0.oauthToken!) }
}
static var shared = AppAccountsManager()
private init() {
var defaultAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
do {
let keychainAccounts = try AppAccount.retrieveAll()
availableAccounts = keychainAccounts
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) {
defaultAccount = currentAccount
} else {
defaultAccount = keychainAccounts.last ?? defaultAccount
}
} catch {
availableAccounts = [defaultAccount]
let keychainAccounts = AppAccount.retrieveAll()
availableAccounts = keychainAccounts
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) {
defaultAccount = currentAccount
} else {
defaultAccount = keychainAccounts.last ?? defaultAccount
}
currentAccount = defaultAccount
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)

View file

@ -11,8 +11,10 @@ import RevenueCat
struct IceCubesApp: App {
public static let defaultServer = "mastodon.social"
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager()
@StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance()
@StateObject private var currentAccount = CurrentAccount()
@StateObject private var userPreferences = UserPreferences()
@ -36,6 +38,7 @@ struct IceCubesApp: App {
setNewClientsInEnv(client: appAccountsManager.currentClient)
setBarsColor(color: theme.primaryBackgroundColor)
setupRevenueCat()
refreshPushSubs()
}
.preferredColorScheme(theme.selectedScheme == ColorScheme.dark ? .dark : .light)
.environmentObject(appAccountsManager)
@ -46,6 +49,7 @@ struct IceCubesApp: App {
.environmentObject(userPreferences)
.environmentObject(theme)
.environmentObject(watcher)
.environmentObject(PushNotifications.shared)
.quickLookPreview($quickLook.url, in: quickLook.urls)
}
.onChange(of: scenePhase, perform: { scenePhase in
@ -148,4 +152,30 @@ struct IceCubesApp: App {
Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
}
private func refreshPushSubs() {
PushNotifications.shared.requestPushNotifications()
Task {
await PushNotifications.shared.fetchSubscriptions(accounts: appAccountsManager.pushAccounts)
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushNotifications.shared.pushToken = deviceToken
Task {
await PushNotifications.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
}
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print(error)
}
}

View file

@ -13,6 +13,7 @@ struct AddAccountView: View {
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var pushNotifications: PushNotifications
@EnvironmentObject private var theme: Theme
@State private var instanceName: String = ""
@ -144,6 +145,9 @@ struct AddAccountView: View {
do {
let oauthToken = try await client.continueOauthFlow(url: url)
appAccountsManager.add(account: AppAccount(server: client.server, oauthToken: oauthToken))
Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts)
}
isSigninIn = false
dismiss()
} catch {

View file

@ -0,0 +1,82 @@
import SwiftUI
import Models
import DesignSystem
import NukeUI
import Network
import UserNotifications
import Env
struct PushNotificationsView: View {
@EnvironmentObject private var theme: Theme
@EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var pushNotifications: PushNotifications
@State private var subscriptions: [PushSubscription] = []
var body: some View {
Form {
Section {
Toggle(isOn: $pushNotifications.isPushEnabled) {
Text("Push notification")
}
Group {
Toggle(isOn: $pushNotifications.isFollowNotificationEnabled) {
Text("Follow notification")
}
Toggle(isOn: $pushNotifications.isFavoriteNotificationEnabled) {
Text("Favorite notification")
}
Toggle(isOn: $pushNotifications.isReblogNotificationEnabled) {
Text("Boost notification")
}
Toggle(isOn: $pushNotifications.isMentionNotificationEnabled) {
Text("Mention notification")
}
Toggle(isOn: $pushNotifications.isPollNotificationEnabled) {
Text("Polls notification")
}
}.disabled(!pushNotifications.isPushEnabled)
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("Push Notifications")
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.onAppear {
Task {
await pushNotifications.fetchSubscriptions(accounts: appAccountsManager.pushAccounts)
}
}
.onChange(of: pushNotifications.isPushEnabled) { newValue in
pushNotifications.isUserPushEnabled = newValue
if !newValue {
Task {
await pushNotifications.deleteSubscriptions(accounts: appAccountsManager.pushAccounts)
}
} else {
updateSubscriptions()
}
}
.onChange(of: pushNotifications.isFollowNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isPollNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isReblogNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isMentionNotificationEnabled) { _ in
updateSubscriptions()
}
.onChange(of: pushNotifications.isFavoriteNotificationEnabled) { _ in
updateSubscriptions()
}
}
private func updateSubscriptions() {
Task {
await pushNotifications.updateSubscriptions(accounts: appAccountsManager.pushAccounts)
}
}
}

View file

@ -68,6 +68,9 @@ struct SettingsTabs: View {
@ViewBuilder
private var generalSection: some View {
Section("General") {
NavigationLink(destination: PushNotificationsView()) {
Label("Push notifications", systemImage: "bell.and.waves.left.and.right")
}
if let instanceData = currentInstance.instance {
NavigationLink(destination: InstanceInfoView(instance: instanceData)) {
Label("Instance Information", systemImage: "server.rack")

View file

@ -2,6 +2,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>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>

View file

@ -0,0 +1,10 @@
<?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>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.thomasricouard.IceCubesApp</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,141 @@
import UserNotifications
import KeychainSwift
import Env
import CryptoKit
import Models
@MainActor
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
bestAttemptContent?.title = "A new notification have been received"
if let bestAttemptContent {
let privateKey = PushNotifications.shared.notificationsPrivateKeyAsKey
let auth = PushNotifications.shared.notificationsAuthKeyAsKey
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64()) else {
contentHandler(bestAttemptContent)
return
}
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) else {
contentHandler(bestAttemptContent)
return
}
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64()) else {
contentHandler(bestAttemptContent)
return
}
guard let plaintextData = NotificationService.decrypt(payload: payload,
salt: salt,
auth: auth,
privateKey: privateKey,
publicKey: publicKey),
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else {
contentHandler(bestAttemptContent)
return
}
bestAttemptContent.title = notification.title
bestAttemptContent.subtitle = ""
bestAttemptContent.body = notification.body.escape()
bestAttemptContent.userInfo["plaintext"] = plaintextData
contentHandler(bestAttemptContent)
}
}
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
return nil
}
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceData = nonce.withUnsafeBytes(Array.init)
guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else {
return nil
}
var _plaintext: Data?
do {
_plaintext = try AES.GCM.open(sealedBox, using: key)
} catch {
print(error)
}
guard let plaintext = _plaintext else {
return nil
}
let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1])
guard plaintext.count >= 2 + paddingLength else {
print("1")
fatalError()
}
let unpadded = plaintext.suffix(from: paddingLength + 2)
return Data(unpadded)
}
static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data {
var info = Data()
info.append("Content-Encoding: ".data(using: .utf8)!)
info.append(type.data(using: .utf8)!)
info.append(0)
info.append("P-256".data(using: .utf8)!)
info.append(0)
info.append(0)
info.append(65)
info.append(clientPublicKey)
info.append(0)
info.append(65)
info.append(serverPublicKey)
return info
}
}
extension String {
func escape() -> String {
return self
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&#39;", with: "")
}
func URLSafeBase64ToBase64() -> String {
var base64 = replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let countMod4 = count % 4
if countMod4 != 0 {
base64.append(String(repeating: "=", count: 4 - countMod4))
}
return base64
}
}

View file

@ -0,0 +1,170 @@
import Foundation
import UserNotifications
import SwiftUI
import KeychainSwift
import CryptoKit
import Models
import Network
@MainActor
public class PushNotifications: ObservableObject {
enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainGroup = "Z6P74P6T99.com.thomasricouard.IceCubesApp"
static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key"
}
public struct PushAccounts {
public let server: String
public let token: OauthToken
public init(server: String, token: OauthToken) {
self.server = server
self.token = token
}
}
public static let shared = PushNotifications()
@Published public var pushToken: Data?
@AppStorage("user_push_is_on") public var isUserPushEnabled: Bool = true
@Published public var isPushEnabled: Bool = false {
didSet {
if !oldValue && isPushEnabled {
requestPushNotifications()
}
}
}
@Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true
private var subscriptions: [PushSubscription] = []
private var keychain: KeychainSwift {
let keychain = KeychainSwift()
keychain.accessGroup = Constants.keychainGroup
return keychain
}
public func requestPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { (_, _) in
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
public func fetchSubscriptions(accounts: [PushAccounts]) async {
subscriptions = []
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
let sub: PushSubscription = try await client.get(endpoint: Push.subscription)
subscriptions.append(sub)
} catch { }
}
refreshSubscriptionsUI()
}
public func updateSubscriptions(accounts: [PushAccounts]) async {
subscriptions = []
guard let pushToken = pushToken, isUserPushEnabled else { return }
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
var listenerURL = Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
let sub: PushSubscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: notificationsPrivateKeyAsKey.publicKey.x963Representation,
auth: notificationsAuthKeyAsKey,
mentions: isMentionNotificationEnabled,
status: true,
reblog: isReblogNotificationEnabled,
follow: isFollowNotificationEnabled,
favourite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled))
subscriptions.append(sub)
} catch { }
}
refreshSubscriptionsUI()
}
public func deleteSubscriptions(accounts: [PushAccounts]) async {
for account in accounts {
let client = Client(server: account.server, oauthToken: account.token)
do {
_ = try await client.delete(endpoint: Push.subscription)
} catch { }
}
await fetchSubscriptions(accounts: accounts)
refreshSubscriptionsUI()
}
private func refreshSubscriptionsUI() {
if let sub = subscriptions.first {
isPushEnabled = true
isFollowNotificationEnabled = sub.alerts.follow
isFavoriteNotificationEnabled = sub.alerts.favourite
isReblogNotificationEnabled = sub.alerts.reblog
isMentionNotificationEnabled = sub.alerts.mention
isPollNotificationEnabled = sub.alerts.poll
} else {
isPushEnabled = false
}
}
// MARK: - Key management
public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey {
if let key = keychain.get(Constants.keychainPrivateKey),
let data = Data(base64Encoded: key) {
do {
return try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
} catch {
let key = P256.KeyAgreement.PrivateKey()
keychain.set(key.rawRepresentation.base64EncodedString(), forKey: Constants.keychainPrivateKey)
return key
}
} else {
let key = P256.KeyAgreement.PrivateKey()
keychain.set(key.rawRepresentation.base64EncodedString(), forKey: Constants.keychainPrivateKey)
return key
}
}
public var notificationsAuthKeyAsKey: Data {
if let key = keychain.get(Constants.keychainAuthKey),
let data = Data(base64Encoded: key) {
return data
} else {
let key = Self.makeRandomeNotificationsAuthKey()
keychain.set(key.base64EncodedString(), forKey: Constants.keychainAuthKey)
return key
}
}
static private func makeRandomeNotificationsAuthKey() -> Data {
let byteCount = 16
var bytes = Data(count: byteCount)
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
return bytes
}
}
extension Data {
var hexString: String {
return map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
}
}

View file

@ -0,0 +1,24 @@
import Foundation
public struct MastodonPushNotification: Codable {
public let accessToken: String
public let notificationID: Int
public let notificationType: String
public let preferredLocale: String?
public let icon: String?
public let title: String
public let body: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case notificationID = "notification_id"
case notificationType = "notification_type"
case preferredLocale = "preferred_locale"
case icon
case title
case body
}
}

View file

@ -0,0 +1,16 @@
import Foundation
public struct PushSubscription: Identifiable, Decodable {
public struct Alerts: Decodable {
public let follow: Bool
public let favourite: Bool
public let reblog: Bool
public let mention: Bool
public let poll: Bool
}
public let id: Int
public let endpoint: URL
public let serverKey: String
public let alerts: Alerts
}

View file

@ -0,0 +1,41 @@
import Foundation
public enum Push: Endpoint {
case subscription
case createSub(endpoint: String,
p256dh: Data,
auth: Data,
mentions: Bool,
status: Bool,
reblog: Bool,
follow: Bool,
favourite: Bool,
poll: Bool)
public func path() -> String {
switch self {
case .subscription, .createSub:
return "push/subscription"
}
}
public func queryItems() -> [URLQueryItem]? {
switch self {
case let .createSub(endpoint, p256dh, auth, mentions, status, reblog, follow, favourite, poll):
var params: [URLQueryItem] = []
params.append(.init(name: "subscription[endpoint]", value: endpoint))
params.append(.init(name: "subscription[keys][p256dh]", value: p256dh.base64UrlEncodedString()))
params.append(.init(name: "subscription[keys][auth]", value: auth.base64UrlEncodedString()))
params.append(.init(name: "data[alerts][mention]", value: mentions ? "true" : "false"))
params.append(.init(name: "data[alerts][status]", value: status ? "true" : "false"))
params.append(.init(name: "data[alerts][follow]", value: follow ? "true" : "false"))
params.append(.init(name: "data[alerts][reblog]", value: reblog ? "true" : "false"))
params.append(.init(name: "data[alerts][favourite]", value: favourite ? "true" : "false"))
params.append(.init(name: "data[alerts][poll]", value: poll ? "true" : "false"))
params.append(.init(name: "policy", value: "all"))
return params
default:
return nil
}
}
}

View file

@ -0,0 +1,10 @@
import Foundation
extension Data {
func base64UrlEncodedString() -> String {
return base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}