Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift
1 views
//
// 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 {
                try container.encode(conditionalCheck, forKey: .conditionalCheck)
            }

            if let primaryAction {
                try container.encode(primaryAction, forKey: .primaryAction)
            }

            if let primaryActionData {
                try container.encode(primaryActionData, forKey: .primaryActionData)
            }

            if let secondaryAction {
                try container.encode(secondaryAction, forKey: .secondaryAction)
            }

            if let 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(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 {
                try container.encode(associatedData, forKey: .associatedData)
            }
        }
    }
}

// MARK: - Translation

extension RemoteMegaphoneModel {
    public static let imagesDirectory: URL = {
        let imagesSubdirectory: String = "MegaphoneImages"
        return OWSFileSystem.appSharedDataDirectoryURL().appendingPathComponent(imagesSubdirectory)
    }()

    /// 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?

        /// Whether or not this megaphone has a locally-stored image asset.
        public private(set) var hasImage: Bool
        public var imageLocalRelativePath: String { id }

        /// 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?,
            hasImage: Bool,
            primaryActionText: String?,
            secondaryActionText: String?,
        ) {
            self.id = id
            self.title = title
            self.body = body
            self.imageRemoteUrlPath = imageRemoteUrlPath
            self.hasImage = hasImage
            self.primaryActionText = primaryActionText
            self.secondaryActionText = secondaryActionText
        }

        public mutating func setHasImage(_ hasImage: Bool) {
            self.hasImage = hasImage
        }

        // 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,
                hasImage: false,
                primaryActionText: primaryActionText,
                secondaryActionText: secondaryActionText,
            )
        }

        // MARK: Codable

        public enum CodingKeys: String, CodingKey {
            case id
            case title
            case body
            case imageRemoteUrlPath
            case hasImage
            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)
            hasImage = try container.decodeIfPresent(Bool.self, forKey: .hasImage) ?? (try container.decodeIfPresent(URL.self, forKey: .imageLocalUrl) != nil)
            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 {
                try container.encode(imageRemoteUrlPath, forKey: .imageRemoteUrlPath)
            }

            try container.encode(hasImage, forKey: .hasImage)

            if let primaryActionText {
                try container.encode(primaryActionText, forKey: .primaryActionText)
            }

            if let secondaryActionText {
                try container.encode(secondaryActionText, forKey: .secondaryActionText)
            }
        }
    }
}