Signal-iOS/Signal/Calls/CallingAssetsFetcher.swift

186 lines
5.9 KiB
Swift

//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import SignalServiceKit
final class CallingAssetsFetcher {
private let logger: PrefixedLogger
private let signalService: OWSSignalServiceProtocol
init(
signalService: OWSSignalServiceProtocol,
) {
self.logger = PrefixedLogger(prefix: "[CallingAssets]")
self.signalService = signalService
}
// MARK: -
func fetchLocalAssets(ignore: Set<CallingAssetManifestEntry>) -> [(CallingAssetManifestEntry, Data)] {
return CALLING_ASSET_MANIFEST.compactMap { manifestEntry in
if ignore.contains(manifestEntry) {
return nil
}
if let data = fetchFileAsset(entry: manifestEntry) {
return (manifestEntry, data)
}
return nil
}
}
func cleanupStaleAssets() throws {
guard OWSFileSystem.fileOrFolderExists(url: assetsDirectory) else {
return
}
let baseUrl = assetsDirectory
let files: [String]
do {
files = try FileManager.default.contentsOfDirectory(atPath: baseUrl.path)
} catch {
owsFailDebug("Failed to read contents of calling assets directory! \(error)", logger: logger)
throw error
}
for fileName in files {
let fileUrl = baseUrl.appendingPathComponent(fileName)
guard FileManager.default.isDeletableFile(atPath: fileUrl.path) else {
continue
}
if CALLING_ASSET_MANIFEST_MAP[fileName] == nil {
do {
try FileManager.default.removeItem(at: fileUrl)
logger.info("Deleted stale calling asset file: \(fileUrl.path)")
} catch {
owsFailDebug("Failed to delete stale calling asset! \(error)", logger: logger)
throw error
}
}
}
}
// MARK: -
func fetchMissingRemoteAssets() async -> [Result<Void, Error>] {
return await withThrowingTaskGroup(of: Void.self) { taskGroup in
for entry in CALLING_ASSET_MANIFEST {
if hasFileAsset(entry: entry) {
continue
}
taskGroup.addTask { [self] in
do {
try await fetchRemoteAsset(entry: entry)
logger.info("Fetched remote calling asset: \(entry.name)")
} catch let error {
logger.warn("Failed to fetch calling asset: \(entry.name)! \(error)")
throw error
}
}
}
var results: [Result<Void, Error>] = []
while let result = await taskGroup.nextResult() {
results.append(result)
}
return results
}
}
private func fetchRemoteAsset(entry: CallingAssetManifestEntry) async throws {
logger.info("Fetching remote asset: \(entry.name)")
let response = try await signalService.urlSessionForUpdates2().performDownload(
entry.path,
method: .get,
)
guard let content = FileManager.default.contents(atPath: response.downloadUrl.path) else {
throw OWSAssertionError("Missing downloaded calling asset file: \(entry.name)", logger: logger)
}
guard
content.count == entry.size,
SHA512.hash(data: content) == entry.digest
else {
throw OWSAssertionError("Downloaded calling asset file has unexpected size/hash: \(entry.name)", logger: logger)
}
guard let assetFileUrl = assetFileUrl(forAssetEntry: entry) else {
throw OWSAssertionError("Missing asset file URL for entry: \(entry.name)", logger: logger)
}
do {
try FileManager.default.moveItem(
at: response.downloadUrl,
to: assetFileUrl,
)
} catch let error {
throw OWSAssertionError("Failed to move downloaded calling asset! \(error)", logger: logger)
}
}
// MARK: - Filesystem Interaction
private let assetsDirectory: URL = {
let assetsSubdirectory: String = "CallingAssets"
return OWSFileSystem.appSharedDataDirectoryURL().appendingPathComponent(assetsSubdirectory)
}()
private func assetFileUrl(forAssetEntry entry: CallingAssetManifestEntry) -> URL? {
guard OWSFileSystem.ensureDirectoryExists(assetsDirectory.path) else {
return nil
}
return assetsDirectory.appendingPathComponent(entry.name)
}
private func hasFileAsset(entry: CallingAssetManifestEntry) -> Bool {
guard let url = assetFileUrl(forAssetEntry: entry) else {
return false
}
return FileManager.default.fileExists(atPath: url.path)
}
private func fetchFileAsset(entry: CallingAssetManifestEntry) -> Data? {
guard let url = assetFileUrl(forAssetEntry: entry) else {
return nil
}
return FileManager.default.contents(atPath: url.path)
}
}
// MARK: - Asset Manifest
struct CallingAssetManifestEntry: Hashable {
let assetGroup: String
let name: String
let digest: Data
let path: String
let size: UInt64
}
private let CALLING_ASSET_MANIFEST: Array<CallingAssetManifestEntry> = [
CallingAssetManifestEntry(
assetGroup: "opus-dred",
name: "calling-dred_weights-1_6_1-f4aed08a.bin",
digest: Data(base64Encoded: "sdfpdb/u3wiTfBr2s0gx1LJX6jii4tquyax/UBThTGWTEXyOCSKjYmYV+9tKQZcO+Q1B1ReoGSW3VbvzeMGKaQ==")!,
path: "static/android/calling/deep_plc-dred_weights-1_6_1-f4aed08a.bin",
size: 1998208,
),
]
private let CALLING_ASSET_MANIFEST_MAP: [String: CallingAssetManifestEntry] = Dictionary(
uniqueKeysWithValues: CALLING_ASSET_MANIFEST.map { ($0.name, $0) },
)