@onekeyfe/react-native-image 3.0.105 → 3.0.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/OneKeyImage.podspec +2 -1
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt +264 -0
  3. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt +173 -0
  4. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +23 -11
  5. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +2 -1
  6. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt +11 -0
  7. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt +1 -0
  8. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +5 -0
  9. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyTosUrl.kt +10 -11
  10. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt +327 -0
  11. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt +214 -0
  12. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +10 -1
  13. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyTosUrlTest.kt +42 -10
  14. package/ios/OneKeyAvatarImageLoader.swift +217 -0
  15. package/ios/OneKeyBlockie.swift +126 -0
  16. package/ios/OneKeyImage.swift +14 -2
  17. package/ios/OneKeyImageCache.swift +11 -1
  18. package/ios/OneKeyImageCoderBridge.h +14 -0
  19. package/ios/OneKeyImageCoderBridge.m +25 -0
  20. package/ios/OneKeyImageRequestContext.swift +14 -15
  21. package/ios/OneKeyImageReusableView.swift +6 -1
  22. package/ios/OneKeyTosURL.swift +13 -14
  23. package/ios/tests/OneKeyAvatarImageTests.swift +49 -0
  24. package/ios/tests/OneKeyTosURLTests.swift +43 -3
  25. package/lib/module/index.js +3 -1
  26. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
  27. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
  28. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
  29. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
  30. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
  31. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
  32. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
  33. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
  34. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
  35. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
  36. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
  37. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
  38. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
  39. package/lib/typescript/src/OneKeyImage.nitro.d.ts +2 -0
  40. package/lib/typescript/src/index.d.ts +1 -1
  41. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
  42. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
  43. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
  45. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
  46. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
  47. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
  48. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
  49. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
  50. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
  51. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
  52. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
  53. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
  54. package/package.json +2 -2
  55. package/src/OneKeyImage.nitro.ts +2 -0
  56. package/src/index.tsx +3 -1
