De-SDS-ify ExperienceUpgrade

This commit is contained in:
Sasha Weiss 2026-05-29 09:42:50 -07:00 committed by GitHub
parent c57f731c67
commit 102b164f89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 208 additions and 130 deletions

View File

@ -2897,6 +2897,7 @@
D9791BC42EAADF010016AA5A /* HTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9791BC32EAADEFD0016AA5A /* HTTPResponse.swift */; };
D97992A12D9E55F20080A4F5 /* CurrencyFormatterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97992A02D9E55E10080A4F5 /* CurrencyFormatterTest.swift */; };
D97992A32D9E55FB0080A4F5 /* CurrencyFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97992A22D9E55F80080A4F5 /* CurrencyFormatter.swift */; };
D97992AC2FC147C5002E79F3 /* ExperienceUpgradeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D97992AB2FC147C2002E79F3 /* ExperienceUpgradeStore.swift */; };
D979CC262AD3933B006AAC49 /* IndividualCallRecordManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC1F2AD3933B006AAC49 /* IndividualCallRecordManager.swift */; };
D979CC292AD3933B006AAC49 /* OutgoingCallEventSyncMessageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC222AD3933B006AAC49 /* OutgoingCallEventSyncMessageManager.swift */; };
D979CC2B2AD3933B006AAC49 /* InteractionStore+CallRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = D979CC242AD3933B006AAC49 /* InteractionStore+CallRecord.swift */; };
@ -7183,6 +7184,7 @@
D9791BC32EAADEFD0016AA5A /* HTTPResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPResponse.swift; sourceTree = "<group>"; };
D97992A02D9E55E10080A4F5 /* CurrencyFormatterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFormatterTest.swift; sourceTree = "<group>"; };
D97992A22D9E55F80080A4F5 /* CurrencyFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFormatter.swift; sourceTree = "<group>"; };
D97992AB2FC147C2002E79F3 /* ExperienceUpgradeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradeStore.swift; sourceTree = "<group>"; };
D979CC1F2AD3933B006AAC49 /* IndividualCallRecordManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IndividualCallRecordManager.swift; sourceTree = "<group>"; };
D979CC202AD3933B006AAC49 /* IncomingCallEventSyncMessageManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IncomingCallEventSyncMessageManager.swift; sourceTree = "<group>"; };
D979CC222AD3933B006AAC49 /* OutgoingCallEventSyncMessageManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutgoingCallEventSyncMessageManager.swift; sourceTree = "<group>"; };
@ -13327,6 +13329,7 @@
D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */,
F9C5CB5B289453B200548EEE /* ExperienceUpgradeFinder.swift */,
D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */,
D97992AB2FC147C2002E79F3 /* ExperienceUpgradeStore.swift */,
040506FB2F7FE3D50078B769 /* RemoteAnnouncementModel.swift */,
D98DD85E28EE53B00089333E /* RemoteMegaphoneModel.swift */,
040506FF2F8049910078B769 /* RemoteReleaseNotesService.swift */,
@ -19232,6 +19235,7 @@
D9C7CEB428EB8495001E87B6 /* ExperienceUpgrade.swift in Sources */,
F9C5CE2D289453B400548EEE /* ExperienceUpgradeFinder.swift in Sources */,
D9C7CECB28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift in Sources */,
D97992AC2FC147C5002E79F3 /* ExperienceUpgradeStore.swift in Sources */,
D98BC5332EE387A30052A81F /* ExpirationJob.swift in Sources */,
C1FB9B752B16498C00D51A3B /* ExternalPendingDonationStore.swift in Sources */,
F9C5CE57289453B400548EEE /* Factories.swift in Sources */,

View File

