Refactoring

This commit is contained in:
Justin Mazzocchi 2020-08-13 18:24:53 -07:00
parent 39a7b24370
commit 43ccc12468
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
10 changed files with 93 additions and 118 deletions

View file

@ -38,7 +38,8 @@ extension IdentityDatabase {
lastUsedAt: Date(), lastUsedAt: Date(),
preferences: Identity.Preferences(), preferences: Identity.Preferences(),
instanceURI: nil, instanceURI: nil,
pushSubscriptionAlerts: nil) lastRegisteredDeviceToken: nil,
pushSubscriptionAlerts: .initial)
.save) .save)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -202,7 +203,7 @@ 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("pushSubscriptionAlerts", .blob).notNull()
t.column("lastRegisteredDeviceToken", .text) t.column("lastRegisteredDeviceToken", .text)
} }
@ -233,7 +234,8 @@ 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? let lastRegisteredDeviceToken: String?
let pushSubscriptionAlerts: PushSubscription.Alerts
} }
extension StoredIdentity { extension StoredIdentity {
@ -253,7 +255,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? let pushSubscriptionAlerts: PushSubscription.Alerts
} }
private extension Identity { private extension Identity {
@ -265,6 +267,7 @@ private extension Identity {
preferences: result.identity.preferences, preferences: result.identity.preferences,
instance: result.instance, instance: result.instance,
account: result.account, account: result.account,
lastRegisteredDeviceToken: result.identity.lastRegisteredDeviceToken,
pushSubscriptionAlerts: result.pushSubscriptionAlerts) pushSubscriptionAlerts: result.pushSubscriptionAlerts)
} }
} }

View file

