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

481 lines
20 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import SignalServiceKit
/// Handles fetching and parsing remote megaphones.
public class RemoteMegaphoneFetcher {
private let databaseStorage: SDSDatabaseStorage
private let signalService: any OWSSignalServiceProtocol
public init(
databaseStorage: SDSDatabaseStorage,
signalService: any OWSSignalServiceProtocol
) {
self.databaseStorage = databaseStorage
self.signalService = signalService
}
/// Fetch all remote megaphones currently on the service and persist them
/// locally. Removes any locally-persisted remote megaphones that are no
/// longer available remotely.
public func syncRemoteMegaphonesIfNecessary() async throws {
let shouldSync = databaseStorage.read { self.shouldSync(transaction: $0) }
guard shouldSync else {
return
}
Logger.info("Beginning remote megaphone fetch.")
let megaphones: [RemoteMegaphoneModel]
do {
megaphones = try await fetchRemoteMegaphones()
} catch {
Logger.warn("\(error)")
throw error
}
Logger.info("Syncing \(megaphones.count) fetched remote megaphones with local state.")
await self.databaseStorage.awaitableWrite { transaction in
self.updatePersistedMegaphones(
withFetchedMegaphones: megaphones,
transaction: transaction
)
self.recordCompletedSync(transaction: transaction)
}
}
}
// MARK: - Sync conditions
private extension String {
static let fetcherStoreCollection = "RemoteMegaphoneFetcher"
static let appVersionAtLastFetchKey = "appVersionAtLastFetch"
static let lastFetchDateKey = "lastFetchDate"
}
private extension RemoteMegaphoneFetcher {
private static let fetcherStore = KeyValueStore(collection: .fetcherStoreCollection)
private static let delayBetweenSyncs: TimeInterval = 3 * kDayInterval
func shouldSync(transaction: SDSAnyReadTransaction) -> Bool {
guard
let appVersionAtLastFetch = Self.fetcherStore.getString(.appVersionAtLastFetchKey, transaction: transaction.asV2Read),
let lastFetchDate = Self.fetcherStore.getDate(.lastFetchDateKey, transaction: transaction.asV2Read)
else {
// If we have never recorded last-fetch data, we should sync.
return true
}
let hasChangedAppVersion = appVersionAtLastFetch != AppVersionImpl.shared.currentAppVersion
let hasWaitedEnoughSinceLastSync = Date().timeIntervalSince(lastFetchDate) > Self.delayBetweenSyncs
return hasChangedAppVersion || hasWaitedEnoughSinceLastSync
}
func recordCompletedSync(transaction: SDSAnyWriteTransaction) {
Self.fetcherStore.setString(
AppVersionImpl.shared.currentAppVersion,
key: .appVersionAtLastFetchKey,
transaction: transaction.asV2Write
)
Self.fetcherStore.setDate(
Date(),
key: .lastFetchDateKey,
transaction: transaction.asV2Write
)
}
}
// MARK: - Persisted megaphones
private extension RemoteMegaphoneFetcher {
/// 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.
func updatePersistedMegaphones(
withFetchedMegaphones serviceMegaphones: [RemoteMegaphoneModel],
transaction: SDSAnyWriteTransaction
) {
// Get the current remote megaphones.
var localRemoteMegaphones: [String: ExperienceUpgrade] = [:]
ExperienceUpgrade.anyEnumerate(transaction: transaction) { upgrade, _ in
if case .remoteMegaphone = upgrade.manifest {
localRemoteMegaphones[upgrade.uniqueId] = upgrade
}
}
// Insert all megaphones we got from the service. If we already have a
// persisted copy of this megaphone, update it - this will ensure that
// if anything has changed about the megaphone we have the latest state.
// For example, if the user's locale has changed we may have updated
// translations.
for serviceMegaphone in serviceMegaphones {
if let existingLocalMegaphone = localRemoteMegaphones[serviceMegaphone.id] {
existingLocalMegaphone.updateManifestRemoteMegaphone(withRefetchedMegaphone: serviceMegaphone)
existingLocalMegaphone.anyUpsert(transaction: transaction)
localRemoteMegaphones.removeValue(forKey: serviceMegaphone.id)
} else {
ExperienceUpgrade
.makeNew(withManifest: .remoteMegaphone(megaphone: serviceMegaphone))
.anyInsert(transaction: transaction)
}
}
// Remove records for any remaining local megaphones, which are no
// longer on the service.
for (_, experienceUpgradeToRemove) in localRemoteMegaphones {
experienceUpgradeToRemove.anyRemove(transaction: transaction)
}
}
}
// MARK: - Fetching
private extension RemoteMegaphoneFetcher {
func fetchRemoteMegaphones() async throws -> [RemoteMegaphoneModel] {
let manifests = try await fetchManifests()
return try await withThrowingTaskGroup(of: RemoteMegaphoneModel.self) { taskGroup in
for manifest in manifests {
taskGroup.addTask {
let translation = try await self.fetchTranslation(forMegaphoneManifest: manifest)
if manifest.id != translation.id {
// We shouldn't fail here, but this scenario is
// unexpected so let's keep an eye out for it.
owsFailDebug("Have manifest ID \(manifest.id) that does not match fetched translation ID \(translation.id)")
}
return RemoteMegaphoneModel(manifest: manifest, translation: translation)
}
}
return try await taskGroup.reduce(into: [], { $0.append($1) })
}
}
private func getUrlSession() -> OWSURLSessionProtocol {
signalService.urlSessionForUpdates2()
}
/// Fetch the manifests for the currently-active remote megaphones.
/// Manifests contain metadata about a megaphone, such as when it should be
/// shown and what actions it should expose. They do not contain any
/// user-visible content, such as strings.
private func fetchManifests(remainingRetries: UInt = 2) async throws -> [RemoteMegaphoneModel.Manifest] {
var remainingRetries = remainingRetries
while true {
do {
let response = try await getUrlSession().performRequest(
.manifestUrlPath,
method: .get
)
guard let responseJson = response.responseBodyJson else {
throw OWSAssertionError("Missing body JSON for manifest!")
}
return try RemoteMegaphoneModel.Manifest.parseFrom(responseJson: responseJson)
} catch where remainingRetries > 0 && error.isNetworkFailureOrTimeout {
Logger.warn("Retrying after failure: \(error)")
remainingRetries -= 1
continue
}
}
}
/// Fetch user-displayable localized strings for the given manifest. Will
/// attempt to fetch a translation matching the user's current locale,
/// falling back to English otherwise.
private func fetchTranslation(
forMegaphoneManifest manifest: RemoteMegaphoneModel.Manifest
) async throws -> RemoteMegaphoneModel.Translation {
let localeStrings: [String] = .possibleTranslationLocaleStrings
for (index, localeString) in localeStrings.enumerated() {
do {
var translation = try await fetchTranslation(forMegaphoneManifest: manifest, withLocaleString: localeString)
if let url = try await self.downloadImageIfNecessary(forTranslation: translation) {
translation.setImageLocalUrl(url)
}
return translation
} catch let error as OWSHTTPError where error.responseStatusCode == 404 && (index + 1) != localeStrings.endIndex {
// If this isn't the last locale & it's not found, try the next one.
continue
}
// If we hit a non-404 error, propagate it out immediately.
}
// We either return a value or throw an error in the loop as long as there
// is at least one locale.
throw OWSAssertionError("Unexpectedly found no locale strings!")
}
/// Fetch a translation for the given manifest, using the given locale
/// string. Retries automatically on network failure, if possible. May
/// fail with a 404, if no translation exists for the given locale string.
private func fetchTranslation(
forMegaphoneManifest manifest: RemoteMegaphoneModel.Manifest,
withLocaleString localeString: String,
remainingRetries: UInt = 2
) async throws -> RemoteMegaphoneModel.Translation {
var remainingRetries = remainingRetries
while true {
do {
guard let translationUrlPath: String = .translationUrlPath(
forManifest: manifest,
withLocaleString: localeString
) else {
throw OWSAssertionError("Failed to create translation URL path for manifest \(manifest.id)")
}
let response = try await getUrlSession().performRequest(translationUrlPath, method: .get)
guard let responseJson = response.responseBodyJson else {
throw OWSAssertionError("Missing body JSON for translation!")
}
return try RemoteMegaphoneModel.Translation.parseFrom(responseJson: responseJson)
} catch where remainingRetries > 0 && error.isNetworkFailureOrTimeout {
Logger.warn("Retrying after failure: \(error)")
remainingRetries -= 1
continue
}
}
}
/// Get a path to the local image file for this translation. Fetches the
/// image if necessary. Returns ``nil`` if this translation has no image.
private func downloadImageIfNecessary(
forTranslation translation: RemoteMegaphoneModel.Translation,
remainingRetries: UInt = 2
) async throws -> URL? {
guard let imageRemoteUrlPath = translation.imageRemoteUrlPath else {
return nil
}
guard let imageFileUrl: URL = .imageFilePath(forFetchedTranslation: translation) else {
throw OWSAssertionError("Failed to get image file path for translation with ID \(translation.id)")
}
var remainingRetries = remainingRetries
while !FileManager.default.fileExists(atPath: imageFileUrl.path) {
do {
let response = try await getUrlSession().performDownload(
imageRemoteUrlPath,
method: .get
)
do {
try FileManager.default.moveItem(
at: response.downloadUrl,
to: imageFileUrl
)
} catch let error {
throw OWSAssertionError("Failed to move downloaded image! \(error)")
}
break
} catch where remainingRetries > 0 && error.isNetworkFailureOrTimeout {
remainingRetries -= 1
continue
} catch let error as OWSHTTPError {
switch error.responseStatusCode {
case 404:
owsFailDebug("Unexpectedly got 404 while fetching remote megaphone image for ID \(translation.id)!")
return nil
case 500..<600:
owsFailDebug("Encountered server error with status \(error.responseStatusCode) while fetching remote megaphone image!")
return nil
default:
owsFailDebug("Unexpectedly got error status code \(error.responseStatusCode) while fetching remote megaphone image for ID \(translation.id)!")
throw error
}
}
}
return imageFileUrl
}
}
// MARK: URLs
private extension URL {
private static let imagesSubdirectory: String = "MegaphoneImages"
static func imageFilePath(forFetchedTranslation translation: RemoteMegaphoneModel.Translation) -> URL? {
let dirUrl = OWSFileSystem.appSharedDataDirectoryURL()
.appendingPathComponent(Self.imagesSubdirectory)
guard OWSFileSystem.ensureDirectoryExists(dirUrl.path) else {
return nil
}
return dirUrl.appendingPathComponent(translation.id)
}
}
private extension Array<String> {
/// A list of possible locale strings for which a translation may be
/// available, based on the user's current locale. Includes a fallback to
/// English.
static var possibleTranslationLocaleStrings: [String] {
var locales: [String] = []
if let langCode = Locale.current.languageCode {
locales.append(langCode)
if let regionCode = Locale.current.regionCode {
locales.append("\(langCode)_\(regionCode)")
}
}
// Always include English at the end, as a fallback. This translation
// should always exist.
return locales + ["en"]
}
}
private extension String {
/// The path at which remote megaphone manifests are listed.
static let manifestUrlPath = "dynamic/release-notes/release-notes-v2.json"
/// The path at which a translation may be found, for the given manifest
/// and locale string.
static func translationUrlPath(
forManifest manifest: RemoteMegaphoneModel.Manifest,
withLocaleString localeString: String
) -> String? {
"static/release-notes/\(manifest.id)/\(localeString).json"
.percentEncodedAsUrlPath
}
}
// MARK: - Parsing manifests
private extension RemoteMegaphoneModel.Manifest {
private static let megaphonesKey = "megaphones"
private static let uuidKey = "uuid"
private static let priorityKey = "priority"
private static let iosMinVersionKey = "iosMinVersion"
private static let countriesKey = "countries"
private static let dontShowBeforeEpochSecondsKey = "dontShowBeforeEpochSeconds"
private static let dontShowAfterEpochSecondsKey = "dontShowAfterEpochSeconds"
private static let showForNumberOfDaysKey = "showForNumberOfDays"
private static let conditionalIdKey = "conditionalId"
private static let primaryCtaIdKey = "primaryCtaId"
private static let primaryCtaDataKey = "primaryCtaData"
private static let secondaryCtaIdKey = "secondaryCtaId"
private static let secondaryCtaDataKey = "secondaryCtaData"
static func parseFrom(responseJson: Any?) throws -> [Self] {
guard let megaphonesArrayParser = ParamParser(responseObject: responseJson) else {
throw OWSAssertionError("Failed to create parser from response JSON!")
}
let individualMegaphones: [[String: Any]] = try megaphonesArrayParser.required(key: Self.megaphonesKey)
return try individualMegaphones.compactMap { megaphoneObject throws -> Self? in
guard let megaphoneParser = ParamParser(responseObject: megaphoneObject) else {
throw OWSAssertionError("Failed to create parser from individual megaphone JSON!")
}
guard let iosMinVersion: String = try megaphoneParser.optional(key: Self.iosMinVersionKey) else {
return nil
}
let uuid: String = try megaphoneParser.required(key: Self.uuidKey)
let priority: Int = try megaphoneParser.required(key: Self.priorityKey)
let countries: String = try megaphoneParser.required(key: Self.countriesKey)
let dontShowBeforeEpochSeconds: UInt64 = try megaphoneParser.required(key: Self.dontShowBeforeEpochSecondsKey)
let dontShowAfterEpochSeconds: UInt64 = try megaphoneParser.required(key: Self.dontShowAfterEpochSecondsKey)
let showForNumberOfDays: Int = try megaphoneParser.required(key: Self.showForNumberOfDaysKey)
let conditionalId: String? = try megaphoneParser.optional(key: Self.conditionalIdKey)
let primaryCtaId: String? = try megaphoneParser.optional(key: Self.primaryCtaIdKey)
let primaryCtaDataJson: [String: Any]? = try megaphoneParser.optional(key: Self.primaryCtaDataKey)
let secondaryCtaId: String? = try megaphoneParser.optional(key: Self.secondaryCtaIdKey)
let secondaryCtaDataJson: [String: Any]? = try megaphoneParser.optional(key: Self.secondaryCtaDataKey)
var conditionalCheck: ConditionalCheck?
if let conditionalId = conditionalId {
conditionalCheck = ConditionalCheck(fromConditionalId: conditionalId)
}
var primaryAction: Action?
if let primaryCtaId = primaryCtaId {
primaryAction = Action(fromActionId: primaryCtaId)
}
var primaryActionData: ActionData?
if let primaryCtaDataJson = primaryCtaDataJson {
primaryActionData = try ActionData.parse(fromJson: primaryCtaDataJson)
}
var secondaryAction: Action?
if let secondaryCtaId = secondaryCtaId {
secondaryAction = Action(fromActionId: secondaryCtaId)
}
var secondaryActionData: ActionData?
if let secondaryCtaDataJson = secondaryCtaDataJson {
secondaryActionData = try ActionData.parse(fromJson: secondaryCtaDataJson)
}
return RemoteMegaphoneModel.Manifest(
id: uuid,
priority: priority,
minAppVersion: iosMinVersion,
countries: countries,
dontShowBefore: dontShowBeforeEpochSeconds,
dontShowAfter: dontShowAfterEpochSeconds,
showForNumberOfDays: showForNumberOfDays,
conditionalCheck: conditionalCheck,
primaryAction: primaryAction,
primaryActionData: primaryActionData,
secondaryAction: secondaryAction,
secondaryActionData: secondaryActionData
)
}
}
}
// MARK: - Parsing translations
private extension RemoteMegaphoneModel.Translation {
private static let uuidKey = "uuid"
private static let imageUrlKey = "image"
private static let titleKey = "title"
private static let bodyKey = "body"
private static let primaryCtaTextKey = "primaryCtaText"
private static let secondaryCtaTextKey = "secondaryCtaText"
static func parseFrom(responseJson: Any?) throws -> Self {
guard let parser = ParamParser(responseObject: responseJson) else {
throw OWSAssertionError("Failed to create parser from response JSON!")
}
let uuid: String = try parser.required(key: Self.uuidKey)
let imageUrl: String? = try parser.optional(key: Self.imageUrlKey)
let title: String = try parser.required(key: Self.titleKey)
let body: String = try parser.required(key: Self.bodyKey)
let primaryCtaText: String? = try parser.optional(key: Self.primaryCtaTextKey)
let secondaryCtaText: String? = try parser.optional(key: Self.secondaryCtaTextKey)
guard uuid.isPermissibleAsFilename else {
throw OWSAssertionError("Translation had UUID that is illegal filename: \(uuid)")
}
return RemoteMegaphoneModel.Translation.makeWithoutLocalImage(
id: uuid,
title: title,
body: body,
imageRemoteUrlPath: imageUrl,
primaryActionText: primaryCtaText,
secondaryActionText: secondaryCtaText
)
}
}