Fetch remote announcements

This commit is contained in:
kate-signal 2026-05-21 11:37:50 -04:00 committed by GitHub
parent 543085bd26
commit ba2b662d37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1018 additions and 349 deletions

View File

@ -7,6 +7,16 @@
objects = {
/* Begin PBXBuildFile section */
040506F82F7EEF3B0078B769 /* RemoteReleaseNotesFetchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */; };
040506FA2F7EF4F50078B769 /* RemoteReleaseNotesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506F92F7EF4ED0078B769 /* RemoteReleaseNotesFetcher.swift */; };
040506FC2F7FE3DB0078B769 /* RemoteAnnouncementModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506FB2F7FE3D50078B769 /* RemoteAnnouncementModel.swift */; };
040506FE2F7FE9170078B769 /* RemoteAnnouncementFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506FD2F7FE9130078B769 /* RemoteAnnouncementFetcher.swift */; };
040507012F804C240078B769 /* RemoteReleaseNotesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506FF2F8049910078B769 /* RemoteReleaseNotesService.swift */; };
040507152F8063AB0078B769 /* RemoteReleaseNotesFetchingManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */; };
040507162F8063CE0078B769 /* RemoteReleaseNotesFetchingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */; };
040507172F8063E80078B769 /* RemoteMegaphoneFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D98DD85D28EE53B00089333E /* RemoteMegaphoneFetcher.swift */; };
040507182F8064190078B769 /* RemoteReleaseNotesFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506F92F7EF4ED0078B769 /* RemoteReleaseNotesFetcher.swift */; };
040507192F8416090078B769 /* RemoteAnnouncementFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 040506FD2F7FE9130078B769 /* RemoteAnnouncementFetcher.swift */; };
04127D912F23B3B000B4E95B /* CVCapsuleLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04127D902F23B3B000B4E95B /* CVCapsuleLabel.swift */; };
041A5F072E05B3F900FAED05 /* BackupEnablementMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 041A5F062E05B3F000FAED05 /* BackupEnablementMegaphone.swift */; };
041C24ED2DF782AF0065B685 /* OutgoingGroupUpdateMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9BC9C6428B7C00A0077D442 /* OutgoingGroupUpdateMessageTest.swift */; };
@ -4152,6 +4162,12 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetchingManager.swift; sourceTree = "<group>"; };
040506F92F7EF4ED0078B769 /* RemoteReleaseNotesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetcher.swift; sourceTree = "<group>"; };
040506FB2F7FE3D50078B769 /* RemoteAnnouncementModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAnnouncementModel.swift; sourceTree = "<group>"; };
040506FD2F7FE9130078B769 /* RemoteAnnouncementFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAnnouncementFetcher.swift; sourceTree = "<group>"; };
040506FF2F8049910078B769 /* RemoteReleaseNotesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesService.swift; sourceTree = "<group>"; };
040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetchingManagerTests.swift; sourceTree = "<group>"; };
04127D902F23B3B000B4E95B /* CVCapsuleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCapsuleLabel.swift; sourceTree = "<group>"; };
041A5F062E05B3F000FAED05 /* BackupEnablementMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementMegaphone.swift; sourceTree = "<group>"; };
041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementReminderMegaphoneTests.swift; sourceTree = "<group>"; };
@ -8454,6 +8470,14 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
040507132F80639B0078B769 /* RemoteReleaseNotes */ = {
isa = PBXGroup;
children = (
040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */,
);
path = RemoteReleaseNotes;
sourceTree = "<group>";
};
0436E4B12E5E2DC80011E125 /* Polls */ = {
isa = PBXGroup;
children = (
@ -11454,7 +11478,10 @@
children = (
88A505FE23DBAE640005C012 /* UserInterface */,
88A505F323DA16E10005C012 /* ExperienceUpgradeManager.swift */,
040506FD2F7FE9130078B769 /* RemoteAnnouncementFetcher.swift */,
D98DD85D28EE53B00089333E /* RemoteMegaphoneFetcher.swift */,
040506F92F7EF4ED0078B769 /* RemoteReleaseNotesFetcher.swift */,
040506F72F7EEF290078B769 /* RemoteReleaseNotesFetchingManager.swift */,
);
path = Megaphones;
sourceTree = "<group>";
@ -11651,6 +11678,7 @@
D99ABC712A3D0BAA0034CD3B /* QRCodes */,
50791B1B2D037A7800D747F8 /* RecipientPickers */,
661278052996BA6700A1D5A1 /* Registration */,
040507132F80639B0078B769 /* RemoteReleaseNotes */,
4C3EF8002109184A0007EBF7 /* SSKTests */,
D97046082E81D5B60034C05D /* Storage */,
E75DD3DC2810CD3500E32C36 /* subscriptions */,
@ -13356,7 +13384,9 @@
D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */,
F9C5CB5B289453B200548EEE /* ExperienceUpgradeFinder.swift */,
D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */,
040506FB2F7FE3D50078B769 /* RemoteAnnouncementModel.swift */,
D98DD85E28EE53B00089333E /* RemoteMegaphoneModel.swift */,
040506FF2F8049910078B769 /* RemoteReleaseNotesService.swift */,
);
path = Megaphones;
sourceTree = "<group>";
@ -18687,9 +18717,12 @@
F9E3006C299D76C3000323F8 /* RegistrationVerificationViewController.swift in Sources */,
F95D71A3299305C400ED3102 /* RegistrationViewUtil.swift in Sources */,
50EA40912E3A899F009CB839 /* RegistrationWebSocketManager.swift in Sources */,
040506FE2F7FE9170078B769 /* RemoteAnnouncementFetcher.swift in Sources */,
D997FA7628F8E3A2003C7B8B /* RemoteMegaphone.swift in Sources */,
509DC8DA2BCED88600375E86 /* RemoteMegaphoneFetcher.swift in Sources */,
55B753602D97304100CCC91C /* RemoteMuteToast.swift in Sources */,
040506FA2F7EF4F50078B769 /* RemoteReleaseNotesFetcher.swift in Sources */,
040506F82F7EEF3B0078B769 /* RemoteReleaseNotesFetchingManager.swift in Sources */,
D9E43C0D2CC194140001536E /* RemoteVideoView.swift in Sources */,
348433DF243CA94600C7F64A /* ReplaceAdminViewController.swift in Sources */,
F952C0A629C8DA5E00D93766 /* RequestAccountDataReportViewController.swift in Sources */,
@ -18868,6 +18901,11 @@
E16B440E2BBF242C00D2583E /* ReactionsModelTest.swift in Sources */,
661278082996BA8900A1D5A1 /* RegistrationCoordinatorTest.swift in Sources */,
6612780D2996BD0300A1D5A1 /* RegistrationCoordinatorTestShims.swift in Sources */,
040507192F8416090078B769 /* RemoteAnnouncementFetcher.swift in Sources */,
040507172F8063E80078B769 /* RemoteMegaphoneFetcher.swift in Sources */,
040507182F8064190078B769 /* RemoteReleaseNotesFetcher.swift in Sources */,
040507162F8063CE0078B769 /* RemoteReleaseNotesFetchingManager.swift in Sources */,
040507152F8063AB0078B769 /* RemoteReleaseNotesFetchingManagerTests.swift in Sources */,
F5C80FA22BE3F29F0028F76D /* RTCIceServerFetcherTest.swift in Sources */,
F963164B291AE06C00218FB7 /* ScrubbingLogFormatterTest.swift in Sources */,
505C2ED92997422D00C23FB2 /* SelfSignedIdentityTest.swift in Sources */,
@ -19765,9 +19803,11 @@
6691E7F22996E9BC0032A68A /* RegistrationSessionManagerMock.swift in Sources */,
6646573B2AC388C70099DE1C /* RegistrationStateChangeManager.swift in Sources */,
6646573D2AC3894D0099DE1C /* RegistrationStateChangeManagerImpl.swift in Sources */,
040506FC2F7FE3DB0078B769 /* RemoteAnnouncementModel.swift in Sources */,
F9C5CCB0289453B300548EEE /* RemoteAttestation.swift in Sources */,
F9C5CE17289453B400548EEE /* RemoteConfigManager.swift in Sources */,
D98DD86028EE53B00089333E /* RemoteMegaphoneModel.swift in Sources */,
040507012F804C240078B769 /* RemoteReleaseNotesService.swift in Sources */,
5063B41E2C5432A30041CA51 /* ResolvableValue.swift in Sources */,
502C69742B06F0A400012867 /* Result.swift in Sources */,
50C0203E2CA4A7A500BDC4EF /* Retry.swift in Sources */,

View File

@ -645,16 +645,16 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
let remoteMegaphoneFetcher = RemoteMegaphoneFetcher(
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
signalService: SSKEnvironment.shared.signalServiceRef,
let remoteReleaseNotesFetchingManager = RemoteReleaseNotesFetchingManager(
db: DependenciesBridge.shared.db,
remoteReleaseNotesService: DependenciesBridge.shared.remoteReleaseNotesService,
)
cron.schedulePeriodically(
uniqueKey: .fetchMegaphones,
approximateInterval: 3 * .day,
mustBeRegistered: false,
mustBeConnected: true,
operation: { try await remoteMegaphoneFetcher.syncRemoteMegaphones() },
operation: { try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes() },
)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {

View File

@ -0,0 +1,52 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import SignalServiceKit
/// Handles fetching and parsing remote announcements.
public class RemoteAnnouncementFetcher: RemoteReleaseNotesFetcher<RemoteAnnouncementModel.Manifest, RemoteAnnouncementModel.Translation> {
override func updatePersistedData(
withFetchedData fetchedTranslations: [(RemoteAnnouncementModel.Manifest, RemoteAnnouncementModel.Translation)],
transaction: DBWriteTransaction,
) {
// TODO: [KC] implement!
}
override func fetchTranslationAndImage(
forManifest manifest: RemoteAnnouncementModel.Manifest,
withLocaleString localeString: String,
) async throws -> RemoteAnnouncementModel.Translation {
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: {
guard
let translationUrlPath: String = .translationUrlPath(
forManifestId: manifest.id,
withLocaleString: localeString,
)
else {
throw OWSAssertionError("Failed to create translation URL path for manifest \(manifest.id)")
}
let translationParser = try await remoteReleaseNotesService.fetchTranslationParser(translationUrlPath: translationUrlPath)
let translation = try RemoteAnnouncementModel.Translation.parseFrom(parser: translationParser)
// TODO: [KC] May want to store whether we've downloaded media
let _ = try await self.downloadMediaIfNecessary(
mediaRemoteUrlPath: translation.mediaRemoteUrlPath,
mediaFileDirectory: RemoteAnnouncementModel.mediaDirectory,
translationId: translation.id,
)
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 translation
},
)
}
}

View File

@ -4,55 +4,16 @@
//
import Foundation
import SignalServiceKit
public import SignalServiceKit
/// Handles fetching and parsing remote megaphones.
class RemoteMegaphoneFetcher {
private let databaseStorage: SDSDatabaseStorage
private let signalService: any OWSSignalServiceProtocol
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.
func syncRemoteMegaphones() async throws {
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,
)
}
}
}
// MARK: - Persisted megaphones
private extension RemoteMegaphoneFetcher {
public class RemoteMegaphoneFetcher: RemoteReleaseNotesFetcher<RemoteMegaphoneModel.Manifest, RemoteMegaphoneModel.Translation> {
/// 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],
override func updatePersistedData(
withFetchedData fetchedTranslations: [(RemoteMegaphoneModel.Manifest, RemoteMegaphoneModel.Translation)],
transaction: DBWriteTransaction,
) {
// Get the current remote megaphones.
@ -68,7 +29,8 @@ private extension RemoteMegaphoneFetcher {
// 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 {
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)
@ -87,88 +49,12 @@ private extension RemoteMegaphoneFetcher {
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() async throws -> [RemoteMegaphoneModel.Manifest] {
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: {
Logger.info("Fetching remote megaphone manifests")
let response = try await getUrlSession().performRequest(
.manifestUrlPath,
method: .get,
)
guard let parser = response.responseBodyParamParser else {
throw OWSAssertionError("Missing or invalid body JSON for manifest!")
}
return try RemoteMegaphoneModel.Manifest.parseFrom(parser: parser)
},
)
}
/// 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)
translation.setHasImage(try await self.downloadImageIfNecessary(forTranslation: translation))
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,
override func fetchTranslationAndImage(
forManifest manifest: RemoteMegaphoneModel.Manifest,
withLocaleString localeString: String,
) async throws -> RemoteMegaphoneModel.Translation {
return try await Retry.performWithBackoff(
@ -177,236 +63,26 @@ private extension RemoteMegaphoneFetcher {
block: {
guard
let translationUrlPath: String = .translationUrlPath(
forManifest: manifest,
forManifestId: manifest.id,
withLocaleString: localeString,
)
else {
throw OWSAssertionError("Failed to create translation URL path for manifest \(manifest.id)")
}
Logger.info("Fetching remote megaphone translation")
let response = try await getUrlSession().performRequest(translationUrlPath, method: .get)
guard let parser = response.responseBodyParamParser else {
throw OWSAssertionError("Missing or invalid body JSON for translation!")
}
return try RemoteMegaphoneModel.Translation.parseFrom(parser: parser)
},
)
}
/// Downloads the image if necessary.
///
/// Doesn't perform any network requests if the image has already been
/// downloaded.
///
/// - Throws: If the image should be downloaded but can't be downloaded.
/// - Returns: Whether or not `translation` has an image.
private func downloadImageIfNecessary(
forTranslation translation: RemoteMegaphoneModel.Translation,
) async throws -> Bool {
guard let imageRemoteUrlPath = translation.imageRemoteUrlPath else {
return false
}
guard let imageFileUrl: URL = .imageFilePath(forFetchedTranslation: translation) else {
throw OWSAssertionError("Failed to get image file path for translation with ID \(translation.id)")
}
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: {
do {
if !FileManager.default.fileExists(atPath: imageFileUrl.path) {
Logger.info("Fetching remote megaphone image")
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)")
}
}
return true
} catch where error.httpStatusCode == 404 {
owsFailDebug("Unexpectedly got 404 while fetching remote megaphone image for ID \(translation.id)!")
return false
} catch let error as OWSHTTPError {
owsFailDebug("Unexpectedly got error status code \(error.responseStatusCode) while fetching remote megaphone image for ID \(translation.id)!")
throw error
let translationParser = try await remoteReleaseNotesService.fetchTranslationParser(translationUrlPath: translationUrlPath)
var translation = try RemoteMegaphoneModel.Translation.parseFrom(parser: translationParser)
translation.setHasImage(try await self.downloadMediaIfNecessary(
mediaRemoteUrlPath: translation.imageRemoteUrlPath,
mediaFileDirectory: RemoteMegaphoneModel.imagesDirectory,
translationId: translation.id,
))
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 translation
},
)
}
}
// MARK: URLs
private extension URL {
static func imageFilePath(forFetchedTranslation translation: RemoteMegaphoneModel.Translation) -> URL? {
let dirUrl = RemoteMegaphoneModel.imagesDirectory
guard OWSFileSystem.ensureDirectoryExists(dirUrl.path) else {
return nil
}
return dirUrl.appendingPathComponent(translation.imageLocalRelativePath)
}
}
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(parser megaphonesArrayParser: ParamParser) throws -> [Self] {
let individualMegaphones: [[String: Any]] = try megaphonesArrayParser.required(key: Self.megaphonesKey)
return try individualMegaphones.compactMap { megaphoneObject throws -> Self? in
let megaphoneParser = ParamParser(megaphoneObject)
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 {
conditionalCheck = ConditionalCheck(fromConditionalId: conditionalId)
}
var primaryAction: Action?
if let primaryCtaId {
primaryAction = Action(fromActionId: primaryCtaId)
}
var primaryActionData: ActionData?
if let primaryCtaDataJson {
primaryActionData = try ActionData.parse(fromJson: primaryCtaDataJson)
}
var secondaryAction: Action?
if let secondaryCtaId {
secondaryAction = Action(fromActionId: secondaryCtaId)
}
var secondaryActionData: ActionData?
if let 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(parser: ParamParser) throws -> Self {
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,
)
}
}

View File

@ -0,0 +1,149 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
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"]
}
}
extension String {
/// The path at which a translation may be found, for the given manifest
/// and locale string.
static func translationUrlPath(
forManifestId manifestId: String,
withLocaleString localeString: String,
) -> String? {
"static/release-notes/\(manifestId)/\(localeString).json"
.percentEncodedAsUrlPath
}
}
// MARK: URLs
extension URL {
static func mediaFilePath(dirUrl: URL, mediaLocalRelativePath: String) -> URL? {
guard OWSFileSystem.ensureDirectoryExists(dirUrl.path) else {
return nil
}
return dirUrl.appendingPathComponent(mediaLocalRelativePath)
}
}
public class RemoteReleaseNotesFetcher<ManifestType, TranslationType> {
let db: DB
let remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol
var fetchedTranslations: [(ManifestType, TranslationType)] = []
init(
db: DB,
remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol,
) {
self.db = db
self.remoteReleaseNotesService = remoteReleaseNotesService
}
func run(manifests: [ManifestType]) async throws {
fetchedTranslations = try await withThrowingTaskGroup(of: (ManifestType, TranslationType).self) { taskGroup in
for manifest in manifests {
taskGroup.addTask {
let translation = try await self.fetchTranslation(forManifest: manifest)
return (manifest, translation)
}
}
return try await taskGroup.reduce(into: [], { $0.append($1) })
}
await db.awaitableWrite { tx in
updatePersistedData(withFetchedData: fetchedTranslations, transaction: tx)
}
}
/// 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(
forManifest manifest: ManifestType,
) async throws -> TranslationType {
let localeStrings: [String] = .possibleTranslationLocaleStrings
for (index, localeString) in localeStrings.enumerated() {
do {
return try await fetchTranslationAndImage(forManifest: manifest, withLocaleString: localeString)
} 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!")
}
/// Downloads the image if necessary.
///
/// Doesn't perform any network requests if the image has already been
/// downloaded.
///
/// - Throws: If the image should be downloaded but can't be downloaded.
/// - Returns: Whether or not `translation` has an image.
func downloadMediaIfNecessary(
mediaRemoteUrlPath: String?,
mediaFileDirectory: URL,
translationId: String,
) async throws -> Bool {
guard let mediaRemoteUrlPath else {
return false
}
guard let mediaFileUrl: URL = .mediaFilePath(dirUrl: mediaFileDirectory, mediaLocalRelativePath: translationId) else {
throw OWSAssertionError("Failed to get image file path for translation with ID \(translationId)")
}
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: {
try await remoteReleaseNotesService.downloadMedia(
mediaRemoteUrlPath: mediaRemoteUrlPath,
mediaFileUrl: mediaFileUrl,
translationId: translationId,
)
},
)
}
func fetchTranslationAndImage(
forManifest manifest: ManifestType,
withLocaleString localeString: String,
) async throws -> TranslationType {
owsFail("Must override fetch")
}
func updatePersistedData(withFetchedData fetchedTranslations: [(ManifestType, TranslationType)], transaction: DBWriteTransaction) {
owsFail("Must override fetch")
}
}

