fix: server-side data is inconsistent with local

This commit is contained in:
ihugo 2021-04-18 02:02:08 +08:00
parent e42af11bf7
commit 8c7149af89
8 changed files with 212 additions and 125 deletions

View File

@ -155,13 +155,14 @@
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="hashtag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
</entity>
<entity name="Setting" representedClassName="Setting" syncable="YES">
<entity name="Setting" representedClassName=".Setting" syncable="YES">
<attribute name="appearance" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="domain" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="triggerBy" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="userID" optional="YES" attributeType="String"/>
<relationship name="subscription" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Subscription" inverseName="setting" inverseEntity="Subscription"/>
</entity>
<entity name="Status" representedClassName=".Status" syncable="YES">
@ -202,7 +203,7 @@
<relationship name="replyTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="replyFrom" inverseEntity="Status"/>
<relationship name="tags" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Tag" inverseName="statuses" inverseEntity="Tag"/>
</entity>
<entity name="Subscription" representedClassName="Subscription" syncable="YES">
<entity name="Subscription" representedClassName=".Subscription" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="endpoint" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
@ -212,7 +213,7 @@
<relationship name="alert" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SubscriptionAlerts" inverseName="subscription" inverseEntity="SubscriptionAlerts"/>
<relationship name="setting" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Setting" inverseName="subscription" inverseEntity="Setting"/>
</entity>
<entity name="SubscriptionAlerts" representedClassName="SubscriptionAlerts" syncable="YES">
<entity name="SubscriptionAlerts" representedClassName=".SubscriptionAlerts" syncable="YES">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="favourite" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="follow" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
@ -244,10 +245,10 @@
<element name="PollOption" positionX="0" positionY="0" width="128" height="134"/>
<element name="PrivateNote" positionX="0" positionY="0" width="128" height="89"/>
<element name="SearchHistory" positionX="0" positionY="0" width="128" height="104"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="149"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
<element name="Setting" positionX="72" positionY="162" width="128" height="134"/>
<element name="Subscription" positionX="81" positionY="171" width="128" height="149"/>
<element name="SubscriptionAlerts" positionX="72" positionY="162" width="128" height="149"/>
<element name="Tag" positionX="0" positionY="0" width="128" height="134"/>
</elements>
</model>

View File

@ -8,11 +8,11 @@
import CoreData
import Foundation
@objc(Setting)
public final class Setting: NSManagedObject {
@NSManaged public var appearance: String?
@NSManaged public var triggerBy: String?
@NSManaged public var domain: String?
@NSManaged public var userID: String?
@NSManaged public private(set) var createdAt: Date
@NSManaged public private(set) var updatedAt: Date
@ -40,6 +40,7 @@ public extension Setting {
setting.appearance = property.appearance
setting.triggerBy = property.triggerBy
setting.domain = property.domain
setting.userID = property.userID
return setting
}
@ -61,11 +62,13 @@ public extension Setting {
public let appearance: String
public let triggerBy: String
public let domain: String
public let userID: String
public init(appearance: String, triggerBy: String, domain: String) {
public init(appearance: String, triggerBy: String, domain: String, userID: String) {
self.appearance = appearance
self.triggerBy = triggerBy
self.domain = domain
self.userID = userID
}
}
}
@ -77,8 +80,11 @@ extension Setting: Managed {
}
extension Setting {
public static func predicate(domain: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Setting.domain), domain)
public static func predicate(domain: String, userID: String) -> NSPredicate {
return NSPredicate(format: "%K == %@ AND %K == %@",
#keyPath(Setting.domain), domain,
#keyPath(Setting.userID), userID
)
}
}

View File

@ -9,7 +9,6 @@
import Foundation
import CoreData
@objc(Subscription)
public final class Subscription: NSManagedObject {
@NSManaged public var id: String
@NSManaged public var endpoint: String
@ -95,8 +94,8 @@ extension Subscription: Managed {
extension Subscription {
public static func predicate(id: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Subscription.id), id)
public static func predicate(type: String) -> NSPredicate {
return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type)
}
}

View File

