From 347eb1d51655279a9c1bea67e5384b33044dee22 Mon Sep 17 00:00:00 2001
From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com>
Date: Wed, 12 Aug 2020 00:24:39 -0700
Subject: [PATCH] Push notifications
---
Development Assets/DevelopmentModels.swift | 29 +++---
Development Assets/MockKeychainService.swift | 30 ++++--
Metatext.entitlements | 8 ++
Metatext.xcodeproj/project.pbxproj | 34 +++++++
Shared/AppDelegate.swift | 58 +++++++++++
Shared/Databases/IdentityDatabase.swift | 37 +++++++-
Shared/Extensions/Data+Extensions.swift | 9 ++
Shared/MetatextApp.swift | 31 +++---
Shared/Model/AppEnvironment.swift | 11 ++-
Shared/Model/Identity.swift | 1 +
Shared/Model/PushSubscription.swift | 17 ++++
Shared/Networking/HTTPClient.swift | 6 +-
.../Endpoints/PushSubscriptionEndpoint.swift | 65 +++++++++++++
.../Mastodon API/MastodonClient.swift | 4 +-
Shared/Services/AuthenticationService.swift | 2 +-
Shared/Services/IdentitiesService.swift | 95 +++++++++++++++++--
Shared/Services/IdentityService.swift | 5 +-
Shared/Services/KeychainService.swift | 80 ++++++++++++----
Shared/Services/NotificationService.swift | 53 +++++++++++
Shared/Services/SecretsService.swift | 54 +++++++++--
Shared/View Models/AddIdentityViewModel.swift | 14 +--
Shared/View Models/RootViewModel.swift | 28 +++++-
Shared/Views/AddIdentityView.swift | 4 +-
.../AddIdentityViewModelTests.swift | 26 +++--
Tests/View Models/RootViewModelTests.swift | 11 ++-
macOS/macOS.entitlements | 2 +
26 files changed, 597 insertions(+), 117 deletions(-)
create mode 100644 Metatext.entitlements
create mode 100644 Shared/AppDelegate.swift
create mode 100644 Shared/Extensions/Data+Extensions.swift
create mode 100644 Shared/Model/PushSubscription.swift
create mode 100644 Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift
create mode 100644 Shared/Services/NotificationService.swift
diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift
index 5fa6236..ac9bdbf 100644
--- a/Development Assets/DevelopmentModels.swift
+++ b/Development Assets/DevelopmentModels.swift
@@ -10,19 +10,6 @@ private let devInstanceURL = URL(string: "https://mastodon.social")!
private let devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!
private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN"
-func freshKeychainService() -> KeychainServiceType { MockKeychainService() }
-
-let developmentKeychainService: KeychainServiceType = {
- let keychainService = MockKeychainService()
- let secretsService = SecretsService(identityID: devIdentityID, keychainService: keychainService)
-
- try! secretsService.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID)
- try! secretsService.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret)
- try! secretsService.set(devAccessToken, forItem: .accessToken)
-
- return keychainService
-}()
-
extension Account {
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
}
@@ -58,8 +45,9 @@ extension IdentityDatabase {
extension AppEnvironment {
static let development = AppEnvironment(
- URLSessionConfiguration: .stubbing,
- webAuthSessionType: SuccessfulMockWebAuthSession.self)
+ session: Session(configuration: .stubbing),
+ webAuthSessionType: SuccessfulMockWebAuthSession.self,
+ keychainServiceType: MockKeychainService.self)
}
extension IdentitiesService {
@@ -69,13 +57,11 @@ extension IdentitiesService {
environment: AppEnvironment = .development) -> IdentitiesService {
IdentitiesService(
identityDatabase: identityDatabase,
- keychainService: keychainService,
environment: environment)
}
static let development = IdentitiesService(
identityDatabase: .development,
- keychainService: developmentKeychainService,
environment: .development)
}
@@ -83,8 +69,15 @@ extension IdentityService {
static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
}
+extension NotificationService {
+ static let development = NotificationService(userNotificationCenter: .current())
+}
+
extension RootViewModel {
- static let development = RootViewModel(identitiesService: .development)
+ static let development = RootViewModel(
+ appDelegate: AppDelegate(),
+ identitiesService: .development,
+ notificationService: .development)
}
extension AddIdentityViewModel {
diff --git a/Development Assets/MockKeychainService.swift b/Development Assets/MockKeychainService.swift
index 4bd1987..a443cd7 100644
--- a/Development Assets/MockKeychainService.swift
+++ b/Development Assets/MockKeychainService.swift
@@ -2,20 +2,36 @@
import Foundation
-class MockKeychainService {
- private var items = [String: Data]()
+struct MockKeychainService {}
+
+extension MockKeychainService {
+ static func reset() {
+ items = [String: Data]()
+ }
}
extension MockKeychainService: KeychainServiceType {
- func set(data: Data, forKey key: String) throws {
+ static func setGenericPassword(data: Data, forAccount key: String, service: String) throws {
items[key] = data
}
- func deleteData(key: String) throws {
- items[key] = nil
+ static func deleteGenericPassword(account: String, service: String) throws {
+ items[account] = nil
}
- func getData(key: String) throws -> Data? {
- items[key]
+ static func getGenericPassword(account: String, service: String) throws -> Data? {
+ items[account]
+ }
+
+ static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data {
+ fatalError("not implemented")
+ }
+
+ static func getPrivateKey(applicationTag: String) throws -> Data? {
+ fatalError("not implemented")
}
}
+
+private extension MockKeychainService {
+ static var items = [String: Data]()
+}
diff --git a/Metatext.entitlements b/Metatext.entitlements
new file mode 100644
index 0000000..903def2
--- /dev/null
+++ b/Metatext.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ development
+
+
diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj
index 01ee3cf..73ce389 100644
--- a/Metatext.xcodeproj/project.pbxproj
+++ b/Metatext.xcodeproj/project.pbxproj
@@ -143,6 +143,16 @@
D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
D0EC8DD424DFE38900A08489 /* AuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */; };
+ D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; };
+ D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */; };
+ D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; };
+ D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DD724E096C900A08489 /* NotificationService.swift */; };
+ D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; };
+ D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */; };
+ D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; };
+ D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */; };
+ D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; };
+ D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */; };
D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
@@ -250,6 +260,12 @@
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitiesService.swift; sourceTree = ""; };
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; };
D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = ""; };
+ D0EC8DD724E096C900A08489 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; };
+ D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = ""; };
+ D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; };
+ D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscription.swift; sourceTree = ""; };
+ D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionEndpoint.swift; sourceTree = ""; };
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = ""; };
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = ""; };
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; };
@@ -338,6 +354,7 @@
D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */,
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
+ D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */,
);
path = Endpoints;
sourceTree = "";
@@ -358,6 +375,7 @@
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
D0EC8DC424DF842700A08489 /* KeychainService.swift */,
+ D0EC8DD724E096C900A08489 /* NotificationService.swift */,
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
);
path = Services;
@@ -366,6 +384,7 @@
D047FA7F24C3E21000AF17C5 = {
isa = PBXGroup;
children = (
+ D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */,
D0ED1BB224CE3A1600B4899C /* Development Assets */,
D0666A7924C7745A00F3F04B /* Frameworks */,
D047FA8E24C3E21200AF17C5 /* iOS */,
@@ -379,6 +398,7 @@
D047FA8424C3E21000AF17C5 /* Shared */ = {
isa = PBXGroup;
children = (
+ D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */,
D047FA8724C3E21200AF17C5 /* Assets.xcassets */,
D019E6EB24DF7BB800697C7D /* Databases */,
D0DB6F1624C665B400D965FE /* Extensions */,
@@ -448,6 +468,7 @@
D0666A4D24C6C39600F3F04B /* Instance.swift */,
D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */,
+ D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */,
D0CD847524DBDF3C00CF380C /* Status.swift */,
D0CD847B24DBEA9F00CF380C /* Unknowable.swift */,
);
@@ -513,6 +534,7 @@
D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */,
D081A40424D0F1A8001B016E /* String+Extensions.swift */,
D065F53A24D3B33A00741304 /* View+Extensions.swift */,
+ D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */,
);
path = Extensions;
sourceTree = "";
@@ -775,6 +797,7 @@
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
+ D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */,
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
@@ -789,6 +812,7 @@
D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */,
D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */,
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */,
+ D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */,
D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */,
D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */,
@@ -810,13 +834,16 @@
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
+ D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */,
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */,
+ D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */,
D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */,
D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */,
+ D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */,
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */,
D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
@@ -864,6 +891,7 @@
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
+ D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */,
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
@@ -873,6 +901,7 @@
D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */,
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */,
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
+ D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */,
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */,
D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */,
@@ -889,6 +918,7 @@
D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */,
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
+ D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */,
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
@@ -898,6 +928,7 @@
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
+ D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */,
D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */,
D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
@@ -912,6 +943,7 @@
D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */,
D0CD847724DBDF3C00CF380C /* Status.swift in Sources */,
+ D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1052,6 +1084,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
DEVELOPMENT_TEAM = 82HL67AXQ2;
@@ -1075,6 +1108,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
DEVELOPMENT_TEAM = 82HL67AXQ2;
diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift
new file mode 100644
index 0000000..68fe88d
--- /dev/null
+++ b/Shared/AppDelegate.swift
@@ -0,0 +1,58 @@
+// Copyright © 2020 Metabolist. All rights reserved.
+
+#if os(macOS)
+import AppKit
+typealias AppDelegateType = NSApplicationDelegate
+typealias ApplicationType = NSApplication
+#else
+import UIKit
+typealias AppDelegateType = UIApplicationDelegate
+typealias ApplicationType = UIApplication
+#endif
+
+import Combine
+
+class AppDelegate: NSObject {
+ @Published private var application: ApplicationType?
+ private let remoteNotificationDeviceTokens = PassthroughSubject()
+}
+
+extension AppDelegate {
+ func registerForRemoteNotifications() -> AnyPublisher {
+ $application
+ .compactMap { $0 }
+ .handleEvents(receiveOutput: { $0.registerForRemoteNotifications() })
+ .setFailureType(to: Error.self)
+ .zip(remoteNotificationDeviceTokens)
+ .first()
+ .map { $1.hexEncodedString() }
+ .eraseToAnyPublisher()
+ }
+}
+
+extension AppDelegate: AppDelegateType {
+ #if os(macOS)
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ application = notification.object as? ApplicationType
+ }
+ #else
+ func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
+ self.application = application
+
+ return true
+ }
+ #endif
+
+ func application(_ application: ApplicationType,
+ didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ // this doesn't get called on macOS, need to figure out why
+ remoteNotificationDeviceTokens.send(deviceToken)
+ }
+
+ func application(_ application: ApplicationType,
+ didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ remoteNotificationDeviceTokens.send(completion: .failure(error))
+ }
+}
diff --git a/Shared/Databases/IdentityDatabase.swift b/Shared/Databases/IdentityDatabase.swift
index f8902d0..dfd94e7 100644
--- a/Shared/Databases/IdentityDatabase.swift
+++ b/Shared/Databases/IdentityDatabase.swift
@@ -37,7 +37,9 @@ extension IdentityDatabase {
url: url,
lastUsedAt: Date(),
preferences: Identity.Preferences(),
- instanceURI: nil).save)
+ instanceURI: nil,
+ pushSubscriptionAlerts: nil)
+ .save)
.eraseToAnyPublisher()
}
@@ -100,6 +102,23 @@ extension IdentityDatabase {
.eraseToAnyPublisher()
}
+ func updatePushSubscription(deviceToken: String,
+ alerts: PushSubscription.Alerts,
+ forIdentityID identityID: UUID) -> AnyPublisher {
+ databaseQueue.writePublisher {
+ let data = try StoredIdentity.databaseJSONEncoder(for: "pushSubscriptionAlerts").encode(alerts)
+
+ try StoredIdentity
+ .filter(Column("id") == identityID)
+ .updateAll($0, Column("pushSubscriptionAlerts").set(to: data))
+
+ try StoredIdentity
+ .filter(Column("id") == identityID)
+ .updateAll($0, Column("lastRegisteredDeviceToken").set(to: deviceToken))
+ }
+ .eraseToAnyPublisher()
+ }
+
func identityObservation(id: UUID) -> AnyPublisher {
ValueObservation.tracking(
StoredIdentity
@@ -144,6 +163,15 @@ extension IdentityDatabase {
.publisher(in: databaseQueue, scheduling: .immediate)
.eraseToAnyPublisher()
}
+
+ func identitiesWithOutdatedDeviceTokens(deviceToken: String) -> AnyPublisher<[Identity], Error> {
+ databaseQueue.readPublisher(
+ value: Self.identitiesRequest()
+ .filter(Column("lastRegisteredDeviceToken") != deviceToken)
+ .fetchAll)
+ .map { $0.map(Identity.init(result:)) }
+ .eraseToAnyPublisher()
+ }
}
private extension IdentityDatabase {
@@ -174,6 +202,8 @@ private extension IdentityDatabase {
.indexed()
.references("instance", column: "uri")
t.column("preferences", .blob).notNull()
+ t.column("pushSubscriptionAlerts", .blob)
+ t.column("lastRegisteredDeviceToken", .text)
}
try db.create(table: "account", ifNotExists: true) { t in
@@ -203,6 +233,7 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord,
let lastUsedAt: Date
let preferences: Identity.Preferences
let instanceURI: String?
+ let pushSubscriptionAlerts: PushSubscription.Alerts?
}
extension StoredIdentity {
@@ -222,6 +253,7 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord {
let identity: StoredIdentity
let instance: Identity.Instance?
let account: Identity.Account?
+ let pushSubscriptionAlerts: PushSubscription.Alerts?
}
private extension Identity {
@@ -232,7 +264,8 @@ private extension Identity {
lastUsedAt: result.identity.lastUsedAt,
preferences: result.identity.preferences,
instance: result.instance,
- account: result.account)
+ account: result.account,
+ pushSubscriptionAlerts: result.pushSubscriptionAlerts)
}
}
diff --git a/Shared/Extensions/Data+Extensions.swift b/Shared/Extensions/Data+Extensions.swift
new file mode 100644
index 0000000..68fb103
--- /dev/null
+++ b/Shared/Extensions/Data+Extensions.swift
@@ -0,0 +1,9 @@
+// Copyright © 2020 Metabolist. All rights reserved.
+
+import Foundation
+
+extension Data {
+ func hexEncodedString() -> String {
+ map { String(format: "%02hhx", $0) }.joined()
+ }
+}
diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift
index 5d2f3ff..d639752 100644
--- a/Shared/MetatextApp.swift
+++ b/Shared/MetatextApp.swift
@@ -4,31 +4,32 @@ import SwiftUI
@main
struct MetatextApp: App {
- private let identityDatabase: IdentityDatabase
- private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext")
- private let environment = AppEnvironment(
- URLSessionConfiguration: .default,
- webAuthSessionType: WebAuthSession.self)
+ // swiftlint:disable weak_delegate
+ #if os(macOS)
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ #else
+ @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ #endif
+ // swiftlint:enable weak_delegate
+
+ private let identitiesService: IdentitiesService = {
+ let identityDatabase: IdentityDatabase
- init() {
do {
try identityDatabase = IdentityDatabase()
} catch {
fatalError("Failed to initialize identity database")
}
- }
+
+ return IdentitiesService(identityDatabase: identityDatabase, environment: .live)
+ }()
var body: some Scene {
WindowGroup {
RootView(
- viewModel: RootViewModel(identitiesService: IdentitiesService(
- identityDatabase: identityDatabase,
- keychainService: keychainServive,
- environment: environment)))
+ viewModel: RootViewModel(appDelegate: appDelegate,
+ identitiesService: identitiesService,
+ notificationService: NotificationService()))
}
}
}
-
-private extension MetatextApp {
- static let keychainServiceName = "com.metabolist.metatext"
-}
diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift
index 3869141..8ab371a 100644
--- a/Shared/Model/AppEnvironment.swift
+++ b/Shared/Model/AppEnvironment.swift
@@ -3,6 +3,15 @@
import Foundation
struct AppEnvironment {
- let URLSessionConfiguration: URLSessionConfiguration
+ let session: Session
let webAuthSessionType: WebAuthSessionType.Type
+ let keychainServiceType: KeychainServiceType.Type
+ let userDefaults: UserDefaults = .standard
+}
+
+extension AppEnvironment {
+ static let live: Self = Self(
+ session: Session(configuration: .default),
+ webAuthSessionType: WebAuthSession.self,
+ keychainServiceType: KeychainService.self)
}
diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift
index dab7327..c1d4f66 100644
--- a/Shared/Model/Identity.swift
+++ b/Shared/Model/Identity.swift
@@ -9,6 +9,7 @@ struct Identity: Codable, Hashable, Identifiable {
let preferences: Identity.Preferences
let instance: Identity.Instance?
let account: Identity.Account?
+ let pushSubscriptionAlerts: PushSubscription.Alerts?
}
extension Identity {
diff --git a/Shared/Model/PushSubscription.swift b/Shared/Model/PushSubscription.swift
new file mode 100644
index 0000000..07caf12
--- /dev/null
+++ b/Shared/Model/PushSubscription.swift
@@ -0,0 +1,17 @@
+// Copyright © 2020 Metabolist. All rights reserved.
+
+import Foundation
+
+struct PushSubscription: Codable {
+ struct Alerts: Codable, Hashable {
+ let follow: Bool
+ let favourite: Bool
+ let reblog: Bool
+ let mention: Bool
+ let poll: Bool
+ }
+
+ let endpoint: URL
+ let alerts: Alerts
+ let serverKey: String
+}
diff --git a/Shared/Networking/HTTPClient.swift b/Shared/Networking/HTTPClient.swift
index f2e1607..4f13d8e 100644
--- a/Shared/Networking/HTTPClient.swift
+++ b/Shared/Networking/HTTPClient.swift
@@ -4,12 +4,14 @@ import Foundation
import Combine
import Alamofire
+typealias Session = Alamofire.Session
+
class HTTPClient {
private let session: Session
private let decoder: DataDecoder
- init(configuration: URLSessionConfiguration, decoder: DataDecoder = JSONDecoder()) {
- self.session = Session(configuration: configuration)
+ init(session: Session, decoder: DataDecoder = JSONDecoder()) {
+ self.session = session
self.decoder = decoder
}
diff --git a/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift
new file mode 100644
index 0000000..117f72e
--- /dev/null
+++ b/Shared/Networking/Mastodon API/Endpoints/PushSubscriptionEndpoint.swift
@@ -0,0 +1,65 @@
+// Copyright © 2020 Metabolist. All rights reserved.
+
+import Foundation
+
+enum PushSubscriptionEndpoint {
+ case create(
+ endpoint: URL,
+ publicKey: String,
+ auth: String,
+ follow: Bool,
+ favourite: Bool,
+ reblog: Bool,
+ mention: Bool,
+ poll: Bool)
+ case read
+ case update(follow: Bool, favourite: Bool, reblog: Bool, mention: Bool, poll: Bool)
+ case delete
+}
+
+extension PushSubscriptionEndpoint: MastodonEndpoint {
+ typealias ResultType = PushSubscription
+
+ var context: [String] {
+ defaultContext + ["push", "subscription"]
+ }
+
+ var pathComponentsInContext: [String] { [] }
+
+ var method: HTTPMethod {
+ switch self {
+ case .create: return .post
+ case .read: return .get
+ case .update: return .put
+ case .delete: return .delete
+ }
+ }
+
+ var parameters: [String: Any]? {
+ switch self {
+ case let .create(endpoint, publicKey, auth, follow, favourite, reblog, mention, poll):
+ return ["subscription":
+ ["endpoint": endpoint.absoluteString,
+ "keys": [
+ "p256dh": publicKey,
+ "auth": auth]],
+ "data": [
+ "alerts": [
+ "follow": follow,
+ "favourite": favourite,
+ "reblog": reblog,
+ "mention": mention,
+ "poll": poll
+ ]]]
+ case let .update(follow, favourite, reblog, mention, poll):
+ return ["data":
+ ["alerts":
+ ["follow": follow,
+ "favourite": favourite,
+ "reblog": reblog,
+ "mention": mention,
+ "poll": poll]]]
+ default: return nil
+ }
+ }
+}
diff --git a/Shared/Networking/Mastodon API/MastodonClient.swift b/Shared/Networking/Mastodon API/MastodonClient.swift
index 3a65689..c61048d 100644
--- a/Shared/Networking/Mastodon API/MastodonClient.swift
+++ b/Shared/Networking/Mastodon API/MastodonClient.swift
@@ -7,8 +7,8 @@ class MastodonClient: HTTPClient {
var instanceURL: URL?
var accessToken: String?
- init(configuration: URLSessionConfiguration = URLSessionConfiguration.af.default) {
- super.init(configuration: configuration, decoder: MastodonDecoder())
+ init(session: Session) {
+ super.init(session: session, decoder: MastodonDecoder())
}
override func request(_ target: T) -> AnyPublisher {
diff --git a/Shared/Services/AuthenticationService.swift b/Shared/Services/AuthenticationService.swift
index 39862be..c5c0d4f 100644
--- a/Shared/Services/AuthenticationService.swift
+++ b/Shared/Services/AuthenticationService.swift
@@ -9,7 +9,7 @@ struct AuthenticationService {
private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
init(environment: AppEnvironment) {
- networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
+ networkClient = MastodonClient(session: environment.session)
webAuthSessionType = environment.webAuthSessionType
}
}
diff --git a/Shared/Services/IdentitiesService.swift b/Shared/Services/IdentitiesService.swift
index dfc1177..1b2ca2a 100644
--- a/Shared/Services/IdentitiesService.swift
+++ b/Shared/Services/IdentitiesService.swift
@@ -7,12 +7,10 @@ class IdentitiesService {
@Published var mostRecentlyUsedIdentityID: UUID?
private let identityDatabase: IdentityDatabase
- private let keychainService: KeychainServiceType
private let environment: AppEnvironment
- init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) {
+ init(identityDatabase: IdentityDatabase, environment: AppEnvironment) {
self.identityDatabase = identityDatabase
- self.keychainService = keychainService
self.environment = environment
identityDatabase.mostRecentlyUsedIdentityIDObservation()
@@ -25,7 +23,6 @@ extension IdentitiesService {
func identityService(id: UUID) throws -> IdentityService {
try IdentityService(identityID: id,
identityDatabase: identityDatabase,
- keychainService: keychainService,
environment: environment)
}
@@ -34,7 +31,7 @@ extension IdentitiesService {
}
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher {
- let secretsService = SecretsService(identityID: id, keychainService: keychainService)
+ let secretsService = SecretsService(identityID: id, keychainServiceType: environment.keychainServiceType)
let authenticationService = AuthenticationService(environment: environment)
return authenticationService.authorizeApp(instanceURL: instanceURL)
@@ -54,16 +51,96 @@ extension IdentitiesService {
}
func deleteIdentity(id: UUID) -> AnyPublisher {
- identityDatabase.deleteIdentity(id: id)
- .continuingIfWeakReferenceIsStillAlive(to: self)
- .tryMap { _, welf -> Void in
+ let environment = self.environment
+
+ return identityDatabase.deleteIdentity(id: id)
+ .tryMap { _ -> Void in
try SecretsService(
identityID: id,
- keychainService: welf.keychainService)
+ keychainServiceType: environment.keychainServiceType)
.deleteAllItems()
return ()
}
.eraseToAnyPublisher()
}
+
+ func updatePushSubscription(
+ identityID: UUID,
+ instanceURL: URL,
+ deviceToken: String,
+ alerts: PushSubscription.Alerts?) -> AnyPublisher {
+ let secretsService = SecretsService(
+ identityID: identityID,
+ keychainServiceType: environment.keychainServiceType)
+ let accessTokenOptional: String?
+
+ do {
+ accessTokenOptional = try secretsService.item(.accessToken) as String?
+ } catch {
+ return Fail(error: error).eraseToAnyPublisher()
+ }
+
+ guard let accessToken: String = accessTokenOptional
+ else { return Empty().eraseToAnyPublisher() }
+
+ let publicKey: String
+ let auth: String
+
+ do {
+ publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString()
+ auth = try secretsService.generatePushAuth().base64EncodedString()
+ } catch {
+ return Fail(error: error).eraseToAnyPublisher()
+ }
+
+ let networkClient = MastodonClient(session: environment.session)
+ networkClient.instanceURL = instanceURL
+ networkClient.accessToken = accessToken
+
+ let endpoint = Self.pushSubscriptionEndpointURL
+ .appendingPathComponent(deviceToken)
+ .appendingPathComponent(identityID.uuidString)
+
+ return networkClient.request(
+ PushSubscriptionEndpoint.create(
+ endpoint: endpoint,
+ publicKey: publicKey,
+ auth: auth,
+ follow: alerts?.follow ?? true,
+ favourite: alerts?.favourite ?? true,
+ reblog: alerts?.reblog ?? true,
+ mention: alerts?.mention ?? true,
+ poll: alerts?.poll ?? true))
+ .map { (deviceToken, $0.alerts, identityID) }
+ .flatMap(identityDatabase.updatePushSubscription(deviceToken:alerts:forIdentityID:))
+ .eraseToAnyPublisher()
+ }
+
+ func updatePushSubscriptions(deviceToken: String) -> AnyPublisher {
+ identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken)
+ .flatMap { identities -> Publishers.MergeMany> in
+ Publishers.MergeMany(
+ identities.map { [weak self] in
+ guard let self = self else { return Empty().eraseToAnyPublisher() }
+
+ return self.updatePushSubscription(
+ identityID: $0.id,
+ instanceURL: $0.url,
+ deviceToken: deviceToken,
+ alerts: $0.pushSubscriptionAlerts)
+ .catch { _ in Empty() } // can't let one failure stop the pipeline
+ .eraseToAnyPublisher()
+ })
+ }
+ .eraseToAnyPublisher()
+ }
+}
+
+private extension IdentitiesService {
+ #if DEBUG
+ static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push?sandbox=true")!
+ #else
+ static let pushSubscriptionEndpointURL = URL(string: "https://metatext-apns.metabolist.com/push")!
+ #endif
}
diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift
index 4dba831..d9ddde9 100644
--- a/Shared/Services/IdentityService.swift
+++ b/Shared/Services/IdentityService.swift
@@ -14,7 +14,6 @@ class IdentityService {
init(identityID: UUID,
identityDatabase: IdentityDatabase,
- keychainService: KeychainServiceType,
environment: AppEnvironment) throws {
self.identityDatabase = identityDatabase
self.environment = environment
@@ -30,11 +29,11 @@ class IdentityService {
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
self.identity = identity
- networkClient = MastodonClient(configuration: environment.URLSessionConfiguration)
+ networkClient = MastodonClient(session: environment.session)
networkClient.instanceURL = identity.url
networkClient.accessToken = try SecretsService(
identityID: identityID,
- keychainService: keychainService)
+ keychainServiceType: environment.keychainServiceType)
.item(.accessToken)
observation.catch { [weak self] error -> Empty in
diff --git a/Shared/Services/KeychainService.swift b/Shared/Services/KeychainService.swift
index 7b88b64..bf57fc8 100644
--- a/Shared/Services/KeychainService.swift
+++ b/Shared/Services/KeychainService.swift
@@ -3,18 +3,18 @@
import Foundation
protocol KeychainServiceType {
- func set(data: Data, forKey key: String) throws
- func deleteData(key: String) throws
- func getData(key: String) throws -> Data?
+ static func setGenericPassword(data: Data, forAccount key: String, service: String) throws
+ static func deleteGenericPassword(account: String, service: String) throws
+ static func getGenericPassword(account: String, service: String) throws -> Data?
+ static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data
+ static func getPrivateKey(applicationTag: String) throws -> Data?
}
-struct KeychainService {
- let serviceName: String
-}
+struct KeychainService {}
extension KeychainService: KeychainServiceType {
- func set(data: Data, forKey key: String) throws {
- var query = queryDictionary(key: key)
+ static func setGenericPassword(data: Data, forAccount account: String, service: String) throws {
+ var query = genericPasswordQueryDictionary(account: account, service: service)
query[kSecValueData as String] = data
@@ -25,17 +25,17 @@ extension KeychainService: KeychainServiceType {
}
}
- func deleteData(key: String) throws {
- let status = SecItemDelete(queryDictionary(key: key) as CFDictionary)
+ static func deleteGenericPassword(account: String, service: String) throws {
+ let status = SecItemDelete(genericPasswordQueryDictionary(account: account, service: service) as CFDictionary)
if status != errSecSuccess {
throw NSError(status: status)
}
}
- func getData(key: String) throws -> Data? {
+ static func getGenericPassword(account: String, service: String) throws -> Data? {
var result: AnyObject?
- var query = queryDictionary(key: key)
+ var query = genericPasswordQueryDictionary(account: account, service: service)
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnData as String] = kCFBooleanTrue
@@ -51,14 +51,58 @@ extension KeychainService: KeychainServiceType {
throw NSError(status: status)
}
}
+
+ static func generateKeyAndReturnPublicKey(applicationTag: String) throws -> Data {
+ var attributes = keyAttributes
+ var error: Unmanaged?
+
+ attributes[kSecPrivateKeyAttrs as String] = [
+ kSecAttrIsPermanent as String: true,
+ kSecAttrApplicationTag as String: Data(applicationTag.utf8)]
+
+ guard
+ let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error),
+ let publicKey = SecKeyCopyPublicKey(key),
+ let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &error) as Data?
+ else { throw error?.takeRetainedValue() ?? NSError() }
+
+ return publicKeyData
+ }
+
+ static func getPrivateKey(applicationTag: String) throws -> Data? {
+ var result: AnyObject?
+ let status = SecItemCopyMatching(keyQueryDictionary(applicationTag: applicationTag) as CFDictionary, &result)
+
+ switch status {
+ case errSecSuccess:
+ return result as? Data
+ case errSecItemNotFound:
+ return nil
+ default:
+ throw NSError(status: status)
+ }
+ }
}
private extension KeychainService {
- private func queryDictionary(key: String) -> [String: Any] {
- [
- kSecAttrService as String: serviceName,
- kSecAttrAccount as String: key,
- kSecClass as String: kSecClassGenericPassword
- ]
+ static let keySizeInBits = 256
+
+ static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] {
+ [kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecClass as String: kSecClassGenericPassword]
}
+
+ static func keyQueryDictionary(applicationTag: String) -> [String: Any] {
+ [kSecClass as String: kSecClassKey,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: keySizeInBits,
+ kSecAttrApplicationTag as String: applicationTag,
+ kSecReturnRef as String: true]
+ }
+
+ static let keyAttributes: [String: Any] = [
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: keySizeInBits]
}
diff --git a/Shared/Services/NotificationService.swift b/Shared/Services/NotificationService.swift
new file mode 100644
index 0000000..c16a467
--- /dev/null
+++ b/Shared/Services/NotificationService.swift
@@ -0,0 +1,53 @@
+// Copyright © 2020 Metabolist. All rights reserved.
+
+import Foundation
+import Combine
+import UserNotifications
+
+struct NotificationService {
+ private let userNotificationCenter: UNUserNotificationCenter
+
+ init(userNotificationCenter: UNUserNotificationCenter = .current()) {
+ self.userNotificationCenter = userNotificationCenter
+ }
+}
+
+extension NotificationService {
+ func isAuthorized() -> AnyPublisher {
+ getNotificationSettings()
+ .map(\.authorizationStatus)
+ .flatMap { status -> AnyPublisher in
+ if status == .notDetermined {
+ return requestProvisionalAuthorization().eraseToAnyPublisher()
+ }
+
+ return Just(status == .authorized || status == .provisional)
+ .setFailureType(to: Error.self)
+ .eraseToAnyPublisher()
+ }
+ .eraseToAnyPublisher()
+ }
+}
+
+private extension NotificationService {
+ func getNotificationSettings() -> AnyPublisher {
+ Future { promise in
+ userNotificationCenter.getNotificationSettings { promise(.success($0)) }
+ }
+ .eraseToAnyPublisher()
+ }
+
+ func requestProvisionalAuthorization() -> AnyPublisher {
+ Future { promise in
+ userNotificationCenter.requestAuthorization(
+ options: [.alert, .sound, .badge, .provisional]) { granted, error in
+ if let error = error {
+ return promise(.failure(error))
+ }
+
+ return promise(.success(granted))
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+}
diff --git a/Shared/Services/SecretsService.swift b/Shared/Services/SecretsService.swift
index b4ae80b..399e816 100644
--- a/Shared/Services/SecretsService.swift
+++ b/Shared/Services/SecretsService.swift
@@ -13,29 +13,36 @@ enum SecretsStorableError: Error {
struct SecretsService {
let identityID: UUID
- private let keychainService: KeychainServiceType
+ private let keychainServiceType: KeychainServiceType.Type
- init(identityID: UUID, keychainService: KeychainServiceType) {
+ init(identityID: UUID, keychainServiceType: KeychainServiceType.Type) {
self.identityID = identityID
- self.keychainService = keychainService
+ self.keychainServiceType = keychainServiceType
}
}
extension SecretsService {
enum Item: String, CaseIterable {
- case clientID = "client-id"
- case clientSecret = "client-secret"
- case accessToken = "access-token"
+ case clientID
+ case clientSecret
+ case accessToken
+ case pushKey
+ case pushAuth
}
}
extension SecretsService {
func set(_ data: SecretsStorable, forItem item: Item) throws {
- try keychainService.set(data: data.dataStoredInSecrets, forKey: key(item: item))
+ try keychainServiceType.setGenericPassword(
+ data: data.dataStoredInSecrets,
+ forAccount: key(item: item),
+ service: Self.keychainServiceName)
}
func item(_ item: Item) throws -> T? {
- guard let data = try keychainService.getData(key: key(item: item)) else {
+ guard let data = try keychainServiceType.getGenericPassword(
+ account: key(item: item),
+ service: Self.keychainServiceName) else {
return nil
}
@@ -44,12 +51,41 @@ extension SecretsService {
func deleteAllItems() throws {
for item in SecretsService.Item.allCases {
- try keychainService.deleteData(key: key(item: item))
+ try keychainServiceType.deleteGenericPassword(
+ account: key(item: item),
+ service: Self.keychainServiceName)
}
}
+
+ func generatePushKeyAndReturnPublicKey() throws -> Data {
+ try keychainServiceType.generateKeyAndReturnPublicKey(applicationTag: key(item: .pushKey))
+ }
+
+ func getPushKey() throws -> Data? {
+ try keychainServiceType.getPrivateKey(applicationTag: key(item: .pushKey))
+ }
+
+ func generatePushAuth() throws -> Data {
+ var bytes = [UInt8](repeating: 0, count: Self.authLength)
+
+ _ = SecRandomCopyBytes(kSecRandomDefault, Self.authLength, &bytes)
+
+ let pushAuth = Data(bytes)
+
+ try set(pushAuth, forItem: .pushAuth)
+
+ return pushAuth
+ }
+
+ func getPushAuth() throws -> Data? {
+ try item(.pushAuth)
+ }
}
private extension SecretsService {
+ static let keychainServiceName = "com.metabolist.metatext"
+ private static let authLength = 16
+
func key(item: Item) -> String {
identityID.uuidString + "." + item.rawValue
}
diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift
index 44f1f4d..d7426a5 100644
--- a/Shared/View Models/AddIdentityViewModel.swift
+++ b/Shared/View Models/AddIdentityViewModel.swift
@@ -7,15 +7,15 @@ class AddIdentityViewModel: ObservableObject {
@Published var urlFieldText = ""
@Published var alertItem: AlertItem?
@Published private(set) var loading = false
- let addedIdentityID: AnyPublisher
+ let addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never>
private let identitiesService: IdentitiesService
- private let addedIdentityIDInput = PassthroughSubject()
+ private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>()
private var cancellables = Set()
init(identitiesService: IdentitiesService) {
self.identitiesService = identitiesService
- addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher()
+ addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher()
}
func logInTapped() {
@@ -33,13 +33,13 @@ class AddIdentityViewModel: ObservableObject {
identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
.map { (identityID, instanceURL) }
.flatMap(identitiesService.createIdentity(id:instanceURL:))
- .map { identityID }
+ .map { (identityID, instanceURL) }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
.receive(on: RunLoop.main)
.handleEvents(
receiveSubscription: { [weak self] _ in self?.loading = true },
receiveCompletion: { [weak self] _ in self?.loading = false })
- .sink(receiveValue: addedIdentityIDInput.send)
+ .sink(receiveValue: addedIdentityIDAndURLInput.send)
.store(in: &cancellables)
}
@@ -57,9 +57,9 @@ class AddIdentityViewModel: ObservableObject {
// TODO: Ensure instance has not disabled public preview
identitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
- .map { identityID }
+ .map { (identityID, instanceURL) }
.assignErrorsToAlertItem(to: \.alertItem, on: self)
- .sink(receiveValue: addedIdentityIDInput.send)
+ .sink(receiveValue: addedIdentityIDAndURLInput.send)
.store(in: &cancellables)
}
}
diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift
index 48d4d4a..02aafed 100644
--- a/Shared/View Models/RootViewModel.swift
+++ b/Shared/View Models/RootViewModel.swift
@@ -6,13 +6,27 @@ import Combine
class RootViewModel: ObservableObject {
@Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
+ // swiftlint:disable weak_delegate
+ private let appDelegate: AppDelegate
+ // swiftlint:enable weak_delegate
private let identitiesService: IdentitiesService
+ private let notificationService: NotificationService
private var cancellables = Set()
- init(identitiesService: IdentitiesService) {
+ init(appDelegate: AppDelegate, identitiesService: IdentitiesService, notificationService: NotificationService) {
+ self.appDelegate = appDelegate
self.identitiesService = identitiesService
+ self.notificationService = notificationService
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID)
+
+ notificationService.isAuthorized()
+ .filter { $0 }
+ .zip(appDelegate.registerForRemoteNotifications())
+ .map { $1 }
+ .flatMap(identitiesService.updatePushSubscriptions(deviceToken:))
+ .sink { _ in } receiveValue: { _ in }
+ .store(in: &cancellables)
}
}
@@ -45,6 +59,18 @@ extension RootViewModel {
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
}
+ func newIdentityCreated(id: UUID, instanceURL: URL) {
+ newIdentitySelected(id: id)
+
+ notificationService.isAuthorized()
+ .filter { $0 }
+ .zip(appDelegate.registerForRemoteNotifications())
+ .map { (id, instanceURL, $1, nil) }
+ .flatMap(identitiesService.updatePushSubscription(identityID:instanceURL:deviceToken:alerts:))
+ .sink { _ in } receiveValue: { _ in }
+ .store(in: &cancellables)
+ }
+
func deleteIdentity(id: UUID) {
identitiesService.deleteIdentity(id: id)
.sink(receiveCompletion: { _ in }, receiveValue: {})
diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift
index 836ccd5..e7da4dc 100644
--- a/Shared/Views/AddIdentityView.swift
+++ b/Shared/Views/AddIdentityView.swift
@@ -34,9 +34,9 @@ struct AddIdentityView: View {
}
.paddingIfMac()
.alertItem($viewModel.alertItem)
- .onReceive(viewModel.addedIdentityID) { id in
+ .onReceive(viewModel.addedIdentityIDAndURL) { id, url in
withAnimation {
- rootViewModel.newIdentitySelected(id: id)
+ rootViewModel.newIdentityCreated(id: id, instanceURL: url)
}
}
}
diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift
index 8bcaccd..72f5642 100644
--- a/Tests/View Models/AddIdentityViewModelTests.swift
+++ b/Tests/View Models/AddIdentityViewModelTests.swift
@@ -9,33 +9,29 @@ class AddIdentityViewModelTests: XCTestCase {
func testAddIdentity() throws {
let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
- let addedIDRecorder = sut.addedIdentityID.record()
+ let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
sut.urlFieldText = "https://mastodon.social"
sut.logInTapped()
- let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
- let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
- let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
+ let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
- XCTAssertEqual(addedIdentity.id, addedIdentityID)
- XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
+// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
+ XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
}
func testAddIdentityWithoutScheme() throws {
let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
- let addedIDRecorder = sut.addedIdentityID.record()
+ let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
sut.urlFieldText = "mastodon.social"
sut.logInTapped()
- let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1)
- let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
- let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
+ let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
- XCTAssertEqual(addedIdentity.id, addedIdentityID)
- XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!)
+// XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
+ XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
}
func testInvalidURL() throws {
@@ -54,11 +50,11 @@ class AddIdentityViewModelTests: XCTestCase {
func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment(
- URLSessionConfiguration: .stubbing,
- webAuthSessionType: CanceledLoginMockWebAuthSession.self)
+ session: Session(configuration: .stubbing),
+ webAuthSessionType: CanceledLoginMockWebAuthSession.self,
+ keychainServiceType: MockKeychainService.self)
let identitiesService = IdentitiesService(
identityDatabase: .fresh(),
- keychainService: MockKeychainService(),
environment: environment)
let sut = AddIdentityViewModel(identitiesService: identitiesService)
let recorder = sut.$alertItem.record()
diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift
index d21252e..22d8227 100644
--- a/Tests/View Models/RootViewModelTests.swift
+++ b/Tests/View Models/RootViewModelTests.swift
@@ -9,18 +9,19 @@ class RootViewModelTests: XCTestCase {
var cancellables = Set()
func testAddIdentity() throws {
- let sut = RootViewModel(identitiesService: IdentitiesService(
+ let sut = RootViewModel(appDelegate: AppDelegate(),
+ identitiesService: IdentitiesService(
identityDatabase: .fresh(),
- keychainService: MockKeychainService(),
- environment: .development))
+ environment: .development),
+ notificationService: NotificationService())
let recorder = sut.$mainNavigationViewModel.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
let addIdentityViewModel = sut.addIdentityViewModel()
- addIdentityViewModel.addedIdentityID
- .sink(receiveValue: sut.newIdentitySelected(id:))
+ addIdentityViewModel.addedIdentityIDAndURL
+ .sink(receiveValue: sut.newIdentityCreated(id:instanceURL:))
.store(in: &cancellables)
addIdentityViewModel.urlFieldText = "https://mastodon.social"
diff --git a/macOS/macOS.entitlements b/macOS/macOS.entitlements
index 625af03..b7b98df 100644
--- a/macOS/macOS.entitlements
+++ b/macOS/macOS.entitlements
@@ -2,6 +2,8 @@
+ com.apple.developer.aps-environment
+ development
com.apple.security.app-sandbox
com.apple.security.files.user-selected.read-only