View File

@ -0,0 +1,71 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
/// Handles fetching and parsing remote megaphones and release notes.
public class RemoteReleaseNotesFetchingManager {
private let remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol
private let remoteMegaphoneFetcher: RemoteMegaphoneFetcher
private let remoteAnnouncementFetcher: RemoteAnnouncementFetcher
init(
db: DB,
remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol,
) {
self.remoteReleaseNotesService = remoteReleaseNotesService
self.remoteMegaphoneFetcher = RemoteMegaphoneFetcher(
db: db,
remoteReleaseNotesService: remoteReleaseNotesService,
)
self.remoteAnnouncementFetcher = RemoteAnnouncementFetcher(
db: db,
remoteReleaseNotesService: remoteReleaseNotesService,
)
}
/// Fetch all remote release notes currently on the service and persist them
/// locally. Removes any locally-persisted remote release notes that are no
/// longer available remotely.
func syncRemoteReleaseNotes() async throws {
Logger.info("Beginning remote release notes fetch.")
let (megaphoneManifests, announcementManifests) = try await fetchManifests()
let megaphoneResult = await Result {
try await remoteMegaphoneFetcher.run(manifests: megaphoneManifests)
}
if case .failure(let error) = megaphoneResult {
Logger.error("megaphone fetch failed: \(error)")
}
if BuildFlags.ReleaseNotesChannel.announcementFetch {
let announcementResult = await Result {
try await remoteAnnouncementFetcher.run(manifests: announcementManifests)
}
if case .failure(let error) = announcementResult {
Logger.error("announcement fetch failed: \(error)")
}
}
}
/// 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() async throws -> ([RemoteMegaphoneModel.Manifest], [RemoteAnnouncementModel.Manifest]) {
return try await Retry.performWithBackoff(
maxAttempts: 3,
isRetryable: { $0.isNetworkFailureOrTimeout || $0.is5xxServiceResponse },
block: {
Logger.info("Fetching remote release notes manifests")
return try await remoteReleaseNotesService.fetchManifests()
},
)
}
}