@ -20,7 +20,7 @@ public final class SubscriptionAlerts: NSManagedObject {
@NSManaged public private(set) var updatedAt: Date
// MARK: - relationships
@NSManaged public var pushSubscription: Subscription?
@NSManaged public var subscription: Subscription?
}
public extension SubscriptionAlerts {

View File

@ -46,7 +46,7 @@ class SettingsViewController: UIViewController, NeedsDependency {
UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in
self?.updateTrigger(by: noOne)
},
].reversed()
]
)
return menu
}
@ -344,10 +344,15 @@ extension SettingsViewController: UITableViewDelegate {
// Update setting into core data
extension SettingsViewController {
func updateTrigger(by who: String) {
guard self.viewModel.triggerBy != who else { return }
guard let setting = self.viewModel.setting.value else { return }
_ = context.managedObjectContext.performChanges {
setting.update(triggerBy: who)
setting.update(triggerBy: who)
// trigger to call `subscription` API with POST method
// confirm the local data is correct even if request failed
// The asynchronous execution is to solve the problem of dropped frames for animations.
DispatchQueue.main.async { [weak self] in
self?.viewModel.setting.value = setting
}
}
@ -356,34 +361,35 @@ extension SettingsViewController {
guard let settings = self.viewModel.setting.value else { return }
guard let triggerBy = settings.triggerBy else { return }
guard let alerts = settings.subscription?.first(where: { (s) -> Bool in
if let alerts = settings.subscription?.first(where: { (s) -> Bool in
return s.type == settings.triggerBy
})?.alert else {
return
})?.alert {
var alertValues = [Bool?]()
alertValues.append(alerts.favourite?.boolValue)
alertValues.append(alerts.follow?.boolValue)
alertValues.append(alerts.reblog?.boolValue)
alertValues.append(alerts.mention?.boolValue)
// need to update `alerts` to make update API with correct parameter
switch title {
case L10n.Scene.Settings.Section.Notifications.favorites:
alertValues[0] = isOn
alerts.favourite = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.follows:
alertValues[1] = isOn
alerts.follow = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.boosts:
alertValues[2] = isOn
alerts.reblog = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.mentions:
alertValues[3] = isOn
alerts.mention = NSNumber(booleanLiteral: isOn)
default: break
}
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
} else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] {
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
}
var alertValues = [Bool?]()
alertValues.append(alerts.favourite?.boolValue)
alertValues.append(alerts.follow?.boolValue)
alertValues.append(alerts.reblog?.boolValue)
alertValues.append(alerts.mention?.boolValue)
// need to update `alerts` to make update API with correct parameter
switch title {
case L10n.Scene.Settings.Section.Notifications.favorites:
alertValues[0] = isOn
alerts.favourite = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.follows:
alertValues[1] = isOn
alerts.follow = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.boosts:
alertValues[2] = isOn
alerts.reblog = NSNumber(booleanLiteral: isOn)
case L10n.Scene.Settings.Section.Notifications.mentions:
alertValues[3] = isOn
alerts.mention = NSNumber(booleanLiteral: isOn)
default: break
}
self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues))
}
}
@ -435,7 +441,7 @@ extension SettingsViewController {
guard let setting: Setting? = {
let domain = box.domain
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain)
request.predicate = Setting.predicate(domain: domain, userID: box.userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {

View File

@ -29,7 +29,7 @@ class SettingsViewModel: NSObject, NeedsDependency {
if let box =
self.context.authenticationService.activeMastodonAuthenticationBox.value {
let domain = box.domain
fetchRequest.predicate = Setting.predicate(domain: domain)
fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID)
}
fetchRequest.fetchLimit = 1
@ -78,6 +78,9 @@ class SettingsViewModel: NSObject, NeedsDependency {
return Mastodon.API.privacyURL(domain: box.domain)
}()
/// to store who trigger the notification.
var triggerBy: String?
struct Input {
}
@ -121,12 +124,14 @@ class SettingsViewModel: NSObject, NeedsDependency {
follow: values[1],
reblog: values[2],
mention: values[3],
poll: nil)
poll: nil
)
self.context.apiService.changeSubscription(
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
query: query,
triggerBy: triggerBy
triggerBy: triggerBy,
userID: activeMastodonAuthenticationBox.userID
)
.sink { (_) in
} receiveValue: { (_) in
@ -164,7 +169,8 @@ class SettingsViewModel: NSObject, NeedsDependency {
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox,
query: query,
triggerBy: triggerBy
triggerBy: triggerBy,
userID: activeMastodonAuthenticationBox.userID
)
.sink { (_) in
} receiveValue: { (_) in
@ -178,13 +184,6 @@ class SettingsViewModel: NSObject, NeedsDependency {
// request subsription data for updating or initialization
requestSubscription()
do {
try fetchResultsController.performFetch()
setting.value = fetchResultsController.fetchedObjects?.first
} catch {
assertionFailure(error.localizedDescription)
}
return nil
}
@ -213,12 +212,12 @@ class SettingsViewModel: NSObject, NeedsDependency {
} else if let triggerBy = settings?.triggerBy,
let values = self.notificationDefaultValue[triggerBy] {
switches = values
self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
} else {
// fallback a default value
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
switches = self.notificationDefaultValue[anyone]
}
let notifications = [L10n.Scene.Settings.Section.Notifications.favorites,
L10n.Scene.Settings.Section.Notifications.follows,
L10n.Scene.Settings.Section.Notifications.boosts,
@ -273,31 +272,61 @@ class SettingsViewModel: NSObject, NeedsDependency {
}
private func requestSubscription() {
// request subscription of notifications
typealias SubscriptionResponse = Mastodon.Response.Content<Mastodon.Entity.Subscription>
viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher<SubscriptionResponse, Error> in
guard let self = self,
let activeMastodonAuthenticationBox =
self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return Empty<SubscriptionResponse, Error>().eraseToAnyPublisher()
setting.sink { [weak self] (settings) in
guard let self = self else { return }
guard settings != nil else { return }
guard self.triggerBy != settings?.triggerBy else { return }
self.triggerBy = settings?.triggerBy
var switches: [Bool?]?
var who: String?
if let alerts = settings?.subscription?.first(where: { (s) -> Bool in
return s.type == settings?.triggerBy
})?.alert {
var items = [Bool?]()
items.append(alerts.favourite?.boolValue)
items.append(alerts.follow?.boolValue)
items.append(alerts.reblog?.boolValue)
items.append(alerts.mention?.boolValue)
switches = items
who = settings?.triggerBy
} else if let triggerBy = settings?.triggerBy,
let values = self.notificationDefaultValue[triggerBy] {
switches = values
who = triggerBy
} else {
// fallback a default value
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
switches = self.notificationDefaultValue[anyone]
who = anyone
}
let domain = activeMastodonAuthenticationBox.domain
return self.context.apiService.subscription(
domain: domain,
mastodonAuthenticationBox: activeMastodonAuthenticationBox)
}
.sink { [weak self] competion in
if case .failure(_) = competion {
// create a subscription when doesn't has one
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
if let values = self?.notificationDefaultValue[anyone] {
self?.createSubscriptionSubject.send((triggerBy: anyone, values: values))
}
// should create a subscription whenever change trigger
if let values = switches, let triggerBy = who {
self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values))
}
} receiveValue: { (subscription) in
}
.store(in: &disposeBag)
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
let domain = activeMastodonAuthenticationBox.domain
let userId = activeMastodonAuthenticationBox.userID
do {
try fetchResultsController.performFetch()
if nil == fetchResultsController.fetchedObjects?.first {
let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone
setting.value = self.context.apiService.createSettingIfNeed(domain: domain,
userId: userId,
triggerBy: anyone)
} else {
setting.value = fetchResultsController.fetchedObjects?.first
}
} catch {
assertionFailure(error.localizedDescription)
}
}
deinit {

View File

@ -5,44 +5,71 @@
// Created by ihugo on 2021/4/9.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import Combine
extension APIService {
func subscription(
domain: String,
userID: String,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let findSettings: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain, userID: userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try self.backgroundManagedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
let triggerBy = findSettings?.triggerBy ?? "anyone"
let setting = self.createSettingIfNeed(
domain: domain,
userId: userID,
triggerBy: triggerBy
)
return Mastodon.API.Subscriptions.subscription(
session: session,
domain: domain,
authorization: authorization)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
return self.backgroundManagedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeSubscription(
into: self.backgroundManagedObjectContext,
entity: response.value,
domain: domain)
}
.setFailureType(to: Error.self)
.map { _ in return response }
.eraseToAnyPublisher()
}.eraseToAnyPublisher()
authorization: authorization
)
.flatMap { response -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> in
return self.backgroundManagedObjectContext.performChanges {
_ = APIService.CoreData.createOrMergeSubscription(
into: self.backgroundManagedObjectContext,
entity: response.value,
domain: domain,
triggerBy: triggerBy,
setting: setting)
}
.setFailureType(to: Error.self)
.map { _ in return response }
.eraseToAnyPublisher()
}.eraseToAnyPublisher()
}
func changeSubscription(
domain: String,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
query: Mastodon.API.Subscriptions.CreateSubscriptionQuery,
triggerBy: String
triggerBy: String,
userID: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let setting = self.createSettingIfNeed(domain: domain,
userId: userID,
triggerBy: triggerBy)
return Mastodon.API.Subscriptions.createSubscription(
session: session,
domain: domain,
@ -55,7 +82,9 @@ extension APIService {
into: self.backgroundManagedObjectContext,
entity: response.value,
domain: domain,
triggerBy: triggerBy)
triggerBy: triggerBy,
setting: setting
)
}
.setFailureType(to: Error.self)
.map { _ in return response }
@ -67,10 +96,15 @@ extension APIService {
domain: String,
mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox,
query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery,
triggerBy: String
triggerBy: String,
userID: String
) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.Subscription>, Error> {
let authorization = mastodonAuthenticationBox.userAuthorization
let setting = self.createSettingIfNeed(domain: domain,
userId: userID,
triggerBy: triggerBy)
return Mastodon.API.Subscriptions.updateSubscription(
session: session,
domain: domain,
@ -83,12 +117,47 @@ extension APIService {
into: self.backgroundManagedObjectContext,
entity: response.value,
domain: domain,
triggerBy: triggerBy)
triggerBy: triggerBy,
setting: setting
)
}
.setFailureType(to: Error.self)
.map { _ in return response }
.eraseToAnyPublisher()
}.eraseToAnyPublisher()
}
func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting {
// create setting entity if possible
let oldSetting: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain, userID: userId)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try backgroundManagedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
var setting: Setting!
if let oldSetting = oldSetting {
setting = oldSetting
} else {
let property = Setting.Property(
appearance: "automatic",
triggerBy: triggerBy,
domain: domain,
userID: userId)
(setting, _) = APIService.CoreData.createOrMergeSetting(
into: backgroundManagedObjectContext,
domain: domain,
userID: userId,
property: property
)
}
return setting
}
}