@@ -0,0 +1,217 @@
1
+ // OneKey patch: Share local avatar work and persistent SDWebImage caching across
2
+ // render/preload requests, including managers isolated for HTTP headers.
3
+ import Foundation
4
+ import SDWebImage
5
+ import UIKit
6
+
7
+ final class OneKeyAvatarImageLoader: NSObject, SDImageLoader {
8
+ static let shared = OneKeyAvatarImageLoader()
9
+ static let cache: SDImageCache = {
10
+ let config = SDImageCacheConfig()
11
+ config.maxDiskAge = 30 * 24 * 60 * 60
12
+ config.maxDiskSize = 32 * 1024 * 1024
13
+ config.maxMemoryCost = 8 * 1024 * 1024
14
+ config.maxMemoryCount = 128
15
+ return SDImageCache(namespace: "onekey-avatar-blockie-v1", diskCacheDirectory: nil, config: config)
16
+ }()
17
+
18
+ private final class Subscription: NSObject, SDWebImageOperation {
19
+ let id = UUID()
20
+ let options: SDWebImage.SDWebImageOptions
21
+ let context: [SDWebImageContextOption: Any]?
22
+ private let lock = NSLock()
23
+ private var completion: SDImageLoaderCompletedBlock?
24
+ private var cancellation: (() -> Void)?
25
+ private var terminal = false
26
+
27
+ init(options: SDWebImage.SDWebImageOptions, context: [SDWebImageContextOption: Any]?,
28
+ completion: SDImageLoaderCompletedBlock?) {
29
+ self.options = options; self.context = context; self.completion = completion
30
+ }
31
+
32
+ var isTerminal: Bool {
33
+ lock.lock(); defer { lock.unlock() }; return terminal
34
+ }
35
+
36
+ func onCancel(_ block: @escaping () -> Void) {
37
+ lock.lock()
38
+ let alreadyTerminal = terminal
39
+ if !alreadyTerminal { cancellation = block }
40
+ lock.unlock()
41
+ if alreadyTerminal { block() }
42
+ }
43
+
44
+ func finish(image: UIImage?, data: Data?, error: Error?) {
45
+ lock.lock()
46
+ guard !terminal else { lock.unlock(); return }
47
+ terminal = true
48
+ let callback = completion
49
+ completion = nil; cancellation = nil
50
+ lock.unlock()
51
+ callback?(image, data, error, true)
52
+ }
53
+
54
+ func cancel() {
55
+ lock.lock()
56
+ guard !terminal else { lock.unlock(); return }
57
+ terminal = true
58
+ let callback = completion, cancel = cancellation
59
+ completion = nil; cancellation = nil
60
+ lock.unlock()
61
+ cancel?()
62
+ // Preload's checked continuation must also terminate on cancellation.
63
+ DispatchQueue.global(qos: .userInitiated).async {
64
+ callback?(nil, nil, URLError(.cancelled), true)
65
+ }
66
+ }
67
+ }
68
+
69
+ private final class Flight {
70
+ let id = UUID()
71
+ let descriptor: OneKeyBlockieDescriptor
72
+ let url: URL
73
+ var subscriptions: [UUID: Subscription] = [:]
74
+ var operation: BlockOperation?
75
+ init(descriptor: OneKeyBlockieDescriptor, url: URL) {
76
+ self.descriptor = descriptor; self.url = url
77
+ }
78
+ }
79
+
80
+ private let state = DispatchQueue(label: "onekey.avatar.state")
81
+ private let workers: OperationQueue = {
82
+ let queue = OperationQueue()
83
+ queue.name = "onekey.avatar.generate"
84
+ queue.qualityOfService = .userInitiated
85
+ queue.maxConcurrentOperationCount = 2
86
+ return queue
87
+ }()
88
+ private var flights: [String: Flight] = [:]
89
+ private var diskWrites = 0
90
+
91
+ func canRequestImage(for url: URL?) -> Bool {
92
+ // Own invalid local-avatar URLs too: fail locally instead of using HTTP.
93
+ url?.scheme == "onekey-avatar"
94
+ }
95
+
96
+ func shouldBlockFailedURL(with url: URL, error: Error) -> Bool { false }
97
+
98
+ func requestImage(with url: URL?, options: SDWebImage.SDWebImageOptions,
99
+ context: [SDWebImageContextOption: Any]?, progress: SDImageLoaderProgressBlock?,
100
+ completed: SDImageLoaderCompletedBlock?) -> SDWebImageOperation? {
101
+ let subscriber = Subscription(options: options, context: context, completion: completed)
102
+ state.async {
103
+ guard !subscriber.isTerminal else { return }
104
+ guard let url, let descriptor = OneKeyBlockieDescriptor(url: url) else {
105
+ subscriber.finish(image: nil, data: nil, error: URLError(.badURL))
106
+ return
107
+ }
108
+ let key = descriptor.cacheKey
109
+ let flight = self.flights[key] ?? Flight(descriptor: descriptor, url: url)
110
+ self.flights[key] = flight
111
+ flight.subscriptions[subscriber.id] = subscriber
112
+ subscriber.onCancel { [weak self, weak flight] in
113
+ guard let self, let flight else { return }
114
+ self.state.async {
115
+ guard self.flights[key] === flight else { return }
116
+ flight.subscriptions.removeValue(forKey: subscriber.id)
117
+ if flight.subscriptions.isEmpty {
118
+ self.flights.removeValue(forKey: key)
119
+ flight.operation?.cancel()
120
+ }
121
+ }
122
+ }
123
+ guard flight.operation == nil else { return }
124
+ let operation = BlockOperation { [weak self, weak flight] in
125
+ guard let self, let flight else { return }
126
+ self.load(flight)
127
+ }
128
+ flight.operation = operation
129
+ self.workers.addOperation(operation)
130
+ }
131
+ return subscriber
132
+ }
133
+
134
+ private func load(_ flight: Flight) {
135
+ let key = flight.descriptor.cacheKey
136
+ let cancelled = { flight.operation?.isCancelled != false }
137
+ guard !cancelled() else { return }
138
+ let queryTypes = state.sync { cacheTypes(flight, field: .originalQueryCacheType) }
139
+ // Query again inside the merged worker to close the manager-cache-query vs
140
+ // completed previous flight race. These disk operations never run on UI.
141
+ var data: Data?
142
+ if queryTypes.memory, let image = Self.cache.imageFromMemoryCache(forKey: key) {
143
+ data = image.pngData()
144
+ }
145
+ if data == nil, queryTypes.disk { data = Self.cache.diskImageData(forKey: key) }
146
+ var original = data.flatMap { UIImage(data: $0) }
147
+ // A damaged persistent entry is a local cache miss, not a permanent avatar failure.
148
+ if original == nil {
149
+ data = OneKeyBlockie.png(seed: flight.descriptor.seed, isCancelled: cancelled)
150
+ original = data.flatMap { UIImage(data: $0) }
151
+ }
152
+ guard !cancelled() else { return }
153
+ guard let data, let original else {
154
+ finish(flight, data: nil, error: URLError(.cannotDecodeContentData)); return
155
+ }
156
+ // Synchronous store on this worker seals the cache-fill window before any
157
+ // subscriber (or a new isolated manager) can observe a completed flight.
158
+ var storedMemory = false, storedDisk = false
159
+ while !cancelled() {
160
+ var subscribers: [Subscription]?
161
+ let storeTypes = state.sync { () -> (memory: Bool, disk: Bool) in
162
+ guard flights[key] === flight else { subscribers = []; return (false, false) }
163
+ let required = cacheTypes(flight, field: .originalStoreCacheType)
164
+ if (!required.memory || storedMemory) && (!required.disk || storedDisk) {
165
+ flights.removeValue(forKey: key)
166
+ subscribers = Array(flight.subscriptions.values)
167
+ }
168
+ return required
169
+ }
170
+ if let subscribers { deliver(subscribers, flight: flight, data: data, error: nil); return }
171
+ if storeTypes.memory && !storedMemory {
172
+ Self.cache.storeImage(toMemory: original, forKey: key)
173
+ storedMemory = true
174
+ }
175
+ if storeTypes.disk && !storedDisk {
176
+ Self.cache.storeImageData(toDisk: data, forKey: key)
177
+ storedDisk = true
178
+ state.async {
179
+ self.diskWrites += 1
180
+ // SDWebImage also cleans on background/termination; bound active-session
181
+ // growth without scanning the directory after every small PNG write.
182
+ if self.diskWrites % 128 == 0 { Self.cache.deleteOldFiles(completionBlock: nil) }
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ private func cacheTypes(_ flight: Flight, field: SDWebImageContextOption) -> (memory: Bool, disk: Bool) {
189
+ var memory = false, disk = false
190
+ for subscriber in flight.subscriptions.values where !subscriber.isTerminal {
191
+ let raw = (subscriber.context?[field] as? NSNumber)?.intValue ?? SDImageCacheType.all.rawValue
192
+ memory = memory || raw == SDImageCacheType.memory.rawValue || raw == SDImageCacheType.all.rawValue
193
+ disk = disk || raw == SDImageCacheType.disk.rawValue || raw == SDImageCacheType.all.rawValue
194
+ }
195
+ return (memory, disk)
196
+ }
197
+
198
+ private func finish(_ flight: Flight, data: Data?, error: Error?) {
199
+ let subscribers: [Subscription] = state.sync {
200
+ guard flights[flight.descriptor.cacheKey] === flight else { return [] }
201
+ flights.removeValue(forKey: flight.descriptor.cacheKey)
202
+ return Array(flight.subscriptions.values)
203
+ }
204
+ deliver(subscribers, flight: flight, data: data, error: error)
205
+ }
206
+
207
+ private func deliver(_ subscribers: [Subscription], flight: Flight, data: Data?, error: Error?) {
208
+ for subscriber in subscribers where !subscriber.isTerminal {
209
+ let image: UIImage? = data.flatMap {
210
+ SDWebImage.SDImageLoaderDecodeImageData($0, flight.url,
211
+ .init(rawValue: subscriber.options.rawValue), subscriber.context)
212
+ }
213
+ subscriber.finish(image: image, data: data,
214
+ error: error ?? (image == nil ? URLError(.cannotDecodeContentData) : nil))
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,126 @@
1
+ // OneKey patch: Render the versioned local avatar URI without a JS PNG payload.
2
+ // Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT):
3
+ // https://github.com/MyCryptoHQ/ethereum-blockies-base64
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ // The above copyright notice and this permission notice shall be included in
11
+ // all copies or substantial portions of the Software.
12
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
14
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
18
+ // THE SOFTWARE.
19
+
20
+ import CoreGraphics
21
+ import Foundation
22
+ import ImageIO
23
+
24
+ struct OneKeyBlockieDescriptor {
25
+ let seed: String
26
+ let cacheKey: String
27
+
28
+ init?(url: URL) {
29
+ guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
30
+ parts.scheme == "onekey-avatar", parts.host == "blockie",
31
+ parts.user == nil, parts.password == nil, parts.port == nil,
32
+ parts.query == nil, parts.fragment == nil,
33
+ parts.percentEncodedPath.hasPrefix("/v1/")
34
+ else { return nil }
35
+ let encoded = String(parts.percentEncodedPath.dropFirst(4))
36
+ guard !encoded.contains("/"), let seed = encoded.removingPercentEncoding,
37
+ !seed.isEmpty
38
+ else { return nil }
39
+ // The caller already applied JS lowercase. ASCII keys preserve distinct
40
+ // UTF16 seeds that Swift String otherwise compares as canonically equal.
41
+ let allowed = CharacterSet(charactersIn:
42
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
43
+ guard let canonical = seed.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil }
44
+ self.seed = seed
45
+ cacheKey = "onekey-avatar://blockie/v1/\(canonical)"
46
+ }
47
+ }
48
+
49
+ enum OneKeyBlockie {
50
+ static let pixelSize = 128
51
+
52
+ private struct Random {
53
+ var state = [Int32](repeating: 0, count: 4)
54
+
55
+ init(seed: String) {
56
+ for (index, unit) in seed.utf16.enumerated() {
57
+ let slot = index % 4
58
+ state[slot] = (state[slot] &<< 5) &- state[slot] &+ Int32(unit)
59
+ }
60
+ }
61
+
62
+ mutating func next() -> Double {
63
+ let t = state[0] ^ (state[0] &<< 11)
64
+ state[0] = state[1]; state[1] = state[2]; state[2] = state[3]
65
+ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8)
66
+ return Double(UInt32(bitPattern: state[3])) / 2_147_483_648
67
+ }
68
+
69
+ mutating func color() -> [UInt8] {
70
+ let h = floor(next() * 360) / 360
71
+ let s = (next() * 60 + 40) / 100
72
+ let l = ((next() + next() + next() + next()) * 25) / 100
73
+ let q = l < 0.5 ? l * (1 + s) : l + s - l * s
74
+ let p = 2 * l - q
75
+ func channel(_ value: Double) -> UInt8 {
76
+ var t = value
77
+ if t < 0 { t += 1 }
78
+ if t > 1 { t -= 1 }
79
+ let value: Double
80
+ if t < 1.0 / 6 { value = p + (q - p) * 6 * t }
81
+ else if t < 1.0 / 2 { value = q }
82
+ else if t < 2.0 / 3 { value = p + (q - p) * (2.0 / 3 - t) * 6 }
83
+ else { value = p }
84
+ return UInt8(truncatingIfNeeded: Int(floor(value * 255 + 0.5)))
85
+ }
86
+ return [channel(h + 1.0 / 3), channel(h), channel(h - 1.0 / 3), 255]
87
+ }
88
+ }
89
+
90
+ static func rgba(seed: String, isCancelled: () -> Bool = { false }) -> Data? {
91
+ var random = Random(seed: seed)
92
+ let foreground = random.color(), background = random.color(), spot = random.color()
93
+ var pixels = [UInt8](repeating: 0, count: pixelSize * pixelSize * 4)
94
+ for row in 0..<8 {
95
+ guard !isCancelled() else { return nil }
96
+ let half = (0..<4).map { _ in Int(floor(random.next() * 2.3)) }
97
+ let cells = half + half.reversed()
98
+ for column in 0..<8 {
99
+ let color = cells[column] == 0 ? background : cells[column] == 1 ? foreground : spot
100
+ for y in (row * 16)..<((row + 1) * 16) {
101
+ for x in (column * 16)..<((column + 1) * 16) {
102
+ let offset = (y * pixelSize + x) * 4
103
+ for channel in 0..<4 { pixels[offset + channel] = color[channel] }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return Data(pixels)
109
+ }
110
+
111
+ static func png(seed: String, isCancelled: () -> Bool = { false }) -> Data? {
112
+ guard let data = rgba(seed: seed, isCancelled: isCancelled), !isCancelled(),
113
+ let provider = CGDataProvider(data: data as CFData),
114
+ let image = CGImage(width: pixelSize, height: pixelSize, bitsPerComponent: 8,
115
+ bitsPerPixel: 32, bytesPerRow: pixelSize * 4, space: CGColorSpaceCreateDeviceRGB(),
116
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
117
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
118
+ else { return nil }
119
+ let output = NSMutableData()
120
+ guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil)
121
+ else { return nil }
122
+ CGImageDestinationAddImage(destination, image, nil)
123
+ guard CGImageDestinationFinalize(destination), !isCancelled() else { return nil }
124
+ return output as Data
125
+ }
126
+ }
@@ -121,6 +121,11 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
121
121
  var optimizeTos: Bool? = true {
122
122
  didSet { if optimizeTos != oldValue { identityDidChange() } }
123
123
  }
124
+ var resizeWidth: Double? {
125
+ didSet {
126
+ if !Self.equalOptionalDouble(resizeWidth, oldValue) { identityDidChange() }
127
+ }
128
+ }
124
129
  var overscan: Double? = 1.1 {
125
130
  didSet {
126
131
  if !Self.equalOptionalDouble(overscan, oldValue) { identityDidChange() }
@@ -241,6 +246,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
241
246
  cachePolicy?.stringValue ?? "memory-disk",
242
247
  contentFit?.stringValue ?? "cover",
243
248
  String(optimizeTos ?? true),
249
+ resizeWidth.map(String.init(describing:)) ?? "",
244
250
  String(overscan ?? 1.1),
245
251
  String(Int(hostView.bounds.width.rounded())),
246
252
  String(Int(hostView.bounds.height.rounded())),
@@ -270,11 +276,13 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
270
276
  }
271
277
 
272
278
  let hasCustomIdentity = OneKeyImageRequestContext.headers(from: sourceHeadersJson) != nil
279
+ let displaySize = resizeWidth.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }
280
+ ?? max(hostView.bounds.width, hostView.bounds.height)
273
281
  let requestURL =
274
282
  (optimizeTos ?? true)
275
283
  ? OneKeyTosURL.optimized(
276
284
  rawURL: rawURL,
277
- displaySize: max(hostView.bounds.width, hostView.bounds.height),
285
+ displaySize: displaySize,
278
286
  scale: screenScale,
279
287
  overscan: overscan ?? 1.1,
280
288
  hasCustomIdentity: hasCustomIdentity
@@ -313,7 +321,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
313
321
  cachePolicy: cachePolicy ?? .memoryDisk,
314
322
  thumbnailPixelSize: thumbnailPixelSize,
315
323
  safetyTracker: safetyHandle.tracker,
316
- manager: safetyHandle.manager
324
+ // OneKey patch: Rendering and preload use the same local-avatar loader.
325
+ // manager: safetyHandle.manager
326
+ manager: safetyHandle.manager,
327
+ url: url
317
328
  )
318
329
  hostView.sd_setImage(
319
330
  with: url,
@@ -460,6 +471,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
460
471
  autoplay = true
461
472
  recyclingKey = nil
462
473
  optimizeTos = true
474
+ resizeWidth = nil
463
475
  overscan = 1.1
464
476
  loadingStrategy = .static
465
477
  onLoadStart = nil
@@ -74,6 +74,8 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
74
74
  func clearMemory() throws -> Promise<Void> {
75
75
  Promise.async {
76
76
  SDImageCache.shared.clearMemory()
77
+ // OneKey patch: Clear the dedicated local-avatar cache with public cache operations.
78
+ OneKeyAvatarImageLoader.cache.clearMemory()
77
79
  }
78
80
  }
79
81
 
@@ -82,11 +84,16 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
82
84
  await withCheckedContinuation { continuation in
83
85
  SDImageCache.shared.clearDisk { continuation.resume() }
84
86
  }
87
+ await withCheckedContinuation { continuation in
88
+ OneKeyAvatarImageLoader.cache.clearDisk { continuation.resume() }
89
+ }
85
90
  }
86
91
  }
87
92
 
88
93
  func clearAll() throws -> Promise<Void> {
89
94
  SDImageCache.shared.clearMemory()
95
+ // OneKey patch: The avatar cache is shared by render and preload requests.
96
+ OneKeyAvatarImageLoader.cache.clearMemory()
90
97
  return try clearDisk()
91
98
  }
92
99
 
@@ -154,7 +161,10 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
154
161
  cachePolicy: source.cachePolicy ?? .memoryDisk,
155
162
  thumbnailPixelSize: thumbnailPixelSize,
156
163
  safetyTracker: safetyHandle.tracker,
157
- manager: safetyHandle.manager
164
+ // OneKey patch: Rendering and preload use the same local-avatar loader.
165
+ // manager: safetyHandle.manager
166
+ manager: safetyHandle.manager,
167
+ url: url
158
168
  )
159
169
  return await load(url: url, context: context, safetyHandle: safetyHandle)
160
170
  }
@@ -0,0 +1,14 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ // Keep optional coder modules out of the Swift compilation unit so SDWebImage
6
+ // Objective-C types are imported under a single Swift module identity.
7
+ @interface OneKeyImageCoderBridge : NSObject
8
+
9
+ + (void)addCodersToManager:(id)manager NS_SWIFT_NAME(addCoders(to:));
10
+ + (void)ensureWebPCoderRegistered;
11
+
12
+ @end
13
+
14
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,25 @@
1
+ #import "OneKeyImageCoderBridge.h"
2
+
3
+ #import <SDWebImage/SDWebImage.h>
4
+ #import <SDWebImageSVGCoder/SDImageSVGCoder.h>
5
+ #import <SDWebImageWebPCoder/SDImageWebPCoder.h>
6
+
7
+ @implementation OneKeyImageCoderBridge
8
+
9
+ + (void)addCodersToManager:(id)manager {
10
+ SDImageCodersManager *coderManager = (SDImageCodersManager *)manager;
11
+ [coderManager addCoder:SDImageSVGCoder.sharedCoder];
12
+ [coderManager addCoder:SDImageWebPCoder.sharedCoder];
13
+ }
14
+
15
+ + (void)ensureWebPCoderRegistered {
16
+ SDImageCodersManager *manager = SDImageCodersManager.sharedManager;
17
+ for (id<SDImageCoder> coder in manager.coders) {
18
+ if (coder == SDImageWebPCoder.sharedCoder) {
19
+ return;
20
+ }
21
+ }
22
+ [manager addCoder:SDImageWebPCoder.sharedCoder];
23
+ }
24
+
25
+ @end
@@ -2,8 +2,6 @@ import CryptoKit
2
2
  import Foundation
3
3
  import ImageIO
4
4
  import SDWebImage
5
- import SDWebImageSVGCoder
6
- import SDWebImageWebPCoder
7
5
  import UIKit
8
6
 
9
7
  enum OneKeyImageSafetyViolation: LocalizedError, Equatable, Sendable {
@@ -467,28 +465,18 @@ enum OneKeyImageSafetyPolicy {
467
465
  }
468
466
 
469
467
  enum OneKeyImageCoderRegistry {
470
- private static let svgCoder = SDImageSVGCoder.shared
471
- private static let webPCoder = SDImageWebPCoder.shared
472
-
473
468
  static let coder: SDImageCodersManager = {
474
469
  // Keep OneKey's decoder set independent from Expo's global registrations.
475
470
  // SDImageCodersManager starts with ImageIO, GIF and APNG coders.
476
471
  let manager = SDImageCodersManager()
477
- manager.addCoder(svgCoder)
478
- manager.addCoder(webPCoder)
472
+ OneKeyImageCoderBridge.addCoders(to: manager)
479
473
  return manager
480
474
  }()
481
475
 
482
476
  private static let globalRegistration: Void = {
483
477
  // SDAnimatedImage resolves its animated coder through the global manager,
484
478
  // even when a request-local coder is provided in the SDWebImage context.
485
- let global = SDImageCodersManager.shared
486
- let isAlreadyRegistered = (global.coders ?? []).contains {
487
- ($0 as AnyObject) === webPCoder
488
- }
489
- if !isAlreadyRegistered {
490
- global.addCoder(webPCoder)
491
- }
479
+ OneKeyImageCoderBridge.ensureWebPCoderRegistered()
492
480
  }()
493
481
 
494
482
  static func ensureWebPRegistered() {
@@ -697,7 +685,10 @@ enum OneKeyImageRequestContext {
697
685
  cachePolicy: OneKeyImageCachePolicy,
698
686
  thumbnailPixelSize: CGSize?,
699
687
  safetyTracker: OneKeyImageSafetyTracker?,
700
- manager: SDWebImageManager
688
+ // OneKey patch: Scope local avatar routing to this request, preserving HTTP managers.
689
+ // manager: SDWebImageManager
690
+ manager: SDWebImageManager,
691
+ url: URL? = nil
701
692
  ) -> [SDWebImageContextOption: Any] {
702
693
  var context = baseContext
703
694
  context[.customManager] = manager
@@ -728,6 +719,14 @@ enum OneKeyImageRequestContext {
728
719
  context[.storeCacheType] = cacheType.rawValue
729
720
  context[.originalQueryCacheType] = cacheType.rawValue
730
721
  context[.originalStoreCacheType] = cacheType.rawValue
722
+ if url?.scheme == "onekey-avatar" {
723
+ context[.imageLoader] = OneKeyAvatarImageLoader.shared
724
+ context[.imageCache] = OneKeyAvatarImageLoader.cache
725
+ context[.originalImageCache] = OneKeyAvatarImageLoader.cache
726
+ context[.cacheKeyFilter] = SDWebImageCacheKeyFilter { url in
727
+ OneKeyBlockieDescriptor(url: url)?.cacheKey ?? url.absoluteString
728
+ }
729
+ }
731
730
  return context
732
731
  }
733
732
 
@@ -27,8 +27,13 @@ public final class OneKeyImageReusableView: UIView {
27
27
  recyclingKey: String,
28
28
  optimizeTos: Bool,
29
29
  overscan: Double,
30
- loadingStrategy: String
30
+ loadingStrategy: String,
31
+ onLoad: (() -> Void)? = nil,
32
+ onError: (() -> Void)? = nil
31
33
  ) {
34
+ // OneKey patch: Let reusable cells display their own success and fallback visuals.
35
+ image.onLoad = { _, _, _ in onLoad?() }
36
+ image.onError = { _ in onError?() }
32
37
  image.sourceHeadersJson = sourceHeadersJson
33
38
  image.variant = OneKeyImageVariant(fromString: variant) ?? .generic
34
39
  image.contentFit = OneKeyImageContentFit(fromString: contentFit) ?? .cover
@@ -1,8 +1,10 @@
1
1
  import Foundation
2
2
 
3
3
  enum OneKeyTosURL {
4
- static let widthBuckets = [
5
- 32, 40, 48, 64, 96, 128, 160, 200, 256, 320, 480, 640, 960, 1280,
4
+ // Select by layout size first. Density can only choose between this tier's renditions.
5
+ static let sizeTiers: [(maxDisplaySize: CGFloat, standard: Int, highDensity: Int)] = [
6
+ (48, 96, 160), (96, 192, 320), (192, 384, 640),
7
+ (384, 768, 1280), (.infinity, 1280, 1280),
6
8
  ]
7
9
 
8
10
  static func optimized(
@@ -24,19 +26,16 @@ enum OneKeyTosURL {
24
26
  return rawURL
25
27
  }
26
28
 
29
+ let tier = sizeTiers.first(where: { displaySize <= $0.maxDisplaySize })!
27
30
  let normalizedScale = scale.isFinite ? min(max(scale, 1), 3) : 1
28
- let normalizedOverscan = overscan.isFinite ? max(overscan, 1) : 1
29
- let requestedPixels = ceil(displaySize * normalizedScale * normalizedOverscan)
30
- let bucket: Int
31
- if !requestedPixels.isFinite || requestedPixels >= CGFloat(widthBuckets.last!) {
32
- bucket = widthBuckets.last!
33
- } else {
34
- let requested = Int(requestedPixels)
35
- bucket = widthBuckets.first(where: { $0 >= requested }) ?? widthBuckets.last!
36
- }
37
- var queryItems = components.queryItems ?? []
38
- queryItems.append(URLQueryItem(name: "x-tos-process", value: "image/resize,w_\(bucket)"))
39
- components.queryItems = queryItems
31
+ let normalizedOverscan = overscan.isFinite ? max(overscan, 1) : 1.1
32
+ // Fixed renditions already account for the default margin. Custom margins stay within the tier.
33
+ let density = normalizedScale * CGFloat(normalizedOverscan / 1.1)
34
+ let pixelWidth = density > 2 ? tier.highDensity : tier.standard
35
+ // Match Android and Web without rewriting the source URL's existing query.
36
+ let query = components.percentEncodedQuery ?? ""
37
+ let separator = query.isEmpty || query.hasSuffix("&") ? "" : "&"
38
+ components.percentEncodedQuery = "\(query)\(separator)x-tos-process=image%2Fresize%2Cw_\(pixelWidth)"
40
39
  return components.url ?? rawURL
41
40
  }
42
41
 
@@ -0,0 +1,49 @@
1
+ // OneKey patch: Protect request-local avatar routing and exact UTF16 identities.
2
+ import Foundation
3
+ import SDWebImage
4
+ import XCTest
5
+
6
+ @testable import OneKeyImage
7
+
8
+ final class OneKeyAvatarImageTests: XCTestCase {
9
+ func testAvatarCacheIsSharedWithoutReplacingIsolatedHTTPManagers() throws {
10
+ let url = try XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/synthetic-avatar"))
11
+ let first = OneKeyImagePipeline.makeIsolatedManager()
12
+ let second = OneKeyImagePipeline.makeIsolatedManager()
13
+ func context(_ manager: SDWebImageManager, headers: String) -> [SDWebImageContextOption: Any] {
14
+ OneKeyImageRequestContext.make(headersJson: headers, cachePolicy: .memoryDisk,
15
+ thumbnailPixelSize: CGSize(width: 96, height: 96), safetyTracker: nil,
16
+ manager: manager, url: url)
17
+ }
18
+ let a = context(first, headers: "{\"X-Test\":\"a\"}")
19
+ let b = context(second, headers: "{\"X-Test\":\"b\"}")
20
+ XCTAssertTrue(a[.customManager] as? SDWebImageManager === first)
21
+ XCTAssertTrue(b[.customManager] as? SDWebImageManager === second)
22
+ XCTAssertTrue(a[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared)
23
+ XCTAssertTrue(b[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared)
24
+ XCTAssertTrue(a[.imageCache] as? SDImageCache === b[.imageCache] as? SDImageCache)
25
+ XCTAssertTrue(a[.originalImageCache] as? SDImageCache === OneKeyAvatarImageLoader.cache)
26
+ let firstFilter = try XCTUnwrap(a[.cacheKeyFilter] as? SDWebImageCacheKeyFilter)
27
+ let secondFilter = try XCTUnwrap(b[.cacheKeyFilter] as? SDWebImageCacheKeyFilter)
28
+ XCTAssertEqual(firstFilter.cacheKey(for: url), secondFilter.cacheKey(for: url))
29
+ let remote = OneKeyImageRequestContext.make(headersJson: nil, cachePolicy: .memoryDisk,
30
+ thumbnailPixelSize: nil, safetyTracker: nil, manager: first,
31
+ url: URL(string: "https://example.com/image.png"))
32
+ XCTAssertNil(remote[.imageLoader])
33
+ XCTAssertNil(remote[.imageCache])
34
+ XCTAssertNil(remote[.originalImageCache])
35
+ }
36
+
37
+ func testURIDecodesExactlyOnceAndPreservesUTF16SeedIdentity() throws {
38
+ func descriptor(_ suffix: String) throws -> OneKeyBlockieDescriptor {
39
+ try XCTUnwrap(OneKeyBlockieDescriptor(url:
40
+ XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/" + suffix))))
41
+ }
42
+ XCTAssertEqual(try descriptor("%2561").seed, "%61")
43
+ XCTAssertEqual(try descriptor("%61").cacheKey, try descriptor("a").cacheKey)
44
+ XCTAssertNotEqual(try descriptor("%C3%A9").cacheKey, try descriptor("e%CC%81").cacheKey)
45
+ XCTAssertEqual(try descriptor("%C4%B0").seed.utf16.count, 1)
46
+ XCTAssertNotEqual(OneKeyBlockie.rgba(seed: "é"), OneKeyBlockie.rgba(seed: "e\u{301}"))
47
+ XCTAssertNil(OneKeyBlockie.png(seed: "synthetic", isCancelled: { true }))
48
+ }
49
+ }