@ -8,20 +8,36 @@ public import SignalServiceKit
/// Handles fetching and parsing remote megaphones.
public class RemoteMegaphoneFetcher: RemoteReleaseNotesFetcher<RemoteMegaphoneModel.Manifest, RemoteMegaphoneModel.Translation> {
private let experienceUpgradeStore: ExperienceUpgradeStore
override init(
db: DB,
remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol,
) {
self.experienceUpgradeStore = ExperienceUpgradeStore()
super.init(
db: db,
remoteReleaseNotesService: remoteReleaseNotesService,
)
}
/// Update our local persisted megaphone state with freshly-fetched
/// megaphones from the service. Updates existing megaphones if present,
/// and creates new ones if necessary. Removes any locally-persisted
/// megaphones that no longer exist on the service.
override func updatePersistedData(
withFetchedData fetchedTranslations: [(RemoteMegaphoneModel.Manifest, RemoteMegaphoneModel.Translation)],
transaction: DBWriteTransaction,
transaction tx: DBWriteTransaction,
) {
// Get the current remote megaphones.
var localRemoteMegaphones: [String: ExperienceUpgrade] = [:]
ExperienceUpgrade.anyEnumerate(transaction: transaction) { upgrade, _ in
if case .remoteMegaphone = upgrade.manifest {
localRemoteMegaphones[upgrade.uniqueId] = upgrade
// Get any persisted ExperienceUpgrades for the remote megaphones.
var experienceUpgradesByMegaphoneId: [String: ExperienceUpgrade] = [:]
experienceUpgradeStore.enumerateExperienceUpgrades(tx: tx) { experienceUpgrade in
guard case .remoteMegaphone(let model) = experienceUpgrade.manifest else {
return
}
experienceUpgradesByMegaphoneId[model.manifest.id] = experienceUpgrade
}
// Insert all megaphones we got from the service. If we already have a
@ -30,23 +46,28 @@ public class RemoteMegaphoneFetcher: RemoteReleaseNotesFetcher<RemoteMegaphoneMo
// For example, if the user's locale has changed we may have updated
// translations.
for (manifest, translation) in fetchedTranslations {
let serviceMegaphone = RemoteMegaphoneModel(manifest: manifest, translation: translation)
if let existingLocalMegaphone = localRemoteMegaphones[serviceMegaphone.id] {
existingLocalMegaphone.updateManifestRemoteMegaphone(withRefetchedMegaphone: serviceMegaphone)
existingLocalMegaphone.anyUpsert(transaction: transaction)
localRemoteMegaphones.removeValue(forKey: serviceMegaphone.id)
let remoteMegaphoneModel = RemoteMegaphoneModel(manifest: manifest, translation: translation)
let experienceUpgrade: ExperienceUpgrade
if let persisted = experienceUpgradesByMegaphoneId.removeValue(forKey: manifest.id) {
experienceUpgrade = persisted
} else {
ExperienceUpgrade
.makeNew(withManifest: .remoteMegaphone(megaphone: serviceMegaphone))
.anyInsert(transaction: transaction)
experienceUpgrade = .makeNew(withManifest: .remoteMegaphone(megaphone: remoteMegaphoneModel))
}
experienceUpgradeStore.upsertRemoteMegaphone(
experienceUpgrade: experienceUpgrade,
newRemoteMegaphoneModel: remoteMegaphoneModel,
tx: tx,
)
}
// Remove records for any remaining local megaphones, which are no
// longer on the service.
for (_, experienceUpgradeToRemove) in localRemoteMegaphones {
experienceUpgradeToRemove.anyRemove(transaction: transaction)
for (_, experienceUpgradeToRemove) in experienceUpgradesByMegaphoneId {
experienceUpgradeStore.remove(
experienceUpgrade: experienceUpgradeToRemove,
tx: tx,
)
}
}

View File

@ -12,6 +12,7 @@ import Testing
@MainActor
struct RemoteReleaseNotesFetchingManagerTests {
private let db = InMemoryDB()
private let experienceUpgradeStore = ExperienceUpgradeStore()
private let remoteReleaseNotesFetchingManager: RemoteReleaseNotesFetchingManager
init() {
@ -26,7 +27,7 @@ struct RemoteReleaseNotesFetchingManagerTests {
try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes()
db.read { tx in
ExperienceUpgrade.anyEnumerate(transaction: tx) { upgrade, _ in
experienceUpgradeStore.enumerateExperienceUpgrades(tx: tx) { upgrade in
switch upgrade.manifest {
case .remoteMegaphone(let megaphone):
#expect(megaphone.translation.title == "Donate Today")

View File

@ -6,7 +6,9 @@
import Foundation
public import GRDB
public class ExperienceUpgrade: SDSCodableModel, Decodable {
public class ExperienceUpgrade: Codable, FetchableRecord, PersistableRecord {
public typealias IDType = Int64
public static let databaseTableName = "model_ExperienceUpgrade"
private static var recordType: SDSRecordType { .experienceUpgrade }
@ -22,25 +24,26 @@ public class ExperienceUpgrade: SDSCodableModel, Decodable {
case manifest
}
public var id: RowId?
public var id: IDType?
public var uniqueId: String {
manifest.uniqueId
}
/// Timestamp when this upgrade was first viewed.
public private(set) var firstViewedTimestamp: TimeInterval
public var firstViewedTimestamp: TimeInterval
/// Timestamp when this upgrade was last snoozed.
public internal(set) var lastSnoozedTimestamp: TimeInterval
public var lastSnoozedTimestamp: TimeInterval
/// Number of times this upgrade has been snoozed.
public internal(set) var snoozeCount: UInt
public var snoozeCount: UInt
/// Whether this upgrade should be considered fully complete.
public private(set) var isComplete: Bool
public var isComplete: Bool
/// Identifies and holds metadata about this ``ExperienceUpgrade``.
public private(set) var manifest: ExperienceUpgradeManifest
public var manifest: ExperienceUpgradeManifest
private init(manifest: ExperienceUpgradeManifest) {
self.firstViewedTimestamp = 0
@ -55,6 +58,21 @@ public class ExperienceUpgrade: SDSCodableModel, Decodable {
ExperienceUpgrade(manifest: manifest)
}
// MARK: - PersistableRecord
public func didInsert(with rowID: Int64, for column: String?) {
id = rowID
}
public static let persistenceConflictPolicy: PersistenceConflictPolicy = PersistenceConflictPolicy(
insert: .replace,
update: .replace,
)
public func upsert(tx: DBWriteTransaction) throws {
try self.insert(tx.database)
}
// MARK: - Codable
public required init(from decoder: Decoder) throws {
@ -63,7 +81,7 @@ public class ExperienceUpgrade: SDSCodableModel, Decodable {
let decodedRecordType = try container.decode(Int64.self, forKey: .recordType)
owsAssertDebug(decodedRecordType == Self.recordType.rawValue, "Unexpectedly decoded record with wrong type.")
id = try container.decodeIfPresent(RowId.self, forKey: .id)
id = try container.decodeIfPresent(IDType.self, forKey: .id)
firstViewedTimestamp = try container.decode(TimeInterval.self, forKey: .firstViewedTimestamp)
lastSnoozedTimestamp = try container.decode(TimeInterval.self, forKey: .lastSnoozedTimestamp)
@ -131,85 +149,4 @@ public class ExperienceUpgrade: SDSCodableModel, Decodable {
return Int(clamping: daysSinceFirstView) > manifest.numberOfDaysToShowFor
}
// MARK: - Removal
public func anyDidRemove(transaction: DBWriteTransaction) {
switch manifest {
case
.introducingPins,
.notificationPermissionReminder,
.newLinkedDeviceNotification,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.inactivePrimaryDeviceReminder,
.pinReminder,
.contactPermissionReminder,
.backupKeyReminder,
.enableBackupsReminder,
.haveEnabledBackupsNotification,
.unrecognized:
return
case .remoteMegaphone(let megaphone):
guard megaphone.translation.hasImage else {
return
}
do {
let imageLocalUrl = RemoteMegaphoneModel.imagesDirectory.appendingPathComponent(megaphone.translation.imageLocalRelativePath)
try FileManager.default.removeItem(at: imageLocalUrl)
} catch let error {
owsFailDebug("Failed to remove image file for removed remote megaphone with ID \(megaphone.id)! \(error)")
}
}
}
// MARK: - Mark as <state>
public func markAsSnoozed(transaction: DBWriteTransaction) {
upsert(withTransaction: transaction) { upgrade in
upgrade.lastSnoozedTimestamp = Date().timeIntervalSince1970
upgrade.snoozeCount += 1
}
}
public func markAsComplete(transaction: DBWriteTransaction) {
upsert(withTransaction: transaction) { $0.isComplete = true }
}
public func markAsViewed(transaction: DBWriteTransaction) {
upsert(withTransaction: transaction) { upgrade in
guard upgrade.firstViewedTimestamp == 0 else { return }
upgrade.firstViewedTimestamp = Date().timeIntervalSince1970
}
}
/// If an upgrade is already persisted with our `uniqueId`, performs `block`
/// on it and updates. Otherwise, performs `block` on ourself and inserts
/// ourself. Skips calling `block` if this upgrade should not be saved.
private func upsert(withTransaction transaction: DBWriteTransaction, inBlock block: (ExperienceUpgrade) -> Void) {
guard manifest.shouldSave else {
return
}
let experienceToUpgrade = ExperienceUpgrade.anyFetch(uniqueId: uniqueId, transaction: transaction) ?? self
block(experienceToUpgrade)
experienceToUpgrade.anyUpsert(transaction: transaction)
}
// MARK: - Update remote megaphone info
/// Updates a subset of properties on the existing manifest with the given
/// re-fetched megaphone. Does nothing if the given megaphone does not
/// match the existing.
public func updateManifestRemoteMegaphone(withRefetchedMegaphone refetchedMegaphone: RemoteMegaphoneModel) {
guard case .remoteMegaphone(var megaphone) = manifest else {
owsFailDebug("Attempting to update remote megaphone, but upgrade is not a remote megaphone: \(manifest)")
return
}
megaphone.update(withRefetched: refetchedMegaphone)
manifest = .remoteMegaphone(megaphone: megaphone)
}
}

View File

@ -9,34 +9,23 @@ public class ExperienceUpgradeFinder {
// MARK: -
public class func markAsViewed(experienceUpgrade: ExperienceUpgrade, transaction: DBWriteTransaction) {
Logger.info("marking experience upgrade as seen \(experienceUpgrade.uniqueId)")
experienceUpgrade.markAsViewed(transaction: transaction)
public class func markAsViewed(experienceUpgrade: ExperienceUpgrade, transaction tx: DBWriteTransaction) {
ExperienceUpgradeStore().markAsViewed(experienceUpgrade: experienceUpgrade, tx: tx)
}
public class func markAsSnoozed(experienceUpgrade: ExperienceUpgrade, transaction: DBWriteTransaction) {
Logger.info("marking experience upgrade as snoozed \(experienceUpgrade.uniqueId)")
experienceUpgrade.markAsSnoozed(transaction: transaction)
public class func markAsSnoozed(experienceUpgrade: ExperienceUpgrade, transaction tx: DBWriteTransaction) {
ExperienceUpgradeStore().markAsSnoozed(experienceUpgrade: experienceUpgrade, tx: tx)
}
public class func markAsComplete(
experienceUpgradeManifest manifest: ExperienceUpgradeManifest,
transaction: DBWriteTransaction,
experienceUpgradeManifest: ExperienceUpgradeManifest,
transaction tx: DBWriteTransaction,
) {
markAsComplete(
experienceUpgrade: ExperienceUpgrade.makeNew(withManifest: manifest),
transaction: transaction,
)
markAsComplete(experienceUpgrade: .makeNew(withManifest: experienceUpgradeManifest), transaction: tx)
}
public class func markAsComplete(experienceUpgrade: ExperienceUpgrade, transaction: DBWriteTransaction) {
guard experienceUpgrade.manifest.shouldComplete else {
return Logger.info("Skipping marking complete for experience upgrade with uniqueId: \(experienceUpgrade.uniqueId)")
}
Logger.info("Marking complete experience upgrade with uniqueId: \(experienceUpgrade.uniqueId)")
experienceUpgrade.markAsComplete(transaction: transaction)
public class func markAsComplete(experienceUpgrade: ExperienceUpgrade, transaction tx: DBWriteTransaction) {
ExperienceUpgradeStore().markAsComplete(experienceUpgrade: experienceUpgrade, tx: tx)
}
public class func markAllCompleteForNewUser(transaction: DBWriteTransaction) {
@ -51,13 +40,13 @@ public class ExperienceUpgradeFinder {
/// persisted record if one exists and is applicable, and an in-memory
/// model otherwise.
public class func allKnownExperienceUpgrades(
transaction: DBReadTransaction,
transaction tx: DBReadTransaction,
) -> [ExperienceUpgrade] {
var experienceUpgrades = [ExperienceUpgrade]()
var localManifestsWithoutRecords = ExperienceUpgradeManifest.wellKnownLocalUpgradeManifests
// Load any experience upgrades with persisted records...
ExperienceUpgrade.anyEnumerate(transaction: transaction) { experienceUpgrade, _ in
ExperienceUpgradeStore().enumerateExperienceUpgrades(tx: tx) { experienceUpgrade in
if case .unrecognized = experienceUpgrade.manifest {
// Ignore any no-longer-recognized records.
return

View File

@ -0,0 +1,126 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public struct ExperienceUpgradeStore {
public init() {}
// MARK: -
public func markAsSnoozed(experienceUpgrade: ExperienceUpgrade, tx: DBWriteTransaction) {
Logger.info("Marking snoozed: \(experienceUpgrade.uniqueId)")
experienceUpgrade.lastSnoozedTimestamp = Date().timeIntervalSince1970
experienceUpgrade.snoozeCount += 1
upsert(experienceUpgrade: experienceUpgrade, tx: tx)
}
public func markAsComplete(experienceUpgrade: ExperienceUpgrade, tx: DBWriteTransaction) {
guard experienceUpgrade.manifest.shouldComplete else {
Logger.info("Skipping marking complete: \(experienceUpgrade.uniqueId)")
return
}
Logger.info("Marking complete: \(experienceUpgrade.uniqueId)")
experienceUpgrade.isComplete = true
upsert(experienceUpgrade: experienceUpgrade, tx: tx)
}
public func markAsViewed(experienceUpgrade: ExperienceUpgrade, tx: DBWriteTransaction) {
guard experienceUpgrade.firstViewedTimestamp == 0 else {
Logger.info("Already marked viewed, skipping: \(experienceUpgrade.uniqueId)")
return
}
Logger.info("Marking first viewed: \(experienceUpgrade.uniqueId)")
experienceUpgrade.firstViewedTimestamp = Date().timeIntervalSince1970
upsert(experienceUpgrade: experienceUpgrade, tx: tx)
}
/// Updates a subset of properties on the existing manifest with the given
/// re-fetched megaphone. Does nothing if the given megaphone does not
/// match the existing.
public func upsertRemoteMegaphone(
experienceUpgrade: ExperienceUpgrade,
newRemoteMegaphoneModel: RemoteMegaphoneModel,
tx: DBWriteTransaction,
) {
guard
case .remoteMegaphone(var remoteMegaphoneModel) = experienceUpgrade.manifest
else {
owsFailDebug("Attempting to update remote megaphone, but upgrade is not a remote megaphone! \(experienceUpgrade.uniqueId)")
return
}
remoteMegaphoneModel.updateSelectively(newRemoteMegaphoneModel: newRemoteMegaphoneModel)
experienceUpgrade.manifest = .remoteMegaphone(megaphone: remoteMegaphoneModel)
upsert(experienceUpgrade: experienceUpgrade, tx: tx)
}
private func upsert(experienceUpgrade: ExperienceUpgrade, tx: DBWriteTransaction) {
guard experienceUpgrade.manifest.shouldSave else {
return
}
failIfThrows {
try experienceUpgrade.upsert(tx: tx)
}
}
// MARK: -
public func enumerateExperienceUpgrades(
tx: DBReadTransaction,
block: (ExperienceUpgrade) -> Void,
) {
var cursor = FailIfThrowsRecordCursor {
try ExperienceUpgrade.fetchCursor(tx.database)
}
while let upgrade = cursor.next() {
block(upgrade)
}
}
// MARK: -
public func remove(experienceUpgrade: ExperienceUpgrade, tx: DBWriteTransaction) {
failIfThrows {
try experienceUpgrade.delete(tx.database)
}
switch experienceUpgrade.manifest {
case .introducingPins,
.notificationPermissionReminder,
.newLinkedDeviceNotification,
.createUsernameReminder,
.inactiveLinkedDeviceReminder,
.inactivePrimaryDeviceReminder,
.pinReminder,
.contactPermissionReminder,
.backupKeyReminder,
.enableBackupsReminder,
.haveEnabledBackupsNotification,
.unrecognized:
return
case .remoteMegaphone(let megaphone):
guard megaphone.translation.hasImage else {
return
}
do {
let imageLocalUrl = RemoteMegaphoneModel.imagesDirectory
.appendingPathComponent(megaphone.translation.imageLocalRelativePath)
try FileManager.default.removeItem(at: imageLocalUrl)
} catch let error {
owsFailDebug("Failed to remove image file for removed remote megaphone with ID \(megaphone.id)! \(error)")
}
}
}
}

View File

@ -36,7 +36,7 @@ public struct RemoteMegaphoneModel: Codable {
/// been fetched and cached for this megaphone it is immutable - if this
/// changes in the future, ensure that previously-fetched images are handled
/// appropriately.
mutating func update(withRefetched newMegaphone: RemoteMegaphoneModel) {
mutating func updateSelectively(newRemoteMegaphoneModel newMegaphone: RemoteMegaphoneModel) {
guard id == newMegaphone.id else {
owsFailDebug("Attempting to update remote megaphone, but IDs do not match! Current: \(id), new: \(newMegaphone.id)")
return