View File

@ -16,11 +16,12 @@ extension APIService.CoreData {
static func createOrMergeSetting(
into managedObjectContext: NSManagedObjectContext,
domain: String,
userID: String,
property: Setting.Property
) -> (Subscription: Setting, isCreated: Bool) {
let oldSetting: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: property.domain)
request.predicate = Setting.predicate(domain: property.domain, userID: userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
@ -45,38 +46,12 @@ extension APIService.CoreData {
into managedObjectContext: NSManagedObjectContext,
entity: Mastodon.Entity.Subscription,
domain: String,
triggerBy: String? = nil
triggerBy: String,
setting: Setting
) -> (Subscription: Subscription, isCreated: Bool) {
// create setting entity if possible
let oldSetting: Setting? = {
let request = Setting.sortedFetchRequest
request.predicate = Setting.predicate(domain: domain)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
var setting: Setting!
if let oldSetting = oldSetting {
setting = oldSetting
} else {
let property = Setting.Property(
appearance: "automatic",
triggerBy: "anyone",
domain: domain)
(setting, _) = createOrMergeSetting(
into: managedObjectContext,
domain: domain,
property: property)
}
let oldSubscription: Subscription? = {
let request = Subscription.sortedFetchRequest
request.predicate = Subscription.predicate(id: entity.id)
request.predicate = Subscription.predicate(type: triggerBy)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
@ -91,7 +66,8 @@ extension APIService.CoreData {
endpoint: entity.endpoint,
id: entity.id,
serverKey: entity.serverKey,
type: triggerBy ?? setting.triggerBy ?? "")
type: triggerBy
)
let alertEntity = entity.alerts
let alert = SubscriptionAlerts.Property(
favourite: alertEntity.favouriteNumber,
@ -105,7 +81,8 @@ extension APIService.CoreData {
if nil == oldSubscription.alert {
oldSubscription.alert = SubscriptionAlerts.insert(
into: managedObjectContext,
property: alert)
property: alert
)
} else {
oldSubscription.alert?.updateIfNeed(property: alert)
}