Push notifications
This commit is contained in:
parent
167a050a89
commit
347eb1d516
|
@ -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 {
|
||||
|
|
|
@ -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]()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<?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>development</string>
|
||||
</dict>
|
||||
</plist>
|
|
@ -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 = "<group>"; };
|
||||
D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = "<group>"; };
|
||||
D0EC8DD324DFE38900A08489 /* AuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationServiceTests.swift; sourceTree = "<group>"; };
|
||||
D0EC8DD724E096C900A08489 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metatext.entitlements; sourceTree = "<group>"; };
|
||||
D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
||||
D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscription.swift; sourceTree = "<group>"; };
|
||||
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSubscriptionEndpoint.swift; sourceTree = "<group>"; };
|
||||
D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = "<group>"; };
|
||||
D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = "<group>"; };
|
||||
D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
|
||||
|
@ -338,6 +354,7 @@
|
|||
D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */,
|
||||
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
|
||||
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
|
||||
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */,
|
||||
);
|
||||
path = Endpoints;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -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;
|
||||
|
|
|
@ -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<Data, Error>()
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
func registerForRemoteNotifications() -> AnyPublisher<String, Error> {
|
||||
$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))
|
||||
}
|
||||
}
|
|
@ -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<Void, Error> {
|
||||
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<Identity, Error> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright © 2020 Metabolist. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func hexEncodedString() -> String {
|
||||
map { String(format: "%02hhx", $0) }.joined()
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Void, Error> {
|
||||
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<Void, Error> {
|
||||
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<Void, Error> {
|
||||
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<Void, Error> {
|
||||
identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken)
|
||||
.flatMap { identities -> Publishers.MergeMany<AnyPublisher<Void, Never>> 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
|
||||
}
|
||||
|
|
|
@ -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<Identity, Never> in
|
||||
|
|
|
@ -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<CFError>?
|
||||
|
||||
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]
|
||||
}
|
||||
|
|
|
@ -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<Bool, Error> {
|
||||
getNotificationSettings()
|
||||
.map(\.authorizationStatus)
|
||||
.flatMap { status -> AnyPublisher<Bool, Error> 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<UNNotificationSettings, Never> {
|
||||
Future<UNNotificationSettings, Never> { promise in
|
||||
userNotificationCenter.getNotificationSettings { promise(.success($0)) }
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func requestProvisionalAuthorization() -> AnyPublisher<Bool, Error> {
|
||||
Future<Bool, Error> { 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()
|
||||
}
|
||||
}
|
|
@ -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<T: SecretsStorable>(_ 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
|
||||
}
|
||||
|
|
|
@ -7,15 +7,15 @@ class AddIdentityViewModel: ObservableObject {
|
|||
@Published var urlFieldText = ""
|
||||
@Published var alertItem: AlertItem?
|
||||
@Published private(set) var loading = false
|
||||
let addedIdentityID: AnyPublisher<UUID, Never>
|
||||
let addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never>
|
||||
|
||||
private let identitiesService: IdentitiesService
|
||||
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
|
||||
private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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: {})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -9,18 +9,19 @@ class RootViewModelTests: XCTestCase {
|
|||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
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"
|
||||
|
|
|
@ -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>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
|
|
Loading…
Reference in New Issue