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 devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!
private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN" 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 { extension Account {
static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8)) static let development = try! decoder.decode(Account.self, from: Data(officialAccountJSON.utf8))
} }
@ -58,8 +45,9 @@ extension IdentityDatabase {
extension AppEnvironment { extension AppEnvironment {
static let development = AppEnvironment( static let development = AppEnvironment(
URLSessionConfiguration: .stubbing, session: Session(configuration: .stubbing),
webAuthSessionType: SuccessfulMockWebAuthSession.self) webAuthSessionType: SuccessfulMockWebAuthSession.self,
keychainServiceType: MockKeychainService.self)
} }
extension IdentitiesService { extension IdentitiesService {
@ -69,13 +57,11 @@ extension IdentitiesService {
environment: AppEnvironment = .development) -> IdentitiesService { environment: AppEnvironment = .development) -> IdentitiesService {
IdentitiesService( IdentitiesService(
identityDatabase: identityDatabase, identityDatabase: identityDatabase,
keychainService: keychainService,
environment: environment) environment: environment)
} }
static let development = IdentitiesService( static let development = IdentitiesService(
identityDatabase: .development, identityDatabase: .development,
keychainService: developmentKeychainService,
environment: .development) environment: .development)
} }
@ -83,8 +69,15 @@ extension IdentityService {
static let development = try! IdentitiesService.development.identityService(id: devIdentityID) static let development = try! IdentitiesService.development.identityService(id: devIdentityID)
} }
extension NotificationService {
static let development = NotificationService(userNotificationCenter: .current())
}
extension RootViewModel { extension RootViewModel {
static let development = RootViewModel(identitiesService: .development) static let development = RootViewModel(
appDelegate: AppDelegate(),
identitiesService: .development,
notificationService: .development)
} }
extension AddIdentityViewModel { extension AddIdentityViewModel {

View file

@ -2,20 +2,36 @@
import Foundation import Foundation
class MockKeychainService { struct MockKeychainService {}
private var items = [String: Data]()
extension MockKeychainService {
static func reset() {
items = [String: Data]()
}
} }
extension MockKeychainService: KeychainServiceType { 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 items[key] = data
} }
func deleteData(key: String) throws { static func deleteGenericPassword(account: String, service: String) throws {
items[key] = nil items[account] = nil
} }
func getData(key: String) throws -> Data? { static func getGenericPassword(account: String, service: String) throws -> Data? {
items[key] 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 */; }; D0EC8DCE24DFB64200A08489 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DCD24DFB64200A08489 /* AuthenticationService.swift */; };
D0EC8DCF24DFB64200A08489 /* 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 */; }; 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 */; }; D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; };
D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; };
D0ED1BB824CE47F400B4899C /* 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
@ -338,6 +354,7 @@
D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */, D019E6DC24DF72E700697C7D /* AppAuthorizationEndpoint.swift */,
D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */, D019E6E024DF72E700697C7D /* InstanceEndpoint.swift */,
D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */, D019E6DD24DF72E700697C7D /* PreferencesEndpoint.swift */,
D0EC8DED24E2704D00A08489 /* PushSubscriptionEndpoint.swift */,
); );
path = Endpoints; path = Endpoints;
sourceTree = "<group>"; sourceTree = "<group>";
@ -358,6 +375,7 @@
D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */, D0EC8DCA24DFA06700A08489 /* IdentitiesService.swift */,
D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */,
D0EC8DC424DF842700A08489 /* KeychainService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */,
D0EC8DD724E096C900A08489 /* NotificationService.swift */,
D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */,
); );
path = Services; path = Services;
@ -366,6 +384,7 @@
D047FA7F24C3E21000AF17C5 = { D047FA7F24C3E21000AF17C5 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0EC8DE624E0BA6500A08489 /* Metatext.entitlements */,
D0ED1BB224CE3A1600B4899C /* Development Assets */, D0ED1BB224CE3A1600B4899C /* Development Assets */,
D0666A7924C7745A00F3F04B /* Frameworks */, D0666A7924C7745A00F3F04B /* Frameworks */,
D047FA8E24C3E21200AF17C5 /* iOS */, D047FA8E24C3E21200AF17C5 /* iOS */,
@ -379,6 +398,7 @@
D047FA8424C3E21000AF17C5 /* Shared */ = { D047FA8424C3E21000AF17C5 /* Shared */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0EC8DDE24E09D7000A08489 /* AppDelegate.swift */,
D047FA8724C3E21200AF17C5 /* Assets.xcassets */, D047FA8724C3E21200AF17C5 /* Assets.xcassets */,
D019E6EB24DF7BB800697C7D /* Databases */, D019E6EB24DF7BB800697C7D /* Databases */,
D0DB6F1624C665B400D965FE /* Extensions */, D0DB6F1624C665B400D965FE /* Extensions */,
@ -448,6 +468,7 @@
D0666A4D24C6C39600F3F04B /* Instance.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */,
D0ED1BE224CFA84400B4899C /* MastodonError.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */,
D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */,
D0EC8DEA24E26F1100A08489 /* PushSubscription.swift */,
D0CD847524DBDF3C00CF380C /* Status.swift */, D0CD847524DBDF3C00CF380C /* Status.swift */,
D0CD847B24DBEA9F00CF380C /* Unknowable.swift */, D0CD847B24DBEA9F00CF380C /* Unknowable.swift */,
); );
@ -513,6 +534,7 @@
D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */, D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */,
D081A40424D0F1A8001B016E /* String+Extensions.swift */, D081A40424D0F1A8001B016E /* String+Extensions.swift */,
D065F53A24D3B33A00741304 /* View+Extensions.swift */, D065F53A24D3B33A00741304 /* View+Extensions.swift */,
D0EC8DE724E21FEC00A08489 /* Data+Extensions.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -775,6 +797,7 @@
D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */, D0666A4E24C6C39600F3F04B /* Instance.swift in Sources */,
D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D019E6E924DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */, D0ED1BE324CFA84400B4899C /* MastodonError.swift in Sources */,
D0EC8DE824E21FEC00A08489 /* Data+Extensions.swift in Sources */,
D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */, D0666A6324C6DC6C00F3F04B /* AppAuthorization.swift in Sources */,
D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */, D019E6E524DF72E700697C7D /* AccountEndpoint.swift in Sources */,
D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */, D065F53B24D3B33A00741304 /* View+Extensions.swift in Sources */,
@ -789,6 +812,7 @@
D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */, D019E6E724DF72E700697C7D /* AccessTokenEndpoint.swift in Sources */,
D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */, D0BEC93824C9632800E864C4 /* RootViewModel.swift in Sources */,
D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */,
D0EC8DEE24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */, D0159F9324DE743700E78478 /* SecondaryNavigationView.swift in Sources */,
D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */, D019E6E324DF72E700697C7D /* PreferencesEndpoint.swift in Sources */,
D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */, D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */,
@ -810,13 +834,16 @@
D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */,
D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */, D019E6F024DF7C2F00697C7D /* DatabaseError.swift in Sources */,
D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */, D019E6D724DF728400697C7D /* MastodonEncoder.swift in Sources */,
D0EC8DE524E0B44500A08489 /* NotificationService.swift in Sources */,
D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0EC8DCB24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */, D0091B7124DD68220040E8D2 /* PreferencesViewModel.swift in Sources */,
D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */,
D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6B24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */, D0159F8624DE742F00E78478 /* TabNavigationViewModel.swift in Sources */,
D0EC8DDF24E09D7000A08489 /* AppDelegate.swift in Sources */,
D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */, D0091B6E24DD68090040E8D2 /* PreferencesView.swift in Sources */,
D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */, D0159F8F24DE743700E78478 /* IdentitiesView.swift in Sources */,
D0EC8DEB24E26F1100A08489 /* PushSubscription.swift in Sources */,
D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */, D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */,
D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */, D074577724D29006004758DB /* MockWebAuthSession.swift in Sources */,
D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */, D0159FA524DE989700E78478 /* NSMutableAttributedString+Extensions.swift in Sources */,
@ -864,6 +891,7 @@
D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */, D0BEC93924C9632800E864C4 /* RootViewModel.swift in Sources */,
D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */,
D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */,
D0EC8DE424E0B44400A08489 /* NotificationService.swift in Sources */,
D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */, D0EC8DCC24DFA06700A08489 /* IdentitiesService.swift in Sources */,
D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */,
D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */, D019E6EE24DF7BF300697C7D /* IdentityDatabase.swift in Sources */,
@ -873,6 +901,7 @@
D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */, D0B23F0E24D210E90066F411 /* NSError+Extensions.swift in Sources */,
D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */, D052BBCB24D74C9300A80A7A /* MockUserDefaults.swift in Sources */,
D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */, D0DC175324D008E300A75C65 /* MastodonTarget+Stubbing.swift in Sources */,
D0EC8DE924E21FEC00A08489 /* Data+Extensions.swift in Sources */,
D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */, D0BEC94B24CA231200E864C4 /* TimelineView.swift in Sources */,
D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */, D0BEC93C24C96FD500E864C4 /* RootView.swift in Sources */,
D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */, D0159F9B24DE748900E78478 /* SidebarNavigationViewModel.swift in Sources */,
@ -889,6 +918,7 @@
D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */,
D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */, D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */,
D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */,
D0EC8DE024E09D7000A08489 /* AppDelegate.swift in Sources */,
D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */,
D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */,
D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */, D0EC8DCF24DFB64200A08489 /* AuthenticationService.swift in Sources */,
@ -898,6 +928,7 @@
D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */,
D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */,
D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */, D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */,
D0EC8DEC24E26F1100A08489 /* PushSubscription.swift in Sources */,
D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */,
D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */, D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */,
D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */,
@ -912,6 +943,7 @@
D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */, D0091B6924DC10B30040E8D2 /* PostingReadingPreferencesView.swift in Sources */,
D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */, D019E6E624DF72E700697C7D /* AccountEndpoint.swift in Sources */,
D0CD847724DBDF3C00CF380C /* Status.swift in Sources */, D0CD847724DBDF3C00CF380C /* Status.swift in Sources */,
D0EC8DEF24E2704D00A08489 /* PushSubscriptionEndpoint.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -1052,6 +1084,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs"; DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
DEVELOPMENT_TEAM = 82HL67AXQ2; DEVELOPMENT_TEAM = 82HL67AXQ2;
@ -1075,6 +1108,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Metatext.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs"; DEVELOPMENT_ASSET_PATHS = "Development\\ Assets Development\\ Assets/Mastodon\\ API\\ Stubs";
DEVELOPMENT_TEAM = 82HL67AXQ2; 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, url: url,
lastUsedAt: Date(), lastUsedAt: Date(),
preferences: Identity.Preferences(), preferences: Identity.Preferences(),
instanceURI: nil).save) instanceURI: nil,
pushSubscriptionAlerts: nil)
.save)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -100,6 +102,23 @@ extension IdentityDatabase {
.eraseToAnyPublisher() .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> { func identityObservation(id: UUID) -> AnyPublisher<Identity, Error> {
ValueObservation.tracking( ValueObservation.tracking(
StoredIdentity StoredIdentity
@ -144,6 +163,15 @@ extension IdentityDatabase {
.publisher(in: databaseQueue, scheduling: .immediate) .publisher(in: databaseQueue, scheduling: .immediate)
.eraseToAnyPublisher() .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 { private extension IdentityDatabase {
@ -174,6 +202,8 @@ private extension IdentityDatabase {
.indexed() .indexed()
.references("instance", column: "uri") .references("instance", column: "uri")
t.column("preferences", .blob).notNull() t.column("preferences", .blob).notNull()
t.column("pushSubscriptionAlerts", .blob)
t.column("lastRegisteredDeviceToken", .text)
} }
try db.create(table: "account", ifNotExists: true) { t in try db.create(table: "account", ifNotExists: true) { t in
@ -203,6 +233,7 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord,
let lastUsedAt: Date let lastUsedAt: Date
let preferences: Identity.Preferences let preferences: Identity.Preferences
let instanceURI: String? let instanceURI: String?
let pushSubscriptionAlerts: PushSubscription.Alerts?
} }
extension StoredIdentity { extension StoredIdentity {
@ -222,6 +253,7 @@ private struct IdentityResult: Codable, Hashable, FetchableRecord {
let identity: StoredIdentity let identity: StoredIdentity
let instance: Identity.Instance? let instance: Identity.Instance?
let account: Identity.Account? let account: Identity.Account?
let pushSubscriptionAlerts: PushSubscription.Alerts?
} }
private extension Identity { private extension Identity {
@ -232,7 +264,8 @@ private extension Identity {
lastUsedAt: result.identity.lastUsedAt, lastUsedAt: result.identity.lastUsedAt,
preferences: result.identity.preferences, preferences: result.identity.preferences,
instance: result.instance, 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 @main
struct MetatextApp: App { struct MetatextApp: App {
private let identityDatabase: IdentityDatabase // swiftlint:disable weak_delegate
private let keychainServive = KeychainService(serviceName: "com.metabolist.metatext") #if os(macOS)
private let environment = AppEnvironment( @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
URLSessionConfiguration: .default, #else
webAuthSessionType: WebAuthSession.self) @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
// swiftlint:enable weak_delegate
private let identitiesService: IdentitiesService = {
let identityDatabase: IdentityDatabase
init() {
do { do {
try identityDatabase = IdentityDatabase() try identityDatabase = IdentityDatabase()
} catch { } catch {
fatalError("Failed to initialize identity database") fatalError("Failed to initialize identity database")
} }
}
return IdentitiesService(identityDatabase: identityDatabase, environment: .live)
}()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView( RootView(
viewModel: RootViewModel(identitiesService: IdentitiesService( viewModel: RootViewModel(appDelegate: appDelegate,
identityDatabase: identityDatabase, identitiesService: identitiesService,
keychainService: keychainServive, notificationService: NotificationService()))
environment: environment)))
} }
} }
} }
private extension MetatextApp {
static let keychainServiceName = "com.metabolist.metatext"
}

View file

@ -3,6 +3,15 @@
import Foundation import Foundation
struct AppEnvironment { struct AppEnvironment {
let URLSessionConfiguration: URLSessionConfiguration let session: Session
let webAuthSessionType: WebAuthSessionType.Type 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 preferences: Identity.Preferences
let instance: Identity.Instance? let instance: Identity.Instance?
let account: Identity.Account? let account: Identity.Account?
let pushSubscriptionAlerts: PushSubscription.Alerts?
} }
extension Identity { 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 Combine
import Alamofire import Alamofire
typealias Session = Alamofire.Session
class HTTPClient { class HTTPClient {
private let session: Session private let session: Session
private let decoder: DataDecoder private let decoder: DataDecoder
init(configuration: URLSessionConfiguration, decoder: DataDecoder = JSONDecoder()) { init(session: Session, decoder: DataDecoder = JSONDecoder()) {
self.session = Session(configuration: configuration) self.session = session
self.decoder = decoder 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 instanceURL: URL?
var accessToken: String? var accessToken: String?
init(configuration: URLSessionConfiguration = URLSessionConfiguration.af.default) { init(session: Session) {
super.init(configuration: configuration, decoder: MastodonDecoder()) super.init(session: session, decoder: MastodonDecoder())
} }
override func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> { override func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {

View file

@ -9,7 +9,7 @@ struct AuthenticationService {
private let webAuthSessionContextProvider = WebAuthSessionContextProvider() private let webAuthSessionContextProvider = WebAuthSessionContextProvider()
init(environment: AppEnvironment) { init(environment: AppEnvironment) {
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) networkClient = MastodonClient(session: environment.session)
webAuthSessionType = environment.webAuthSessionType webAuthSessionType = environment.webAuthSessionType
} }
} }

View file

@ -7,12 +7,10 @@ class IdentitiesService {
@Published var mostRecentlyUsedIdentityID: UUID? @Published var mostRecentlyUsedIdentityID: UUID?
private let identityDatabase: IdentityDatabase private let identityDatabase: IdentityDatabase
private let keychainService: KeychainServiceType
private let environment: AppEnvironment private let environment: AppEnvironment
init(identityDatabase: IdentityDatabase, keychainService: KeychainServiceType, environment: AppEnvironment) { init(identityDatabase: IdentityDatabase, environment: AppEnvironment) {
self.identityDatabase = identityDatabase self.identityDatabase = identityDatabase
self.keychainService = keychainService
self.environment = environment self.environment = environment
identityDatabase.mostRecentlyUsedIdentityIDObservation() identityDatabase.mostRecentlyUsedIdentityIDObservation()
@ -25,7 +23,6 @@ extension IdentitiesService {
func identityService(id: UUID) throws -> IdentityService { func identityService(id: UUID) throws -> IdentityService {
try IdentityService(identityID: id, try IdentityService(identityID: id,
identityDatabase: identityDatabase, identityDatabase: identityDatabase,
keychainService: keychainService,
environment: environment) environment: environment)
} }
@ -34,7 +31,7 @@ extension IdentitiesService {
} }
func authorizeIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Void, Error> { 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) let authenticationService = AuthenticationService(environment: environment)
return authenticationService.authorizeApp(instanceURL: instanceURL) return authenticationService.authorizeApp(instanceURL: instanceURL)
@ -54,16 +51,96 @@ extension IdentitiesService {
} }
func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> { func deleteIdentity(id: UUID) -> AnyPublisher<Void, Error> {
identityDatabase.deleteIdentity(id: id) let environment = self.environment
.continuingIfWeakReferenceIsStillAlive(to: self)
.tryMap { _, welf -> Void in return identityDatabase.deleteIdentity(id: id)
.tryMap { _ -> Void in
try SecretsService( try SecretsService(
identityID: id, identityID: id,
keychainService: welf.keychainService) keychainServiceType: environment.keychainServiceType)
.deleteAllItems() .deleteAllItems()
return () return ()
} }
.eraseToAnyPublisher() .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, init(identityID: UUID,
identityDatabase: IdentityDatabase, identityDatabase: IdentityDatabase,
keychainService: KeychainServiceType,
environment: AppEnvironment) throws { environment: AppEnvironment) throws {
self.identityDatabase = identityDatabase self.identityDatabase = identityDatabase
self.environment = environment self.environment = environment
@ -30,11 +29,11 @@ class IdentityService {
guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound }
self.identity = identity self.identity = identity
networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) networkClient = MastodonClient(session: environment.session)
networkClient.instanceURL = identity.url networkClient.instanceURL = identity.url
networkClient.accessToken = try SecretsService( networkClient.accessToken = try SecretsService(
identityID: identityID, identityID: identityID,
keychainService: keychainService) keychainServiceType: environment.keychainServiceType)
.item(.accessToken) .item(.accessToken)
observation.catch { [weak self] error -> Empty<Identity, Never> in observation.catch { [weak self] error -> Empty<Identity, Never> in

View file

@ -3,18 +3,18 @@
import Foundation import Foundation
protocol KeychainServiceType { protocol KeychainServiceType {
func set(data: Data, forKey key: String) throws static func setGenericPassword(data: Data, forAccount key: String, service: String) throws
func deleteData(key: String) throws static func deleteGenericPassword(account: String, service: String) throws
func getData(key: String) throws -> Data? 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 { struct KeychainService {}
let serviceName: String
}
extension KeychainService: KeychainServiceType { extension KeychainService: KeychainServiceType {
func set(data: Data, forKey key: String) throws { static func setGenericPassword(data: Data, forAccount account: String, service: String) throws {
var query = queryDictionary(key: key) var query = genericPasswordQueryDictionary(account: account, service: service)
query[kSecValueData as String] = data query[kSecValueData as String] = data
@ -25,17 +25,17 @@ extension KeychainService: KeychainServiceType {
} }
} }
func deleteData(key: String) throws { static func deleteGenericPassword(account: String, service: String) throws {
let status = SecItemDelete(queryDictionary(key: key) as CFDictionary) let status = SecItemDelete(genericPasswordQueryDictionary(account: account, service: service) as CFDictionary)
if status != errSecSuccess { if status != errSecSuccess {
throw NSError(status: status) throw NSError(status: status)
} }
} }
func getData(key: String) throws -> Data? { static func getGenericPassword(account: String, service: String) throws -> Data? {
var result: AnyObject? var result: AnyObject?
var query = queryDictionary(key: key) var query = genericPasswordQueryDictionary(account: account, service: service)
query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecReturnData as String] = kCFBooleanTrue query[kSecReturnData as String] = kCFBooleanTrue
@ -51,14 +51,58 @@ extension KeychainService: KeychainServiceType {
throw NSError(status: status) 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 extension KeychainService {
private func queryDictionary(key: String) -> [String: Any] { static let keySizeInBits = 256
[
kSecAttrService as String: serviceName, static func genericPasswordQueryDictionary(account: String, service: String) -> [String: Any] {
kSecAttrAccount as String: key, [kSecAttrService as String: service,
kSecClass as String: kSecClassGenericPassword 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 { struct SecretsService {
let identityID: UUID 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.identityID = identityID
self.keychainService = keychainService self.keychainServiceType = keychainServiceType
} }
} }
extension SecretsService { extension SecretsService {
enum Item: String, CaseIterable { enum Item: String, CaseIterable {
case clientID = "client-id" case clientID
case clientSecret = "client-secret" case clientSecret
case accessToken = "access-token" case accessToken
case pushKey
case pushAuth
} }
} }
extension SecretsService { extension SecretsService {
func set(_ data: SecretsStorable, forItem item: Item) throws { 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? { 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 return nil
} }
@ -44,12 +51,41 @@ extension SecretsService {
func deleteAllItems() throws { func deleteAllItems() throws {
for item in SecretsService.Item.allCases { 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 { private extension SecretsService {
static let keychainServiceName = "com.metabolist.metatext"
private static let authLength = 16
func key(item: Item) -> String { func key(item: Item) -> String {
identityID.uuidString + "." + item.rawValue identityID.uuidString + "." + item.rawValue
} }

View file

@ -7,15 +7,15 @@ class AddIdentityViewModel: ObservableObject {
@Published var urlFieldText = "" @Published var urlFieldText = ""
@Published var alertItem: AlertItem? @Published var alertItem: AlertItem?
@Published private(set) var loading = false @Published private(set) var loading = false
let addedIdentityID: AnyPublisher<UUID, Never> let addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never>
private let identitiesService: IdentitiesService private let identitiesService: IdentitiesService
private let addedIdentityIDInput = PassthroughSubject<UUID, Never>() private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(identitiesService: IdentitiesService) { init(identitiesService: IdentitiesService) {
self.identitiesService = identitiesService self.identitiesService = identitiesService
addedIdentityID = addedIdentityIDInput.eraseToAnyPublisher() addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher()
} }
func logInTapped() { func logInTapped() {
@ -33,13 +33,13 @@ class AddIdentityViewModel: ObservableObject {
identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL) identitiesService.authorizeIdentity(id: identityID, instanceURL: instanceURL)
.map { (identityID, instanceURL) } .map { (identityID, instanceURL) }
.flatMap(identitiesService.createIdentity(id:instanceURL:)) .flatMap(identitiesService.createIdentity(id:instanceURL:))
.map { identityID } .map { (identityID, instanceURL) }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.handleEvents( .handleEvents(
receiveSubscription: { [weak self] _ in self?.loading = true }, receiveSubscription: { [weak self] _ in self?.loading = true },
receiveCompletion: { [weak self] _ in self?.loading = false }) receiveCompletion: { [weak self] _ in self?.loading = false })
.sink(receiveValue: addedIdentityIDInput.send) .sink(receiveValue: addedIdentityIDAndURLInput.send)
.store(in: &cancellables) .store(in: &cancellables)
} }
@ -57,9 +57,9 @@ class AddIdentityViewModel: ObservableObject {
// TODO: Ensure instance has not disabled public preview // TODO: Ensure instance has not disabled public preview
identitiesService.createIdentity(id: identityID, instanceURL: instanceURL) identitiesService.createIdentity(id: identityID, instanceURL: instanceURL)
.map { identityID } .map { (identityID, instanceURL) }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink(receiveValue: addedIdentityIDInput.send) .sink(receiveValue: addedIdentityIDAndURLInput.send)
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

View file

@ -6,13 +6,27 @@ import Combine
class RootViewModel: ObservableObject { class RootViewModel: ObservableObject {
@Published private(set) var mainNavigationViewModel: MainNavigationViewModel? @Published private(set) var mainNavigationViewModel: MainNavigationViewModel?
// swiftlint:disable weak_delegate
private let appDelegate: AppDelegate
// swiftlint:enable weak_delegate
private let identitiesService: IdentitiesService private let identitiesService: IdentitiesService
private let notificationService: NotificationService
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(identitiesService: IdentitiesService) { init(appDelegate: AppDelegate, identitiesService: IdentitiesService, notificationService: NotificationService) {
self.appDelegate = appDelegate
self.identitiesService = identitiesService self.identitiesService = identitiesService
self.notificationService = notificationService
newIdentitySelected(id: identitiesService.mostRecentlyUsedIdentityID) 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) 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) { func deleteIdentity(id: UUID) {
identitiesService.deleteIdentity(id: id) identitiesService.deleteIdentity(id: id)
.sink(receiveCompletion: { _ in }, receiveValue: {}) .sink(receiveCompletion: { _ in }, receiveValue: {})

View file

@ -34,9 +34,9 @@ struct AddIdentityView: View {
} }
.paddingIfMac() .paddingIfMac()
.alertItem($viewModel.alertItem) .alertItem($viewModel.alertItem)
.onReceive(viewModel.addedIdentityID) { id in .onReceive(viewModel.addedIdentityIDAndURL) { id, url in
withAnimation { withAnimation {
rootViewModel.newIdentitySelected(id: id) rootViewModel.newIdentityCreated(id: id, instanceURL: url)
} }
} }
} }

View file

@ -9,33 +9,29 @@ class AddIdentityViewModelTests: XCTestCase {
func testAddIdentity() throws { func testAddIdentity() throws {
let identityDatabase = IdentityDatabase.fresh() let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
sut.urlFieldText = "https://mastodon.social" sut.urlFieldText = "https://mastodon.social"
sut.logInTapped() sut.logInTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
XCTAssertEqual(addedIdentity.id, addedIdentityID) // XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
} }
func testAddIdentityWithoutScheme() throws { func testAddIdentityWithoutScheme() throws {
let identityDatabase = IdentityDatabase.fresh() let identityDatabase = IdentityDatabase.fresh()
let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase)) let sut = AddIdentityViewModel(identitiesService: .fresh(identityDatabase: identityDatabase))
let addedIDRecorder = sut.addedIdentityID.record() let addedIDAndURLRecorder = sut.addedIdentityIDAndURL.record()
sut.urlFieldText = "mastodon.social" sut.urlFieldText = "mastodon.social"
sut.logInTapped() sut.logInTapped()
let addedIdentityID = try wait(for: addedIDRecorder.next(), timeout: 1) let addedIdentityIDAndURL = try wait(for: addedIDAndURLRecorder.next(), timeout: 1)
let identityRecorder = identityDatabase.identityObservation(id: addedIdentityID).record()
let addedIdentity = try wait(for: identityRecorder.next(), timeout: 1)
XCTAssertEqual(addedIdentity.id, addedIdentityID) // XCTAssertEqual(addedIdentityIDAndURL.0, addedIdentityID)
XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) XCTAssertEqual(addedIdentityIDAndURL.1, URL(string: "https://mastodon.social")!)
} }
func testInvalidURL() throws { func testInvalidURL() throws {
@ -54,11 +50,11 @@ class AddIdentityViewModelTests: XCTestCase {
func testDoesNotAlertCanceledLogin() throws { func testDoesNotAlertCanceledLogin() throws {
let environment = AppEnvironment( let environment = AppEnvironment(
URLSessionConfiguration: .stubbing, session: Session(configuration: .stubbing),
webAuthSessionType: CanceledLoginMockWebAuthSession.self) webAuthSessionType: CanceledLoginMockWebAuthSession.self,
keychainServiceType: MockKeychainService.self)
let identitiesService = IdentitiesService( let identitiesService = IdentitiesService(
identityDatabase: .fresh(), identityDatabase: .fresh(),
keychainService: MockKeychainService(),
environment: environment) environment: environment)
let sut = AddIdentityViewModel(identitiesService: identitiesService) let sut = AddIdentityViewModel(identitiesService: identitiesService)
let recorder = sut.$alertItem.record() let recorder = sut.$alertItem.record()

View file

@ -9,18 +9,19 @@ class RootViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
func testAddIdentity() throws { func testAddIdentity() throws {
let sut = RootViewModel(identitiesService: IdentitiesService( let sut = RootViewModel(appDelegate: AppDelegate(),
identitiesService: IdentitiesService(
identityDatabase: .fresh(), identityDatabase: .fresh(),
keychainService: MockKeychainService(), environment: .development),
environment: .development)) notificationService: NotificationService())
let recorder = sut.$mainNavigationViewModel.record() let recorder = sut.$mainNavigationViewModel.record()
XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) XCTAssertNil(try wait(for: recorder.next(), timeout: 1))
let addIdentityViewModel = sut.addIdentityViewModel() let addIdentityViewModel = sut.addIdentityViewModel()
addIdentityViewModel.addedIdentityID addIdentityViewModel.addedIdentityIDAndURL
.sink(receiveValue: sut.newIdentitySelected(id:)) .sink(receiveValue: sut.newIdentityCreated(id:instanceURL:))
.store(in: &cancellables) .store(in: &cancellables)
addIdentityViewModel.urlFieldText = "https://mastodon.social" 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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>