Store: save one Fuel Savings field per edit, and update keychain items in place
The Settings sliders called persistFuelSavingsSettings() on every value change, which rewrote all four keys — four UserDefaults writes and four keychain round trips per step, on the main thread, up to 90 times per drag. That is what made them stutter. saveFuelSavingsField(_:from:) writes just the field that moved, so a slider gesture touches one key. writeKeychain now uses SecItemUpdate and only falls back to SecItemAdd on errSecItemNotFound. Delete-then-add cost two round trips to securityd and briefly left the key holding no value at all — a window where the keychain, the authoritative mirror on read, could disagree with the app-group defaults. Tests pin the store half: a single-field save leaves the other three keys alone, and the whole-settings save still writes all four.
This commit is contained in:
@@ -357,6 +357,68 @@ final class AlertsFuelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Settings sliders save ONE field per gesture instead of rewriting all four
|
||||
/// keys (four UserDefaults writes plus four keychain round trips per drag is what
|
||||
/// made them stutter). These pin the store half of that: a single-field save must
|
||||
/// leave the other three keys exactly as they were.
|
||||
final class FuelSavingsFieldSaveTests: XCTestCase {
|
||||
private func seed() {
|
||||
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
|
||||
enabled: true,
|
||||
mpg: 40,
|
||||
preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds)
|
||||
))
|
||||
}
|
||||
|
||||
func testSavingMPGLeavesTheOtherFieldsAlone() {
|
||||
seed()
|
||||
var edited = FuelStore.loadFuelSavingsSettings()
|
||||
edited.mpg = 62
|
||||
FuelStore.saveFuelSavingsField(.mpg, from: edited)
|
||||
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||
XCTAssertEqual(reloaded.mpg, 62, "the edited field is written")
|
||||
XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save")
|
||||
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save")
|
||||
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save")
|
||||
}
|
||||
|
||||
func testSavingFillAmountLeavesMPGAndUnitAlone() {
|
||||
seed()
|
||||
var edited = FuelStore.loadFuelSavingsSettings()
|
||||
edited.preferredAmount.value = 75
|
||||
FuelStore.saveFuelSavingsField(.amount, from: edited)
|
||||
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||
XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written")
|
||||
XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save")
|
||||
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save")
|
||||
}
|
||||
|
||||
func testSavingEnabledLeavesTheNumbersAlone() {
|
||||
seed()
|
||||
var edited = FuelStore.loadFuelSavingsSettings()
|
||||
edited.enabled = false
|
||||
FuelStore.saveFuelSavingsField(.enabled, from: edited)
|
||||
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||
XCTAssertFalse(reloaded.enabled, "the edited field is written")
|
||||
XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save")
|
||||
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save")
|
||||
}
|
||||
|
||||
func testWholeSettingsSaveStillWritesEveryField() {
|
||||
seed()
|
||||
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
|
||||
enabled: false,
|
||||
mpg: 55,
|
||||
preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres)
|
||||
))
|
||||
let reloaded = FuelStore.loadFuelSavingsSettings()
|
||||
XCTAssertFalse(reloaded.enabled)
|
||||
XCTAssertEqual(reloaded.mpg, 55)
|
||||
XCTAssertEqual(reloaded.preferredAmount.value, 30)
|
||||
XCTAssertEqual(reloaded.preferredAmount.unit, .litres)
|
||||
}
|
||||
}
|
||||
|
||||
final class AlertRadiusFollowTests: XCTestCase {
|
||||
func testAlertRadiusOptionsAreMileFriendly() {
|
||||
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
|
||||
|
||||
+29
-2
@@ -850,6 +850,26 @@ struct FuelStore {
|
||||
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||
}
|
||||
|
||||
/// One field of the Fuel Savings settings. A control that edits a single
|
||||
/// value saves just that field, so moving the MPG slider rewrites the MPG key
|
||||
/// instead of all four — four UserDefaults writes and four keychain round
|
||||
/// trips per gesture is what made those sliders stutter.
|
||||
enum FuelSavingsField {
|
||||
case enabled
|
||||
case mpg
|
||||
case amount
|
||||
case unit
|
||||
}
|
||||
|
||||
static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) {
|
||||
switch field {
|
||||
case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
|
||||
case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey)
|
||||
case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
|
||||
case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||
/// third digit (the forecourt style: "£1.29⁹").
|
||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||
@@ -1719,11 +1739,18 @@ struct FuelStore {
|
||||
}
|
||||
|
||||
private static func writeKeychain(data: Data, service: String) {
|
||||
let deleteQuery: [String: Any] = [
|
||||
// Update in place when the item already exists. Delete-then-add cost two
|
||||
// round trips to securityd and briefly left the key holding no value at
|
||||
// all, which is half of why a setting could disagree between launches.
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
let status = SecItemUpdate(query as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary)
|
||||
guard status == errSecItemNotFound else { return }
|
||||
// Nothing stored under this service yet — the first write after the app
|
||||
// was deleted, or a key added since. Add it.
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
|
||||
Reference in New Issue
Block a user