Push notifications

This commit is contained in:
Justin Mazzocchi 2020-08-12 00:24:39 -07:00
parent 167a050a89
commit 347eb1d516
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
26 changed files with 597 additions and 117 deletions

View file

@ -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 {

View file

@ -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]()
}

8
Metatext.entitlements Normal file
View file

@ -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>

View file

@ -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;

58
Shared/AppDelegate.swift Normal file
View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,9 @@
// Copyright © 2020 Metabolist. All rights reserved.
import Foundation
extension Data {
func hexEncodedString() -> String {
map { String(format: "%02hhx", $0) }.joined()
}
}

View file

@ -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"
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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> {

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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]
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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: {})

View file

@ -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)
}
}
}

View file

@ -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()

View file

@ -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"

View file

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>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>