diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index 45713940b..610eb1a86 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -355,8 +355,6 @@ struct NativeEmojiRemoteImage: View { let fallbackColor: UIColor @State private var phase: Phase = .loading - private static let maxDownloadBytes = 10 * 1024 * 1024 - private static let maxThumbnailPixels = 84 private enum Phase { case loading @@ -385,46 +383,148 @@ struct NativeEmojiRemoteImage: View { for (name, value) in requestHeaders { request.setValue(value, forHTTPHeaderField: name) } - let (bytes, response) = try await URLSession.shared.bytes(for: request) - guard - let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) - else { - phase = .failure - return - } - if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), - let byteCount = Int(contentLength), - byteCount > Self.maxDownloadBytes - { - phase = .failure - return - } - var data = Data() - let expected = httpResponse.expectedContentLength - if expected > 0 { - data.reserveCapacity( - Int(min(expected, Int64(Self.maxDownloadBytes))) - ) - } - for try await byte in bytes { - guard data.count < Self.maxDownloadBytes else { - phase = .failure - return - } - data.append(byte) - } - guard let image = Self.thumbnail(from: data) else { - phase = .failure - return - } - phase = .success(image) + phase = .success( + try await NativeEmojiRemoteImageLoader.shared.image(for: request) + ) } catch { if !Task.isCancelled { phase = .failure } } } } + private var requestIdentity: String { + url.absoluteString + } +} + +enum NativeEmojiRemoteImageError: Error { + case invalidResponse + case responseTooLarge + case invalidImage +} + +actor NativeEmojiRemoteImageLoader { + typealias Downloader = (URLRequest) async throws -> UIImage + + static let shared = NativeEmojiRemoteImageLoader() + static let defaultMaximumConcurrentDownloads = 4 + + private static let maximumDownloadBytes = 10 * 1024 * 1024 + private static let maximumThumbnailPixels = 84 + private static let defaultCacheByteLimit = 8 * 1024 * 1024 + + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private let maximumConcurrentDownloads: Int + private let downloader: Downloader + private let cache = NSCache() + private var activeDownloadCount = 0 + private var waiters: [Waiter] = [] + + init( + maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads, + cacheByteLimit: Int = defaultCacheByteLimit, + downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download + ) { + precondition(maximumConcurrentDownloads > 0) + precondition(cacheByteLimit >= 0) + self.maximumConcurrentDownloads = maximumConcurrentDownloads + self.downloader = downloader + cache.totalCostLimit = cacheByteLimit + } + + func image(for request: URLRequest) async throws -> UIImage { + let cacheKey = request as NSURLRequest + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + try await acquireDownloadSlot() + defer { releaseDownloadSlot() } + + try Task.checkCancellation() + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + let image = try await downloader(request) + cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image)) + return image + } + + private func acquireDownloadSlot() async throws { + try Task.checkCancellation() + guard activeDownloadCount >= maximumConcurrentDownloads else { + activeDownloadCount += 1 + return + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + private func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + private func releaseDownloadSlot() { + while !waiters.isEmpty { + let waiter = waiters.removeFirst() + waiter.continuation.resume() + return + } + activeDownloadCount -= 1 + } + + private static func download(_ request: URLRequest) async throws -> UIImage { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + throw NativeEmojiRemoteImageError.invalidResponse + } + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let byteCount = Int(contentLength), + byteCount > maximumDownloadBytes + { + throw NativeEmojiRemoteImageError.responseTooLarge + } + + var data = Data() + let expected = httpResponse.expectedContentLength + if expected > 0 { + data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes)))) + } + for try await byte in bytes { + guard data.count < maximumDownloadBytes else { + throw NativeEmojiRemoteImageError.responseTooLarge + } + data.append(byte) + } + try Task.checkCancellation() + guard let image = thumbnail(from: data) else { + throw NativeEmojiRemoteImageError.invalidImage + } + return image + } + private static func thumbnail(from data: Data) -> UIImage? { guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil @@ -432,7 +532,7 @@ struct NativeEmojiRemoteImage: View { let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels, + kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels, kCGImageSourceShouldCacheImmediately: true, ] guard @@ -447,7 +547,11 @@ struct NativeEmojiRemoteImage: View { return UIImage(cgImage: image) } - private var requestIdentity: String { - url.absoluteString + private static func cacheCost(for image: UIImage) -> Int { + guard let cgImage = image.cgImage else { return 0 } + let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow( + by: cgImage.height + ) + return overflow ? Int.max : cost } } diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 5f3b45f36..f856ae4ff 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -456,6 +456,47 @@ class RunnerTests: XCTestCase { ) } + func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { + let maximumConcurrentDownloads = 3 + let probe = NativeEmojiDownloadProbe() + let loader = NativeEmojiRemoteImageLoader( + maximumConcurrentDownloads: maximumConcurrentDownloads, + cacheByteLimit: 0 + ) { _ in + await probe.holdDownload() + return UIImage() + } + let tasks = (0..<8).map { index in + Task { + try await loader.image( + for: URLRequest( + url: try XCTUnwrap(URL(string: "https://example.com/\(index).png")) + ) + ) + } + } + + await probe.waitUntilStarted(maximumConcurrentDownloads) + try await Task.sleep(nanoseconds: 50_000_000) + var snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.started, maximumConcurrentDownloads) + XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads) + + for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count { + await probe.releaseOne() + await probe.waitUntilStarted(expectedStarted) + } + await probe.releaseAll() + for task in tasks { + _ = try await task.value + } + + snapshot = await probe.snapshot() + XCTAssertEqual(snapshot.started, tasks.count) + XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads) + XCTAssertEqual(snapshot.active, 0) + } + private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage { let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3)) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) @@ -683,3 +724,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16 | UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3]) } + +private actor NativeEmojiDownloadProbe { + private struct MilestoneWaiter { + let count: Int + let continuation: CheckedContinuation + } + + private var active = 0 + private var peakActive = 0 + private var started = 0 + private var releaseContinuations: [CheckedContinuation] = [] + private var milestoneWaiters: [MilestoneWaiter] = [] + + func holdDownload() async { + active += 1 + started += 1 + peakActive = max(peakActive, active) + resumeReachedMilestones() + await withCheckedContinuation { continuation in + releaseContinuations.append(continuation) + } + active -= 1 + } + + func waitUntilStarted(_ count: Int) async { + guard started < count else { return } + await withCheckedContinuation { continuation in + milestoneWaiters.append( + MilestoneWaiter(count: count, continuation: continuation) + ) + } + } + + func releaseOne() { + guard !releaseContinuations.isEmpty else { return } + releaseContinuations.removeFirst().resume() + } + + func releaseAll() { + let continuations = releaseContinuations + releaseContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + func snapshot() -> (active: Int, peakActive: Int, started: Int) { + (active, peakActive, started) + } + + private func resumeReachedMilestones() { + let reached = milestoneWaiters.filter { $0.count <= started } + milestoneWaiters.removeAll { $0.count <= started } + for waiter in reached { + waiter.continuation.resume() + } + } +}