View File

@ -0,0 +1,84 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import Testing
@testable import LibSignalClient
@testable import SignalServiceKit
@MainActor
struct RemoteReleaseNotesFetchingManagerTests {
private let db = InMemoryDB()
private let remoteReleaseNotesFetchingManager: RemoteReleaseNotesFetchingManager
init() {
remoteReleaseNotesFetchingManager = RemoteReleaseNotesFetchingManager(
db: db,
remoteReleaseNotesService: MockRemoteReleaseNotesService(),
)
}
@Test
func testRemoteMegaphoneFetch() async throws {
try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes()
db.read { tx in
ExperienceUpgrade.anyEnumerate(transaction: tx) { upgrade, _ in
switch upgrade.manifest {
case .remoteMegaphone(let megaphone):
#expect(megaphone.translation.title == "Donate Today")
#expect(megaphone.translation.body == "Support privacy by donating to Signal. We're counting on your support.")
#expect(megaphone.translation.secondaryActionText == "Not now")
#expect(megaphone.translation.primaryActionText == "Donate")
#expect(megaphone.translation.hasImage == false)
default:
#expect(Bool(false), "unexpected upgrade: \(upgrade)")
}
}
}
}
}
class MockRemoteReleaseNotesService: RemoteReleaseNotesServiceProtocol {
let uuid: String = UUID().uuidString
func fetchManifests() async throws -> ([RemoteMegaphoneModel.Manifest], [RemoteAnnouncementModel.Manifest]) {
return ([RemoteMegaphoneModel.Manifest(
id: uuid,
priority: 100,
minAppVersion: "6.1.0.17",
countries: "1:1000000",
dontShowBefore: 0,
dontShowAfter: UInt64(Date.distantFuture.timeIntervalSince1970),
showForNumberOfDays: 30,
conditionalCheck: RemoteMegaphoneModel.Manifest.ConditionalCheck(fromConditionalId: "standard_donate"),
primaryAction: RemoteMegaphoneModel.Manifest.Action(fromActionId: "primaryCtaId"),
primaryActionData: nil,
secondaryAction: RemoteMegaphoneModel.Manifest.Action(fromActionId: "snooze"),
secondaryActionData: try RemoteMegaphoneModel.Manifest.ActionData.parse(
fromJson: ["snoozeDurationDays": [UInt(5), UInt(7), UInt(100)]],
),
)], [])
}
func fetchTranslationParser(translationUrlPath: String) async throws -> ParamParser {
return ParamParser(
[
"image": "/static/release-notes/donate-heart.png",
"uuid": uuid,
"secondaryCtaText": "Not now",
"body": "Support privacy by donating to Signal. We're counting on your support.",
"primaryCtaText": "Donate",
"title": "Donate Today",
],
)
}
func downloadMedia(mediaRemoteUrlPath: String, mediaFileUrl: URL, translationId: String) async throws -> Bool {
return false
}
}