@ -9,7 +9,8 @@ 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? let lastRegisteredDeviceToken: String?
let pushSubscriptionAlerts: PushSubscription.Alerts
} }
extension Identity { extension Identity {

View file

@ -15,3 +15,7 @@ struct PushSubscription: Codable {
let alerts: Alerts let alerts: Alerts
let serverKey: String let serverKey: String
} }
extension PushSubscription.Alerts {
static let initial: Self = Self(follow: true, favourite: true, reblog: true, mention: true, poll: true)
}

View file

@ -7,13 +7,9 @@ enum PushSubscriptionEndpoint {
endpoint: URL, endpoint: URL,
publicKey: String, publicKey: String,
auth: String, auth: String,
follow: Bool, alerts: PushSubscription.Alerts)
favourite: Bool,
reblog: Bool,
mention: Bool,
poll: Bool)
case read case read
case update(follow: Bool, favourite: Bool, reblog: Bool, mention: Bool, poll: Bool) case update(alerts: PushSubscription.Alerts)
case delete case delete
} }
@ -37,7 +33,7 @@ extension PushSubscriptionEndpoint: MastodonEndpoint {
var parameters: [String: Any]? { var parameters: [String: Any]? {
switch self { switch self {
case let .create(endpoint, publicKey, auth, follow, favourite, reblog, mention, poll): case let .create(endpoint, publicKey, auth, alerts):
return ["subscription": return ["subscription":
["endpoint": endpoint.absoluteString, ["endpoint": endpoint.absoluteString,
"keys": [ "keys": [
@ -45,20 +41,20 @@ extension PushSubscriptionEndpoint: MastodonEndpoint {
"auth": auth]], "auth": auth]],
"data": [ "data": [
"alerts": [ "alerts": [
"follow": follow, "follow": alerts.follow,
"favourite": favourite, "favourite": alerts.favourite,
"reblog": reblog, "reblog": alerts.reblog,
"mention": mention, "mention": alerts.mention,
"poll": poll "poll": alerts.poll
]]] ]]]
case let .update(follow, favourite, reblog, mention, poll): case let .update(alerts):
return ["data": return ["data":
["alerts": ["alerts":
["follow": follow, ["follow": alerts.follow,
"favourite": favourite, "favourite": alerts.favourite,
"reblog": reblog, "reblog": alerts.reblog,
"mention": mention, "mention": alerts.mention,
"poll": poll]]] "poll": alerts.poll]]]
default: return nil default: return nil
} }
} }

View file

@ -65,82 +65,20 @@ extension IdentitiesService {
.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> { func updatePushSubscriptions(deviceToken: String) -> AnyPublisher<Void, Error> {
identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken) identityDatabase.identitiesWithOutdatedDeviceTokens(deviceToken: deviceToken)
.flatMap { identities -> Publishers.MergeMany<AnyPublisher<Void, Never>> in .tryMap { [weak self] identities -> [AnyPublisher<Void, Never>] in
Publishers.MergeMany( guard let self = self else { return [Empty().eraseToAnyPublisher()] }
identities.map { [weak self] in
guard let self = self else { return Empty().eraseToAnyPublisher() }
return self.updatePushSubscription( return try identities.map {
identityID: $0.id, try self.identityService(id: $0.id)
instanceURL: $0.url, .createPushSubscription(deviceToken: deviceToken, alerts: $0.pushSubscriptionAlerts)
deviceToken: deviceToken, .catch { _ in Empty() } // don't want to disrupt pipeline, consider future telemetry
alerts: $0.pushSubscriptionAlerts)
.catch { _ in Empty() } // can't let one failure stop the pipeline
.eraseToAnyPublisher() .eraseToAnyPublisher()
})
} }
}
.map(Publishers.MergeMany.init)
.map { _ in () }
.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

@ -10,6 +10,7 @@ class IdentityService {
private let identityDatabase: IdentityDatabase private let identityDatabase: IdentityDatabase
private let environment: AppEnvironment private let environment: AppEnvironment
private let networkClient: MastodonClient private let networkClient: MastodonClient
private let secretsService: SecretsService
private let observationErrorsInput = PassthroughSubject<Error, Never>() private let observationErrorsInput = PassthroughSubject<Error, Never>()
init(identityID: UUID, init(identityID: UUID,
@ -29,12 +30,12 @@ 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(session: environment.session) secretsService = SecretsService(
networkClient.instanceURL = identity.url
networkClient.accessToken = try SecretsService(
identityID: identityID, identityID: identityID,
keychainServiceType: environment.keychainServiceType) keychainServiceType: environment.keychainServiceType)
.item(.accessToken) networkClient = MastodonClient(session: environment.session)
networkClient.instanceURL = identity.url
networkClient.accessToken = try secretsService.item(.accessToken)
observation.catch { [weak self] error -> Empty<Identity, Never> in observation.catch { [weak self] error -> Empty<Identity, Never> in
self?.observationErrorsInput.send(error) self?.observationErrorsInput.send(error)
@ -85,4 +86,39 @@ extension IdentityService {
func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> { func updatePreferences(_ preferences: Identity.Preferences) -> AnyPublisher<Void, Error> {
identityDatabase.updatePreferences(preferences, forIdentityID: identity.id) identityDatabase.updatePreferences(preferences, forIdentityID: identity.id)
} }
func createPushSubscription(deviceToken: String, alerts: PushSubscription.Alerts) -> AnyPublisher<Void, Error> {
let publicKey: String
let auth: String
do {
publicKey = try secretsService.generatePushKeyAndReturnPublicKey().base64EncodedString()
auth = try secretsService.generatePushAuth().base64EncodedString()
} catch {
return Fail(error: error).eraseToAnyPublisher()
}
let identityID = identity.id
let endpoint = Self.pushSubscriptionEndpointURL
.appendingPathComponent(deviceToken)
.appendingPathComponent(identityID.uuidString)
return networkClient.request(
PushSubscriptionEndpoint.create(
endpoint: endpoint,
publicKey: publicKey,
auth: auth,
alerts: alerts))
.map { (deviceToken, $0.alerts, identityID) }
.flatMap(identityDatabase.updatePushSubscription(deviceToken:alerts:forIdentityID:))
.eraseToAnyPublisher()
}
}
private extension IdentityService {
#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

@ -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 addedIdentityIDAndURL: AnyPublisher<(UUID, URL), Never> let addedIdentityID: AnyPublisher<UUID, Never>
private let identitiesService: IdentitiesService private let identitiesService: IdentitiesService
private let addedIdentityIDAndURLInput = PassthroughSubject<(UUID, URL), Never>() private let addedIdentityIDInput = PassthroughSubject<UUID, Never>()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(identitiesService: IdentitiesService) { init(identitiesService: IdentitiesService) {
self.identitiesService = identitiesService self.identitiesService = identitiesService
addedIdentityIDAndURL = addedIdentityIDAndURLInput.eraseToAnyPublisher() addedIdentityID = addedIdentityIDInput.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, instanceURL) } .map { identityID }
.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: addedIdentityIDAndURLInput.send) .sink(receiveValue: addedIdentityIDInput.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, instanceURL) } .map { identityID }
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.sink(receiveValue: addedIdentityIDAndURLInput.send) .sink(receiveValue: addedIdentityIDInput.send)
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

View file

@ -55,22 +55,19 @@ extension RootViewModel {
.store(in: &cancellables) .store(in: &cancellables)
identityService.updateLastUse() identityService.updateLastUse()
.sink(receiveCompletion: { _ in }, receiveValue: {}) .sink { _ in } receiveValue: { _ in }
.store(in: &cancellables) .store(in: &cancellables)
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
}
func newIdentityCreated(id: UUID, instanceURL: URL) {
newIdentitySelected(id: id)
userNotificationService.isAuthorized() userNotificationService.isAuthorized()
.filter { $0 } .filter { $0 }
.zip(appDelegate.registerForRemoteNotifications()) .zip(appDelegate.registerForRemoteNotifications())
.map { (id, instanceURL, $1, nil) } .filter { identityService.identity.lastRegisteredDeviceToken != $1 }
.flatMap(identitiesService.updatePushSubscription(identityID:instanceURL:deviceToken:alerts:)) .map { ($1, identityService.identity.pushSubscriptionAlerts) }
.flatMap(identityService.createPushSubscription(deviceToken:alerts:))
.sink { _ in } receiveValue: { _ in } .sink { _ in } receiveValue: { _ in }
.store(in: &cancellables) .store(in: &cancellables)
mainNavigationViewModel = MainNavigationViewModel(identityService: identityService)
} }
func deleteIdentity(id: UUID) { func deleteIdentity(id: UUID) {

View file

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

View file

@ -13,7 +13,7 @@ class RootViewModelTests: XCTestCase {
identitiesService: IdentitiesService( identitiesService: IdentitiesService(
identityDatabase: .fresh(), identityDatabase: .fresh(),
environment: .development), environment: .development),
notificationService: NotificationService()) userNotificationService: UserNotificationService())
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))