Fetch remote announcements
This commit is contained in:
parent
543085bd26
commit
ba2b662d37
@ -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 */,
|
||||
|
||||
@ -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 {
|
||||
|
||||
52
Signal/Megaphones/RemoteAnnouncementFetcher.swift
Normal file
52
Signal/Megaphones/RemoteAnnouncementFetcher.swift
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
149
Signal/Megaphones/RemoteReleaseNotesFetcher.swift
Normal file
149
Signal/Megaphones/RemoteReleaseNotesFetcher.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
71
Signal/Megaphones/RemoteReleaseNotesFetchingManager.swift
Normal file
71
Signal/Megaphones/RemoteReleaseNotesFetchingManager.swift
Normal 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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -92,6 +92,10 @@ public enum BuildFlags {
|
||||
}
|
||||
|
||||
public static let collapsingChatEvents = build <= .beta
|
||||
|
||||
public enum ReleaseNotesChannel {
|
||||
public static let announcementFetch = build <= .dev
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@ -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
|
||||
|
||||
380
SignalServiceKit/Megaphones/RemoteAnnouncementModel.swift
Normal file
380
SignalServiceKit/Megaphones/RemoteAnnouncementModel.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
91
SignalServiceKit/Megaphones/RemoteReleaseNotesService.swift
Normal file
91
SignalServiceKit/Megaphones/RemoteReleaseNotesService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user