View File

@ -1679,6 +1679,8 @@ extension AppSetup.GlobalsContinuation {
tsAccountManager: tsAccountManager,
)
let remoteReleaseNotesService = RemoteReleaseNotesService(signalService: signalService)
let dependenciesBridge = DependenciesBridge(
accountAttributesUpdater: accountAttributesUpdater,
accountEntropyPoolManager: accountEntropyPoolManager,
@ -1797,6 +1799,7 @@ extension AppSetup.GlobalsContinuation {
recipientMerger: recipientMerger,
registrationSessionManager: registrationSessionManager,
registrationStateChangeManager: registrationStateChangeManager,
remoteReleaseNotesService: remoteReleaseNotesService,
searchableNameIndexer: searchableNameIndexer,
sentMessageTranscriptReceiver: sentMessageTranscriptReceiver,
signalProtocolStoreManager: signalProtocolStoreManager,

View File

@ -92,6 +92,10 @@ public enum BuildFlags {
}
public static let collapsingChatEvents = build <= .beta
public enum ReleaseNotesChannel {
public static let announcementFetch = build <= .dev
}
}
// MARK: -

View File

@ -164,6 +164,7 @@ public class DependenciesBridge {
public let recipientMerger: RecipientMerger
public let registrationSessionManager: RegistrationSessionManager
public let registrationStateChangeManager: RegistrationStateChangeManager
public let remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol
public let searchableNameIndexer: SearchableNameIndexer
public let sentMessageTranscriptReceiver: SentMessageTranscriptReceiver
public let signalProtocolStoreManager: SignalProtocolStoreManager
@ -307,6 +308,7 @@ public class DependenciesBridge {
recipientMerger: RecipientMerger,
registrationSessionManager: RegistrationSessionManager,
registrationStateChangeManager: RegistrationStateChangeManager,
remoteReleaseNotesService: any RemoteReleaseNotesServiceProtocol,
searchableNameIndexer: SearchableNameIndexer,
sentMessageTranscriptReceiver: SentMessageTranscriptReceiver,
signalProtocolStoreManager: SignalProtocolStoreManager,
@ -449,6 +451,7 @@ public class DependenciesBridge {
self.recipientMerger = recipientMerger
self.registrationSessionManager = registrationSessionManager
self.registrationStateChangeManager = registrationStateChangeManager
self.remoteReleaseNotesService = remoteReleaseNotesService
self.searchableNameIndexer = searchableNameIndexer
self.sentMessageTranscriptReceiver = sentMessageTranscriptReceiver
self.signalProtocolStoreManager = signalProtocolStoreManager

View File

@ -0,0 +1,380 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public struct RemoteAnnouncementModel: 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: - Codable
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
manifest = try container.decode(RemoteAnnouncementModel.Manifest.self, forKey: .manifest)
translation = try container.decode(RemoteAnnouncementModel.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 RemoteAnnouncementModel {
/// Represents metadata about this announcement
public struct Manifest: Codable {
/// A unique ID for this manifest.
public let id: String
/// 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?
/// Represents an external web link that will be embedded in message
fileprivate(set) var link: URL?
/// Represents an action to be performed in response
public fileprivate(set) var action: Action?
public init(
id: String,
minAppVersion: String,
countries: String?,
link: URL?,
action: Action?,
) {
self.id = id
self.minAppVersion = minAppVersion
self.countries = countries
self.link = link
self.action = action
}
// MARK: Codable
public enum CodingKeys: String, CodingKey {
case id
case minAppVersion
case countries
case link
case action
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
minAppVersion = try container.decode(String.self, forKey: .minAppVersion)
countries = try container.decode(String.self, forKey: .countries)
link = try container.decode(URL.self, forKey: .link)
action = try container.decodeIfPresent(Action.self, forKey: .action)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(minAppVersion, forKey: .minAppVersion)
if let countries {
try container.encode(countries, forKey: .countries)
}
if let link {
try container.encode(link, forKey: .link)
}
if let action {
try container.encode(action, forKey: .action)
}
}
}
}
// MARK: - Action
extension RemoteAnnouncementModel.Manifest {
/// Identifies a known action to take in response to a known user
/// interaction with this release note.
public enum Action: Codable {
case unrecognized(actionId: String)
var actionId: String {
switch self {
case .unrecognized(let conditionalId):
return conditionalId
}
}
public init(fromActionId actionId: String) {
self = {
switch actionId {
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: - Translation
extension RemoteAnnouncementModel {
public static let mediaDirectory: URL = {
let mediaSubdirectory: String = "AnnouncementMedia"
return OWSFileSystem.appSharedDataDirectoryURL().appendingPathComponent(mediaSubdirectory)
}()
/// Represents a localized, user-presentable description of this announcement.
public struct Translation: Codable {
/// A unique ID for the announcement 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 announcement.
public fileprivate(set) var title: String
/// Localized body for this announcement.
public fileprivate(set) var body: String
/// Path to a remote media asset for this announcement.
public let mediaRemoteUrlPath: String?
/// Height and width of media to be presented
public let mediaSize: CGSize?
/// mime type of media to be presented
public let mediaMimeType: String?
/// Localized link text for this announcement
public let linkText: String?
/// Localized text to display on the call-to-action when this
/// announcement is presented.
public let callToActionText: String?
public init(
id: String,
title: String,
body: String,
mediaRemoteUrlPath: String?,
mediaSize: CGSize?,
mediaMimeType: String?,
linkText: String?,
callToActionText: String?,
) {
self.id = id
self.title = title
self.body = body
self.mediaRemoteUrlPath = mediaRemoteUrlPath
self.mediaSize = mediaSize
self.mediaMimeType = mediaMimeType
self.linkText = linkText
self.callToActionText = callToActionText
}
// MARK: Codable
public enum CodingKeys: String, CodingKey {
case id
case title
case body
case mediaRemoteUrlPath
case mediaSize
case mediaMimeType
case linkText
case callToActionText
}
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)
mediaRemoteUrlPath = try container.decodeIfPresent(String.self, forKey: .mediaRemoteUrlPath)
mediaSize = try container.decodeIfPresent(CGSize.self, forKey: .mediaSize)
mediaMimeType = try container.decodeIfPresent(String.self, forKey: .mediaMimeType)
linkText = try container.decodeIfPresent(String.self, forKey: .linkText)
callToActionText = try container.decodeIfPresent(String.self, forKey: .callToActionText)
}
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 mediaRemoteUrlPath {
try container.encode(mediaRemoteUrlPath, forKey: .mediaRemoteUrlPath)
}
if let mediaSize {
try container.encode(mediaSize, forKey: .mediaSize)
}
if let mediaMimeType {
try container.encode(mediaMimeType, forKey: .mediaMimeType)
}
if let linkText {
try container.encode(linkText, forKey: .linkText)
}
if let callToActionText {
try container.encode(callToActionText, forKey: .callToActionText)
}
}
}
}
// MARK: - Parsing manifests
public extension RemoteAnnouncementModel.Manifest {
private static let announcementsKey = "announcements"
private static let uuidKey = "uuid"
private static let countriesKey = "countries"
private static let iosMinVersionKey = "iosMinVersion"
private static let link = "link"
private static let ctaIdKey = "ctaId"
private static let includeBoostMessage = "includeBoostMessage"
static func parseFrom(parser announcementArrayParser: ParamParser) throws -> [Self] {
let individualAnnouncements: [[String: Any]] = try announcementArrayParser.required(key: Self.announcementsKey)
return try individualAnnouncements.compactMap { announcementObject throws -> Self? in
let announcementParser = ParamParser(announcementObject)
guard let iosMinVersion: String = try announcementParser.optional(key: Self.iosMinVersionKey) else {
return nil
}
let uuid: String = try announcementParser.required(key: Self.uuidKey)
// TODO: [KC] If countries is provided, perform "country code check"
let countries: String? = try announcementParser.optional(key: Self.countriesKey)
let link: String? = try announcementParser.optional(key: Self.link)
var linkUrl: URL?
if let link {
linkUrl = URL(string: link)
}
let ctaId: String? = try announcementParser.optional(key: Self.ctaIdKey)
var action: Action?
if let ctaId {
action = Action(fromActionId: ctaId)
}
return RemoteAnnouncementModel.Manifest(
id: uuid,
minAppVersion: iosMinVersion,
countries: countries,
link: linkUrl,
action: action,
)
}
}
}
// MARK: - Parsing translations
public extension RemoteAnnouncementModel.Translation {
private static let uuidKey = "uuid"
private static let mediaHeightKey = "mediaHeight"
private static let mediaWidthKey = "mediaWidth"
private static let mediaKey = "media"
private static let mediaContentTypeKey = "mediaContentType"
private static let titleKey = "title"
private static let bodyKey = "body"
private static let linkTextKey = "linkText"
private static let ctaTextKey = "callToActionText"
static func parseFrom(parser: ParamParser) throws -> Self {
let uuid: String = try parser.required(key: Self.uuidKey)
let mediaHeightString: String? = try parser.optional(key: Self.mediaHeightKey)
let mediaWidthString: String? = try parser.optional(key: Self.mediaWidthKey)
var mediaSize: CGSize?
if
let mediaWidthString,
let mediaHeightString,
let mediaWidth = Float(mediaWidthString),
let mediaHeight = Float(mediaHeightString)
{
mediaSize = CGSize(width: CGFloat(mediaWidth), height: CGFloat(mediaHeight))
}
let mediaUrl: String? = try parser.optional(key: Self.mediaKey)
let mediaContentType: String? = try parser.optional(key: Self.mediaContentTypeKey)
let title: String = try parser.required(key: Self.titleKey)
let body: String = try parser.required(key: Self.bodyKey)
let linkText: String? = try parser.optional(key: Self.linkTextKey)
let ctaText: String? = try parser.optional(key: Self.ctaTextKey)
// TODO: [KC] parse and handle bodyRanges
return RemoteAnnouncementModel.Translation(
id: uuid,
title: title,
body: body,
mediaRemoteUrlPath: mediaUrl,
mediaSize: mediaSize,
mediaMimeType: mediaContentType,
linkText: linkText,
callToActionText: ctaText,
)
}
}

View File

@ -537,3 +537,119 @@ extension RemoteMegaphoneModel {
}
}
}
// MARK: - Parsing manifests
public 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(parser megaphonesArrayParser: ParamParser) throws -> [Self] {
let individualMegaphones: [[String: Any]] = try megaphonesArrayParser.required(key: Self.megaphonesKey)
return try individualMegaphones.compactMap { megaphoneObject throws -> Self? in
let megaphoneParser = ParamParser(megaphoneObject)
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 {
conditionalCheck = ConditionalCheck(fromConditionalId: conditionalId)
}
var primaryAction: Action?
if let primaryCtaId {
primaryAction = Action(fromActionId: primaryCtaId)
}
var primaryActionData: ActionData?
if let primaryCtaDataJson {
primaryActionData = try ActionData.parse(fromJson: primaryCtaDataJson)
}
var secondaryAction: Action?
if let secondaryCtaId {
secondaryAction = Action(fromActionId: secondaryCtaId)
}
var secondaryActionData: ActionData?
if let 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
public 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(parser: ParamParser) throws -> Self {
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,
)
}
}

View File

@ -0,0 +1,91 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
private extension String {
/// The path at which remote megaphone manifests are listed.
static let manifestUrlPath = "dynamic/release-notes/release-notes-v2.json"
}
public protocol RemoteReleaseNotesServiceProtocol {
/// Fetches release-notes manifest from manifestUrlPath
func fetchManifests() async throws -> ([RemoteMegaphoneModel.Manifest], [RemoteAnnouncementModel.Manifest])
/// Fetches release-notes for a specific translation and manifest Id
func fetchTranslationParser(translationUrlPath: String) async throws -> ParamParser
/// Downloads media included in a release notes manifest
func downloadMedia(
mediaRemoteUrlPath: String,
mediaFileUrl: URL,
translationId: String,
) async throws -> Bool
}
class RemoteReleaseNotesService: RemoteReleaseNotesServiceProtocol {
let signalService: any OWSSignalServiceProtocol
init(signalService: any OWSSignalServiceProtocol) {
self.signalService = signalService
}
func getUrlSession() -> OWSURLSessionProtocol {
signalService.urlSessionForUpdates2()
}
func fetchManifests() async throws -> ([RemoteMegaphoneModel.Manifest], [RemoteAnnouncementModel.Manifest]) {
let response = try await getUrlSession().performRequest(
.manifestUrlPath,
method: .get,
)
guard let parser = response.responseBodyParamParser else {
throw OWSAssertionError("Missing or invalid body JSON for manifest!")
}
return try (RemoteMegaphoneModel.Manifest.parseFrom(parser: parser), RemoteAnnouncementModel.Manifest.parseFrom(parser: parser))
}
func fetchTranslationParser(translationUrlPath: String) async throws -> ParamParser {
Logger.info("Fetching remote megaphone translation")
let response = try await getUrlSession().performRequest(translationUrlPath, method: .get)
guard let parser = response.responseBodyParamParser else {
throw OWSAssertionError("Missing or invalid body JSON for translation!")
}
return parser
}
func downloadMedia(
mediaRemoteUrlPath: String,
mediaFileUrl: URL,
translationId: String,
) async throws -> Bool {
do {
if !FileManager.default.fileExists(atPath: mediaFileUrl.path) {
Logger.info("Fetching remote release notes image")
let response = try await getUrlSession().performDownload(
mediaRemoteUrlPath,
method: .get,
)
do {
try FileManager.default.moveItem(
at: response.downloadUrl,
to: mediaFileUrl,
)
} catch let error {
throw OWSAssertionError("Failed to move downloaded image! \(error)")
}
}
return true
} catch where error.httpStatusCode == 404 {
owsFailDebug("Unexpectedly got 404 while fetching remote megaphone image for ID \(translationId)!")
return false
} catch let error as OWSHTTPError {
owsFailDebug("Unexpectedly got error status code \(error.responseStatusCode) while fetching remote megaphone image for ID \(translationId)!")
throw error
}
}
}