diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 0c70e4f273..69a9ae9733 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -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 = ""; }; + 040506F92F7EF4ED0078B769 /* RemoteReleaseNotesFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetcher.swift; sourceTree = ""; }; + 040506FB2F7FE3D50078B769 /* RemoteAnnouncementModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAnnouncementModel.swift; sourceTree = ""; }; + 040506FD2F7FE9130078B769 /* RemoteAnnouncementFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAnnouncementFetcher.swift; sourceTree = ""; }; + 040506FF2F8049910078B769 /* RemoteReleaseNotesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesService.swift; sourceTree = ""; }; + 040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReleaseNotesFetchingManagerTests.swift; sourceTree = ""; }; 04127D902F23B3B000B4E95B /* CVCapsuleLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVCapsuleLabel.swift; sourceTree = ""; }; 041A5F062E05B3F000FAED05 /* BackupEnablementMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementMegaphone.swift; sourceTree = ""; }; 041A5F082E05D3AC00FAED05 /* BackupEnablementReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEnablementReminderMegaphoneTests.swift; sourceTree = ""; }; @@ -8454,6 +8470,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 040507132F80639B0078B769 /* RemoteReleaseNotes */ = { + isa = PBXGroup; + children = ( + 040507142F8063A40078B769 /* RemoteReleaseNotesFetchingManagerTests.swift */, + ); + path = RemoteReleaseNotes; + sourceTree = ""; + }; 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 = ""; @@ -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 = ""; @@ -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 */, diff --git a/Signal/AppLaunch/AppDelegate.swift b/Signal/AppLaunch/AppDelegate.swift index c261914772..0baab806a5 100644 --- a/Signal/AppLaunch/AppDelegate.swift +++ b/Signal/AppLaunch/AppDelegate.swift @@ -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 { diff --git a/Signal/Megaphones/RemoteAnnouncementFetcher.swift b/Signal/Megaphones/RemoteAnnouncementFetcher.swift new file mode 100644 index 0000000000..8edc843e18 --- /dev/null +++ b/Signal/Megaphones/RemoteAnnouncementFetcher.swift @@ -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 { + 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 + }, + ) + } +} diff --git a/Signal/Megaphones/RemoteMegaphoneFetcher.swift b/Signal/Megaphones/RemoteMegaphoneFetcher.swift index 481d9082bc..8bf155ff79 100644 --- a/Signal/Megaphones/RemoteMegaphoneFetcher.swift +++ b/Signal/Megaphones/RemoteMegaphoneFetcher.swift @@ -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 { /// 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 { - /// 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, - ) - } -} diff --git a/Signal/Megaphones/RemoteReleaseNotesFetcher.swift b/Signal/Megaphones/RemoteReleaseNotesFetcher.swift new file mode 100644 index 0000000000..94520744b7 --- /dev/null +++ b/Signal/Megaphones/RemoteReleaseNotesFetcher.swift @@ -0,0 +1,149 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import Foundation +import SignalServiceKit + +private extension Array { + /// 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 { + 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") + } +} diff --git a/Signal/Megaphones/RemoteReleaseNotesFetchingManager.swift b/Signal/Megaphones/RemoteReleaseNotesFetchingManager.swift new file mode 100644 index 0000000000..64fa6135ed --- /dev/null +++ b/Signal/Megaphones/RemoteReleaseNotesFetchingManager.swift @@ -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() + }, + ) + } +} diff --git a/Signal/test/RemoteReleaseNotes/RemoteReleaseNotesFetchingManagerTests.swift b/Signal/test/RemoteReleaseNotes/RemoteReleaseNotesFetchingManagerTests.swift new file mode 100644 index 0000000000..f11e696261 --- /dev/null +++ b/Signal/test/RemoteReleaseNotes/RemoteReleaseNotesFetchingManagerTests.swift @@ -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 + } +} diff --git a/SignalServiceKit/Environment/AppSetup.swift b/SignalServiceKit/Environment/AppSetup.swift index da059d7824..ab1bb58332 100644 --- a/SignalServiceKit/Environment/AppSetup.swift +++ b/SignalServiceKit/Environment/AppSetup.swift @@ -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, diff --git a/SignalServiceKit/Environment/BuildFlags.swift b/SignalServiceKit/Environment/BuildFlags.swift index 196aa48ae5..a19ff07525 100644 --- a/SignalServiceKit/Environment/BuildFlags.swift +++ b/SignalServiceKit/Environment/BuildFlags.swift @@ -92,6 +92,10 @@ public enum BuildFlags { } public static let collapsingChatEvents = build <= .beta + + public enum ReleaseNotesChannel { + public static let announcementFetch = build <= .dev + } } // MARK: - diff --git a/SignalServiceKit/Environment/DependenciesBridge.swift b/SignalServiceKit/Environment/DependenciesBridge.swift index 2e136da345..3dffed98f0 100644 --- a/SignalServiceKit/Environment/DependenciesBridge.swift +++ b/SignalServiceKit/Environment/DependenciesBridge.swift @@ -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 diff --git a/SignalServiceKit/Megaphones/RemoteAnnouncementModel.swift b/SignalServiceKit/Megaphones/RemoteAnnouncementModel.swift new file mode 100644 index 0000000000..2382143268 --- /dev/null +++ b/SignalServiceKit/Megaphones/RemoteAnnouncementModel.swift @@ -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 `:` 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, + ) + } +} diff --git a/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift b/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift index 52905596b7..61ac0bdf7d 100644 --- a/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift +++ b/SignalServiceKit/Megaphones/RemoteMegaphoneModel.swift @@ -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, + ) + } +} diff --git a/SignalServiceKit/Megaphones/RemoteReleaseNotesService.swift b/SignalServiceKit/Megaphones/RemoteReleaseNotesService.swift new file mode 100644 index 0000000000..357d1e00ec --- /dev/null +++ b/SignalServiceKit/Megaphones/RemoteReleaseNotesService.swift @@ -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 + } + } +}