TM-SGNL-iOS/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

534 lines
19 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public struct RemoteMegaphoneModel: Codable {
public enum CodingKeys: String, CodingKey {
case manifest
case translation
}
public private(set) var manifest: Manifest
public private(set) var translation: Translation
public var id: String {
manifest.id
}
public init(manifest: Manifest, translation: Translation) {
self.manifest = manifest
self.translation = translation
}
// MARK: - Update with refetched
/// Update select properties when this megaphone is re-fetched from the
/// service. For example, translations may have been updated (or be for a
/// new locale).
///
/// The properties that are updated here match those that are updated on
/// Android.
///
/// Note that image URLs are not updated, and therefore once an image has
/// 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) {
guard id == newMegaphone.id else {
owsFailDebug("Attempting to update remote megaphone, but IDs do not match! Current: \(id), new: \(newMegaphone.id)")
return
}
manifest.priority = newMegaphone.manifest.priority
manifest.countries = newMegaphone.manifest.countries
manifest.conditionalCheck = newMegaphone.manifest.conditionalCheck
manifest.primaryAction = newMegaphone.manifest.primaryAction
manifest.primaryActionData = newMegaphone.manifest.primaryActionData
manifest.secondaryAction = newMegaphone.manifest.secondaryAction
manifest.secondaryActionData = newMegaphone.manifest.secondaryActionData
translation.title = newMegaphone.translation.title
translation.body = newMegaphone.translation.body
translation.primaryActionText = newMegaphone.translation.primaryActionText
translation.secondaryActionText = newMegaphone.translation.secondaryActionText
}
// MARK: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
manifest = try container.decode(RemoteMegaphoneModel.Manifest.self, forKey: .manifest)
translation = try container.decode(RemoteMegaphoneModel.Translation.self, forKey: .translation)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(manifest, forKey: .manifest)
try container.encode(translation, forKey: .translation)
}
}
// MARK: - Manifest
extension RemoteMegaphoneModel {
/// Represents metadata about this megaphone, such as when it should be
/// presented and what actions it should support.
public struct Manifest: Codable {
public typealias EpochSeconds = UInt64
/// A unique ID for this manifest.
public let id: String
/// Priority of this megaphone relative to other remote megaphones.
/// Higher numbers indicate greater priority.
fileprivate(set) var priority: Int
/// Version string representing the minimum app version for which this
/// upgrade should be shown.
let minAppVersion: String
/// A CSV string of `<country-code>:<parts-per-million>` pairs
/// representing the fraction of users to which this megaphone should
/// be shown, by country code.
///
/// This is the same format used in remote-config country-code
/// restrictions.
fileprivate(set) var countries: String
/// Epoch time before which this megaphone should not be shown.
let dontShowBefore: EpochSeconds
/// Epoch time after which this megaphone should not be shown.
let dontShowAfter: EpochSeconds
/// Number of days after this megaphone is first presented that it
/// should continue to be shown, if the user does not interact with it.
let showForNumberOfDays: Int
/// Represents a condition that must be satisfied in order for this
/// megaphone to be presented.
fileprivate(set) var conditionalCheck: ConditionalCheck?
/// Represents an action to be performed in response to user selection
/// of the "primary" call-to-action in the presented megaphone.
public fileprivate(set) var primaryAction: Action?
/// Represents data associated with the performance of the primary
/// action.
fileprivate(set) var primaryActionData: ActionData?
/// Represents an action to be performed in response to user selection
/// of the "secondary" call-to-action in the presented megaphone.
public fileprivate(set) var secondaryAction: Action?
/// Represents data associated with the performance of the seocndary
/// action.
fileprivate(set) var secondaryActionData: ActionData?
public init(
id: String,
priority: Int,
minAppVersion: String,
countries: String,
dontShowBefore: EpochSeconds,
dontShowAfter: EpochSeconds,
showForNumberOfDays: Int,
conditionalCheck: ConditionalCheck?,
primaryAction: Action?,
primaryActionData: ActionData?,
secondaryAction: Action?,
secondaryActionData: ActionData?
) {
self.id = id
self.priority = priority
self.minAppVersion = minAppVersion
self.countries = countries
self.dontShowBefore = dontShowBefore
self.dontShowAfter = dontShowAfter
self.showForNumberOfDays = showForNumberOfDays
self.conditionalCheck = conditionalCheck
self.primaryAction = primaryAction
self.primaryActionData = primaryActionData
self.secondaryAction = secondaryAction
self.secondaryActionData = secondaryActionData
}
// MARK: Codable
public enum CodingKeys: String, CodingKey {
case id
case priority
case minAppVersion
case countries
case dontShowBefore
case dontShowAfter
case showForNumberOfDays
case conditionalCheck
case primaryAction
case primaryActionData
case secondaryAction
case secondaryActionData
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
priority = try container.decode(Int.self, forKey: .priority)
minAppVersion = try container.decode(String.self, forKey: .minAppVersion)
countries = try container.decode(String.self, forKey: .countries)
dontShowBefore = try container.decode(EpochSeconds.self, forKey: .dontShowBefore)
dontShowAfter = try container.decode(EpochSeconds.self, forKey: .dontShowAfter)
showForNumberOfDays = try container.decode(Int.self, forKey: .showForNumberOfDays)
conditionalCheck = try container.decodeIfPresent(ConditionalCheck.self, forKey: .conditionalCheck)
primaryAction = try container.decodeIfPresent(Action.self, forKey: .primaryAction)
primaryActionData = try container.decodeIfPresent(ActionData.self, forKey: .primaryActionData)
secondaryAction = try container.decodeIfPresent(Action.self, forKey: .secondaryAction)
secondaryActionData = try container.decodeIfPresent(ActionData.self, forKey: .secondaryActionData)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(priority, forKey: .priority)
try container.encode(minAppVersion, forKey: .minAppVersion)
try container.encode(countries, forKey: .countries)
try container.encode(dontShowBefore, forKey: .dontShowBefore)
try container.encode(dontShowAfter, forKey: .dontShowAfter)
try container.encode(showForNumberOfDays, forKey: .showForNumberOfDays)
if let conditionalCheck = conditionalCheck {
try container.encode(conditionalCheck, forKey: .conditionalCheck)
}
if let primaryAction = primaryAction {
try container.encode(primaryAction, forKey: .primaryAction)
}
if let primaryActionData = primaryActionData {
try container.encode(primaryActionData, forKey: .primaryActionData)
}
if let secondaryAction = secondaryAction {
try container.encode(secondaryAction, forKey: .secondaryAction)
}
if let secondaryActionData = secondaryActionData {
try container.encode(secondaryActionData, forKey: .secondaryActionData)
}
}
}
}
// MARK: - Conditional Check
extension RemoteMegaphoneModel.Manifest {
/// Identifies a known conditional check that must be satisfied in order
/// for this megaphone to be shown.
public enum ConditionalCheck: Codable {
case standardDonate
case internalUser
case unrecognized(conditionalId: String)
var conditionalId: String {
switch self {
case .standardDonate:
return "standard_donate"
case .internalUser:
return "internal_user"
case .unrecognized(let conditionalId):
return conditionalId
}
}
public init(fromConditionalId conditionalId: String) {
switch conditionalId {
case Self.standardDonate.conditionalId:
self = .standardDonate
case Self.internalUser.conditionalId:
self = .internalUser
default:
self = .unrecognized(conditionalId: conditionalId)
}
}
// MARK: Codable
private enum CodingKeys: String, CodingKey {
case conditionalId
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let conditionalId = try container.decode(String.self, forKey: .conditionalId)
self.init(fromConditionalId: conditionalId)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(conditionalId, forKey: .conditionalId)
}
}
}
// MARK: - Action
extension RemoteMegaphoneModel.Manifest {
/// Identifies a known action to take in response to a known user
/// interaction with this megaphone.
public enum Action: Codable {
case finish
case donate
case snooze
case donateFriend
case unrecognized(actionId: String)
var actionId: String {
switch self {
case .finish:
return "finish"
case .donate:
return "donate"
case .snooze:
return "snooze"
case .donateFriend:
return "donate_friend"
case .unrecognized(let conditionalId):
return conditionalId
}
}
public init(fromActionId actionId: String) {
self = {
switch actionId {
case Self.finish.actionId:
return .finish
case Self.donate.actionId:
return .donate
case Self.snooze.actionId:
return .snooze
case Self.donateFriend.actionId:
return .donateFriend
default:
return .unrecognized(actionId: actionId)
}
}()
}
// MARK: Codable
private enum CodingKeys: String, CodingKey {
case actionId
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let actionId = try container.decode(String.self, forKey: .actionId)
self.init(fromActionId: actionId)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(actionId, forKey: .actionId)
}
}
}
// MARK: - ActionData
extension RemoteMegaphoneModel.Manifest {
public enum ActionData: Codable {
case snoozeDurationDays(days: [UInt])
case unrecognized(actionDataId: String)
private static let snoozeDurationDaysId: String = "snoozeDurationDays"
public static func parse(fromJson jsonObject: [String: Any]) throws -> Self? {
let parser = ParamParser(dictionary: jsonObject)
if let snoozeDurationDays: [UInt] = try parser.optional(key: snoozeDurationDaysId) {
return .snoozeDurationDays(days: snoozeDurationDays)
}
return nil
}
// MARK: Codable
private enum CodingKeys: String, CodingKey {
case actionDataId
case associatedData
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let actionDataId = try container.decode(String.self, forKey: .actionDataId)
self = try { () throws in
switch actionDataId {
case Self.snoozeDurationDaysId:
let days = try container.decode([UInt].self, forKey: .associatedData)
return .snoozeDurationDays(days: days)
default:
return .unrecognized(actionDataId: actionDataId)
}
}()
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let (actionDataId, associatedData): (String, Encodable?) = {
switch self {
case .snoozeDurationDays(let days):
return (Self.snoozeDurationDaysId, days)
case .unrecognized(let actionDataId):
return (actionDataId, nil)
}
}()
try container.encode(actionDataId, forKey: .actionDataId)
if let associatedData = associatedData {
try container.encode(associatedData, forKey: .associatedData)
}
}
}
}
// MARK: - Translation
extension RemoteMegaphoneModel {
/// Represents a localized, user-presentable description of this megaphone.
public struct Translation: Codable {
/// A unique ID for the megaphone this translation corresponds to.
/// Should match the ID for this translation's manifest, and must be a
/// permissible file name.
public let id: String
/// Localized title for this megaphone.
public fileprivate(set) var title: String
/// Localized body for this megaphone.
public fileprivate(set) var body: String
/// Path to a remote image asset for this megaphone.
public let imageRemoteUrlPath: String?
/// File URL to a locally-stored image asset for this megaphone.
public private(set) var imageLocalUrl: URL?
/// Localized text to display on the "primary" call-to-action when this
/// megaphone is presented.
public fileprivate(set) var primaryActionText: String?
/// Localized text to display on the "secondary" call-to-action when this
/// megaphone is presented.
public fileprivate(set) var secondaryActionText: String?
private init(
id: String,
title: String,
body: String,
imageRemoteUrlPath: String?,
imageLocalUrl: URL?,
primaryActionText: String?,
secondaryActionText: String?
) {
self.id = id
self.title = title
self.body = body
self.imageRemoteUrlPath = imageRemoteUrlPath
self.imageLocalUrl = imageLocalUrl
self.primaryActionText = primaryActionText
self.secondaryActionText = secondaryActionText
}
public mutating func setImageLocalUrl(_ url: URL) {
imageLocalUrl = url
}
// MARK: Factories
public static func makeWithoutLocalImage(
id: String,
title: String,
body: String,
imageRemoteUrlPath: String?,
primaryActionText: String?,
secondaryActionText: String?
) -> Translation {
Translation(
id: id,
title: title,
body: body,
imageRemoteUrlPath: imageRemoteUrlPath,
imageLocalUrl: nil,
primaryActionText: primaryActionText,
secondaryActionText: secondaryActionText
)
}
// MARK: Codable
public enum CodingKeys: String, CodingKey {
case id
case title
case body
case imageRemoteUrlPath
case imageLocalUrl
case primaryActionText
case secondaryActionText
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
body = try container.decode(String.self, forKey: .body)
imageRemoteUrlPath = try container.decodeIfPresent(String.self, forKey: .imageRemoteUrlPath)
imageLocalUrl = try container.decodeIfPresent(URL.self, forKey: .imageLocalUrl)
primaryActionText = try container.decodeIfPresent(String.self, forKey: .primaryActionText)
secondaryActionText = try container.decodeIfPresent(String.self, forKey: .secondaryActionText)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(title, forKey: .title)
try container.encode(body, forKey: .body)
if let imageRemoteUrlPath = imageRemoteUrlPath {
try container.encode(imageRemoteUrlPath, forKey: .imageRemoteUrlPath)
}
if let imageLocalUrl = imageLocalUrl {
try container.encode(imageLocalUrl, forKey: .imageLocalUrl)
}
if let primaryActionText = primaryActionText {
try container.encode(primaryActionText, forKey: .primaryActionText)
}
if let secondaryActionText = secondaryActionText {
try container.encode(secondaryActionText, forKey: .secondaryActionText)
}
}
}
}