@onekeyfe/react-native-image 3.0.155 → 3.0.157-alpha.248

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 (44) hide show
  1. package/README.md +3 -0
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +356 -22
  3. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +34 -4
  4. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariants.kt +119 -0
  5. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +2 -0
  6. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariantsTest.kt +149 -0
  7. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +14 -1
  8. package/ios/OneKeyImage.swift +273 -43
  9. package/ios/OneKeyImageCache.swift +40 -3
  10. package/ios/OneKeyImageMemoryVariants.swift +137 -0
  11. package/ios/OneKeyImageRequestContext.swift +51 -9
  12. package/ios/tests/OneKeyImageInfrastructureTests.swift +255 -18
  13. package/lib/module/index.js +26 -3
  14. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  15. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  16. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  17. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  18. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  19. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  20. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  21. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  22. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  23. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  24. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  25. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  26. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  27. package/lib/typescript/src/OneKeyImage.nitro.d.ts +8 -0
  28. package/lib/typescript/src/index.d.ts +1 -1
  29. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  30. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  31. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  33. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  34. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  35. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  36. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  37. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  39. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  40. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  41. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  42. package/package.json +2 -2
  43. package/src/OneKeyImage.nitro.ts +8 -0
  44. package/src/index.tsx +25 -1
@@ -0,0 +1,137 @@
1
+ import Foundation
2
+
3
+ struct OneKeyImageMemoryFamilyKey: Hashable, Sendable {
4
+ let rawURL: URL
5
+ let headersDigest: String?
6
+
7
+ init(rawURL: URL, headersJson: String?) {
8
+ self.rawURL = rawURL
9
+ headersDigest =
10
+ OneKeyImageHeadersIdentity(
11
+ headers: OneKeyImageRequestContext.headers(from: headersJson)
12
+ ).cacheKeyComponent
13
+ }
14
+ }
15
+
16
+ struct OneKeyImageMemoryVariant: Hashable, Sendable {
17
+ let requestURL: URL
18
+ let pixelWidth: Int
19
+ let pixelHeight: Int
20
+
21
+ init(requestURL: URL, thumbnailPixelSize: CGSize) {
22
+ self.requestURL = requestURL
23
+ pixelWidth = max(Int(thumbnailPixelSize.width.rounded()), 1)
24
+ pixelHeight = max(Int(thumbnailPixelSize.height.rounded()), 1)
25
+ }
26
+
27
+ var thumbnailPixelSize: CGSize {
28
+ CGSize(width: pixelWidth, height: pixelHeight)
29
+ }
30
+
31
+ var area: Int64 { Int64(pixelWidth) * Int64(pixelHeight) }
32
+ }
33
+
34
+ /// Bounded hints for reproducing size-specific SDWebImage memory-cache keys.
35
+ /// SDImageCache remains authoritative; stale hints are removed on probe miss.
36
+ final class OneKeyImageMemoryVariantRegistry: @unchecked Sendable {
37
+ static let shared = OneKeyImageMemoryVariantRegistry()
38
+
39
+ private static let maximumFamilies = 1024
40
+ private static let maximumVariantsPerFamily = 8
41
+ private static let maximumAspectRatioDrift = 1.1
42
+
43
+ private let lock = NSLock()
44
+ private var variantsByFamily: [OneKeyImageMemoryFamilyKey: [OneKeyImageMemoryVariant]] = [:]
45
+ private var familyAccessOrder: [OneKeyImageMemoryFamilyKey] = []
46
+
47
+ func record(_ variant: OneKeyImageMemoryVariant, for family: OneKeyImageMemoryFamilyKey) {
48
+ lock.lock()
49
+ defer { lock.unlock() }
50
+ var variants = variantsByFamily[family] ?? []
51
+ variants.removeAll { $0 == variant }
52
+ variants.append(variant)
53
+ if variants.count > Self.maximumVariantsPerFamily {
54
+ variants.removeFirst(variants.count - Self.maximumVariantsPerFamily)
55
+ }
56
+ variantsByFamily[family] = variants
57
+ touch(family)
58
+ while familyAccessOrder.count > Self.maximumFamilies {
59
+ let oldest = familyAccessOrder.removeFirst()
60
+ variantsByFamily.removeValue(forKey: oldest)
61
+ }
62
+ }
63
+
64
+ func remove(_ variant: OneKeyImageMemoryVariant, for family: OneKeyImageMemoryFamilyKey) {
65
+ lock.lock()
66
+ defer { lock.unlock() }
67
+ guard var variants = variantsByFamily[family] else { return }
68
+ variants.removeAll { $0 == variant }
69
+ if variants.isEmpty {
70
+ variantsByFamily.removeValue(forKey: family)
71
+ familyAccessOrder.removeAll { $0 == family }
72
+ } else {
73
+ variantsByFamily[family] = variants
74
+ }
75
+ }
76
+
77
+ func candidates(
78
+ for family: OneKeyImageMemoryFamilyKey,
79
+ target: OneKeyImageMemoryVariant,
80
+ excluding exactVariants: Set<OneKeyImageMemoryVariant>
81
+ ) -> [OneKeyImageMemoryVariant] {
82
+ lock.lock()
83
+ let variants = variantsByFamily[family] ?? []
84
+ if !variants.isEmpty { touch(family) }
85
+ lock.unlock()
86
+ return Self.orderedCandidates(
87
+ variants: variants,
88
+ target: target,
89
+ excluding: exactVariants
90
+ )
91
+ }
92
+
93
+ func clear() {
94
+ lock.lock()
95
+ variantsByFamily.removeAll()
96
+ familyAccessOrder.removeAll()
97
+ lock.unlock()
98
+ }
99
+
100
+ static func orderedCandidates(
101
+ variants: [OneKeyImageMemoryVariant],
102
+ target: OneKeyImageMemoryVariant,
103
+ excluding exactVariants: Set<OneKeyImageMemoryVariant> = []
104
+ ) -> [OneKeyImageMemoryVariant] {
105
+ var seen: Set<OneKeyImageMemoryVariant> = []
106
+ let compatible = variants.reversed().filter {
107
+ seen.insert($0).inserted && !exactVariants.contains($0) && aspectRatioIsCompatible($0, target)
108
+ }
109
+ let larger =
110
+ compatible
111
+ .filter { $0.pixelWidth >= target.pixelWidth && $0.pixelHeight >= target.pixelHeight }
112
+ .min { lhs, rhs in lhs.area < rhs.area }
113
+ let smaller =
114
+ compatible
115
+ .filter { $0.pixelWidth <= target.pixelWidth && $0.pixelHeight <= target.pixelHeight }
116
+ .max { lhs, rhs in lhs.area < rhs.area }
117
+ var result: [OneKeyImageMemoryVariant] = []
118
+ if let larger { result.append(larger) }
119
+ if let smaller, smaller != larger { result.append(smaller) }
120
+ return result
121
+ }
122
+
123
+ private static func aspectRatioIsCompatible(
124
+ _ candidate: OneKeyImageMemoryVariant,
125
+ _ target: OneKeyImageMemoryVariant
126
+ ) -> Bool {
127
+ let candidateRatio = Double(candidate.pixelWidth) / Double(candidate.pixelHeight)
128
+ let targetRatio = Double(target.pixelWidth) / Double(target.pixelHeight)
129
+ return max(candidateRatio, targetRatio) / min(candidateRatio, targetRatio)
130
+ <= maximumAspectRatioDrift
131
+ }
132
+
133
+ private func touch(_ family: OneKeyImageMemoryFamilyKey) {
134
+ familyAccessOrder.removeAll { $0 == family }
135
+ familyAccessOrder.append(family)
136
+ }
137
+ }
@@ -610,17 +610,59 @@ enum OneKeyImageDecodeSizing {
610
610
  }
611
611
  }
612
612
 
613
- /// The logical size to probe the memory cache with. Before the first layout
614
- /// the host view has no bounds, but the JS side already knows the display
615
- /// size through `resizeWidth` (the same hint the prewarm used), so a square
616
- /// from it yields the decode-thumbnail key the laid-out view will ask for.
617
- /// A cached image can then show on the mount frame instead of a skeleton
618
- /// until layout re-probes.
619
- static func probeViewSize(bounds: CGSize, resizeWidth: Double?) -> CGSize? {
613
+ /// The logical display size the TOS rendition is picked for. A preload picks
614
+ /// it from the long edge of its `resizeWidth`/`resizeHeight` hint, so a view
615
+ /// carrying the same hint must do the same (before AND after layout) or its
616
+ /// request URL, and with it the cache key, diverges from what the preload
617
+ /// stored. Without a hint the laid-out bounds decide; nil before layout.
618
+ static func tosDisplaySize(
619
+ bounds: CGSize,
620
+ resizeWidth: Double?,
621
+ resizeHeight: Double?
622
+ ) -> CGFloat? {
623
+ if let hint = logicalViewSize(width: resizeWidth, height: resizeHeight) {
624
+ return max(hint.width, hint.height)
625
+ }
626
+ let edge = max(bounds.width, bounds.height)
627
+ return edge.isFinite && edge > 0 ? edge : nil
628
+ }
629
+
630
+ /// Decode-thumbnail sizes worth probing the memory cache with, most specific
631
+ /// first. The thumbnail size is part of the SDWebImage cache key, so a probe
632
+ /// only hits when it reproduces the key some earlier load stored under.
633
+ ///
634
+ /// - Laid out: exactly the size this view's own request uses (`bounds` +
635
+ /// `contentFit`), nothing else.
636
+ /// - Before layout the bounds are empty, but the JS side already knows the
637
+ /// display size through the `resizeWidth` / `resizeHeight` hints (the same
638
+ /// fields a preload takes). Two keys can hold the image then: the one the
639
+ /// laid-out request will ask for (hint + this view's `contentFit`) and the
640
+ /// one a preload stored (hint + `.cover`, see `HybridOneKeyImageCache`).
641
+ /// They coincide for a square hint or `.cover`; otherwise both are probed.
642
+ /// - No bounds and no usable hint: nothing to probe with.
643
+ static func probeThumbnailPixelSizes(
644
+ bounds: CGSize,
645
+ resizeWidth: Double?,
646
+ resizeHeight: Double?,
647
+ scale: CGFloat,
648
+ contentFit: OneKeyImageContentFit
649
+ ) -> [CGSize] {
620
650
  if bounds.width.isFinite, bounds.height.isFinite, bounds.width > 0, bounds.height > 0 {
621
- return bounds
651
+ return thumbnailPixelSize(viewSize: bounds, scale: scale, contentFit: contentFit)
652
+ .map { [$0] } ?? []
653
+ }
654
+ guard let hint = logicalViewSize(width: resizeWidth, height: resizeHeight) else {
655
+ return []
656
+ }
657
+ var sizes: [CGSize] = []
658
+ for fit in [contentFit, .cover] {
659
+ if let size = thumbnailPixelSize(viewSize: hint, scale: scale, contentFit: fit),
660
+ !sizes.contains(size)
661
+ {
662
+ sizes.append(size)
663
+ }
622
664
  }
623
- return logicalViewSize(width: resizeWidth, height: nil)
665
+ return sizes
624
666
  }
625
667
 
626
668
  static func thumbnailPixelSize(
@@ -310,31 +310,173 @@ final class OneKeyImageInfrastructureTests: XCTestCase {
310
310
  )
311
311
  }
312
312
 
313
- func testMemoryProbeFallsBackToResizeWidthBeforeLayout() throws {
314
- // Laid out: the real bounds win, even with a hint.
313
+ // The pre-layout memory probe is only useful if it reproduces a key some
314
+ // earlier load stored under. These pin the probe against the two real key
315
+ // producers: the laid-out render request (bounds + contentFit) and the
316
+ // preload (logicalViewSize(resizeWidth, resizeHeight) + .cover).
317
+ private func renderKey(bounds: CGSize, fit: OneKeyImageContentFit) throws -> CGSize {
318
+ try XCTUnwrap(
319
+ OneKeyImageDecodeSizing.thumbnailPixelSize(viewSize: bounds, scale: 3, contentFit: fit)
320
+ )
321
+ }
322
+
323
+ private func preloadKey(width: Double?, height: Double?) throws -> CGSize {
324
+ try renderKey(
325
+ bounds: try XCTUnwrap(OneKeyImageDecodeSizing.logicalViewSize(width: width, height: height)),
326
+ fit: .cover
327
+ )
328
+ }
329
+
330
+ func testLaidOutProbeUsesExactlyTheRenderRequestKey() throws {
331
+ let bounds = CGSize(width: 40, height: 64)
332
+ for fit in [OneKeyImageContentFit.cover, .contain, .fill, .center] {
333
+ XCTAssertEqual(
334
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
335
+ bounds: bounds, resizeWidth: 32, resizeHeight: 48, scale: 3, contentFit: fit
336
+ ),
337
+ [try renderKey(bounds: bounds, fit: fit)],
338
+ "\(fit)"
339
+ )
340
+ }
341
+ }
342
+
343
+ func testPreLayoutSquareHintMatchesPreloadAndRenderKeys() throws {
344
+ // A token icon: resizeWidth only, laid out square, cover. One key, shared
345
+ // by the preload, the laid-out request and the probe.
346
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
347
+ bounds: .zero, resizeWidth: 40, resizeHeight: nil, scale: 3, contentFit: .cover
348
+ )
349
+ XCTAssertEqual(sizes, [CGSize(width: 120, height: 120)])
350
+ XCTAssertEqual(sizes, [try preloadKey(width: 40, height: nil)])
351
+ XCTAssertEqual(sizes, [try renderKey(bounds: CGSize(width: 40, height: 40), fit: .cover)])
352
+ }
353
+
354
+ func testPreLayoutRectangularHintProbesRenderAndPreloadKeys() throws {
355
+ // The README preload shape (48x64 @3) under a non-cover view: the laid-out
356
+ // request key comes first, the preload's cover key second, no duplicates.
357
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
358
+ bounds: .zero, resizeWidth: 48, resizeHeight: 64, scale: 3, contentFit: .contain
359
+ )
315
360
  XCTAssertEqual(
316
- OneKeyImageDecodeSizing.probeViewSize(
317
- bounds: CGSize(width: 40, height: 64),
318
- resizeWidth: 32
361
+ sizes,
362
+ [
363
+ try renderKey(bounds: CGSize(width: 48, height: 64), fit: .contain),
364
+ try preloadKey(width: 48, height: 64),
365
+ ]
366
+ )
367
+ XCTAssertEqual(sizes, [CGSize(width: 144, height: 192), CGSize(width: 192, height: 192)])
368
+ // Under cover both collapse to the preload key.
369
+ XCTAssertEqual(
370
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
371
+ bounds: .zero, resizeWidth: 48, resizeHeight: 64, scale: 3, contentFit: .cover
319
372
  ),
320
- CGSize(width: 40, height: 64)
373
+ [try preloadKey(width: 48, height: 64)]
321
374
  )
322
- // Not laid out yet: a square from the hint, matching the prewarm key.
323
- let probe = try XCTUnwrap(
324
- OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: 40)
375
+ }
376
+
377
+ func testPreLayoutProbeWithoutHintHasNothingToTry() {
378
+ XCTAssertEqual(
379
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
380
+ bounds: .zero, resizeWidth: nil, resizeHeight: nil, scale: 3, contentFit: .cover
381
+ ),
382
+ []
325
383
  )
326
- XCTAssertEqual(probe, CGSize(width: 40, height: 40))
327
384
  XCTAssertEqual(
328
- OneKeyImageDecodeSizing.thumbnailPixelSize(viewSize: probe, scale: 3, contentFit: .cover),
329
- OneKeyImageDecodeSizing.thumbnailPixelSize(
330
- viewSize: try XCTUnwrap(OneKeyImageDecodeSizing.logicalViewSize(width: 40, height: nil)),
331
- scale: 3,
332
- contentFit: .cover
385
+ OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
386
+ bounds: .zero, resizeWidth: 0, resizeHeight: -1, scale: 3, contentFit: .cover
387
+ ),
388
+ []
389
+ )
390
+ }
391
+
392
+ func testMemoryVariantsChooseNearestLargerThenNearestSmaller() {
393
+ let target = memoryVariant(96)
394
+ XCTAssertEqual(
395
+ OneKeyImageMemoryVariantRegistry.orderedCandidates(
396
+ variants: [memoryVariant(48), memoryVariant(72), memoryVariant(120), memoryVariant(144)],
397
+ target: target
398
+ ),
399
+ [memoryVariant(120), memoryVariant(72)]
400
+ )
401
+ }
402
+
403
+ func testMemoryVariantsIgnoreExactAndIncompatibleAspectRatios() {
404
+ let target = memoryVariant(96)
405
+ let wide = OneKeyImageMemoryVariant(
406
+ requestURL: URL(string: "https://example.com/wide.png")!,
407
+ thumbnailPixelSize: CGSize(width: 120, height: 60)
408
+ )
409
+ XCTAssertEqual(
410
+ OneKeyImageMemoryVariantRegistry.orderedCandidates(
411
+ variants: [target, wide, memoryVariant(120)],
412
+ target: target,
413
+ excluding: [target]
414
+ ),
415
+ [memoryVariant(120)]
416
+ )
417
+ }
418
+
419
+ func testCrossSizeCenterPreviewUsesTemporaryScaling() {
420
+ XCTAssertTrue(
421
+ HybridOneKeyImage.shouldScaleCenterMemoryPreview(
422
+ contentFit: .center,
423
+ candidate: memoryVariant(48),
424
+ target: memoryVariant(96)
425
+ )
426
+ )
427
+ XCTAssertTrue(
428
+ HybridOneKeyImage.shouldScaleCenterMemoryPreview(
429
+ contentFit: .center,
430
+ candidate: memoryVariant(144),
431
+ target: memoryVariant(96)
432
+ )
433
+ )
434
+ XCTAssertFalse(
435
+ HybridOneKeyImage.shouldScaleCenterMemoryPreview(
436
+ contentFit: .center,
437
+ candidate: memoryVariant(96),
438
+ target: memoryVariant(96)
439
+ )
440
+ )
441
+ XCTAssertFalse(
442
+ HybridOneKeyImage.shouldScaleCenterMemoryPreview(
443
+ contentFit: .cover,
444
+ candidate: memoryVariant(48),
445
+ target: memoryVariant(96)
446
+ )
447
+ )
448
+ }
449
+
450
+ func testReplacingMemoryPreviewSkipsWholeViewFade() {
451
+ XCTAssertFalse(
452
+ HybridOneKeyImage.shouldAnimateLoadedImageTransition(
453
+ cacheType: .disk,
454
+ requestDuration: 1,
455
+ reduceMotionEnabled: false,
456
+ replacingMemoryPreview: true
457
+ )
458
+ )
459
+ XCTAssertTrue(
460
+ HybridOneKeyImage.shouldAnimateLoadedImageTransition(
461
+ cacheType: .disk,
462
+ requestDuration: 1,
463
+ reduceMotionEnabled: false,
464
+ replacingMemoryPreview: false
333
465
  )
334
466
  )
335
- // No bounds and no usable hint: nothing to probe with.
336
- XCTAssertNil(OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: nil))
337
- XCTAssertNil(OneKeyImageDecodeSizing.probeViewSize(bounds: .zero, resizeWidth: 0))
467
+ }
468
+
469
+ private func memoryVariant(_ size: Int) -> OneKeyImageMemoryVariant {
470
+ OneKeyImageMemoryVariant(
471
+ requestURL: URL(string: "https://example.com/image-\(size).png")!,
472
+ thumbnailPixelSize: CGSize(width: size, height: size)
473
+ )
474
+ }
475
+
476
+ func testDisplayedImageIsPreservedOnlyWhileShowingOne() {
477
+ XCTAssertTrue(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: true, hasImage: true))
478
+ XCTAssertFalse(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: true, hasImage: false))
479
+ XCTAssertFalse(HybridOneKeyImage.shouldPreserveDisplayedImage(isShowingImage: false, hasImage: true))
338
480
  }
339
481
 
340
482
  func testNoSizePreloadUsesCappedDecodeTarget() {
@@ -571,6 +713,55 @@ final class OneKeyImageInfrastructureTests: XCTestCase {
571
713
  )
572
714
  }
573
715
 
716
+ func testRecycledViewForgetsBothResizeHints() throws {
717
+ let image = HybridOneKeyImage()
718
+ image.sourceUri = "https://example.com/hinted.png"
719
+ image.resizeWidth = 48
720
+ image.resizeHeight = 64
721
+ image.prepareForRecycle()
722
+ XCTAssertNil(image.resizeWidth)
723
+ XCTAssertNil(image.resizeHeight)
724
+ XCTAssertNil(image.sourceUri)
725
+ }
726
+
727
+ func testViewAndPreloadPickTheSameTosRenditionForOneHint() throws {
728
+ // Preload: long edge of logicalViewSize(resizeWidth, resizeHeight).
729
+ // View: the same, before and after layout, so the request URL (and with it
730
+ // the cache key) is identical on both sides.
731
+ let raw = try XCTUnwrap(URL(string: "https://common.onekey-asset.com/token.png"))
732
+ let hint = try XCTUnwrap(OneKeyImageDecodeSizing.logicalViewSize(width: 48, height: 64))
733
+ let preloadDisplaySize = max(hint.width, hint.height)
734
+ for bounds in [CGSize.zero, CGSize(width: 48, height: 64), CGSize(width: 30, height: 30)] {
735
+ let viewDisplaySize = try XCTUnwrap(
736
+ OneKeyImageDecodeSizing.tosDisplaySize(bounds: bounds, resizeWidth: 48, resizeHeight: 64)
737
+ )
738
+ XCTAssertEqual(viewDisplaySize, preloadDisplaySize)
739
+ XCTAssertEqual(
740
+ OneKeyTosURL.optimized(
741
+ rawURL: raw, displaySize: viewDisplaySize, scale: 3, overscan: 1.1, hasCustomIdentity: false
742
+ ),
743
+ OneKeyTosURL.optimized(
744
+ rawURL: raw, displaySize: preloadDisplaySize, scale: 3, overscan: 1.1, hasCustomIdentity: false
745
+ )
746
+ )
747
+ }
748
+ // Width-only hint keeps the historical behaviour (the width itself).
749
+ XCTAssertEqual(
750
+ OneKeyImageDecodeSizing.tosDisplaySize(bounds: .zero, resizeWidth: 40, resizeHeight: nil),
751
+ 40
752
+ )
753
+ // No hint: the laid-out bounds decide; nothing before layout.
754
+ XCTAssertEqual(
755
+ OneKeyImageDecodeSizing.tosDisplaySize(
756
+ bounds: CGSize(width: 40, height: 64), resizeWidth: nil, resizeHeight: nil
757
+ ),
758
+ 64
759
+ )
760
+ XCTAssertNil(
761
+ OneKeyImageDecodeSizing.tosDisplaySize(bounds: .zero, resizeWidth: nil, resizeHeight: nil)
762
+ )
763
+ }
764
+
574
765
  func testSupersededRequestNeverPaintsRecycledView() throws {
575
766
  let image = HybridOneKeyImage()
576
767
  let imageView = try XCTUnwrap(image.view as? SDAnimatedImageView)
@@ -592,4 +783,50 @@ final class OneKeyImageInfrastructureTests: XCTestCase {
592
783
  )
593
784
  XCTAssertTrue(imageView.clearBufferWhenStopped)
594
785
  }
786
+
787
+ func testRoundPropUpdatesNativeCornerRadius() throws {
788
+ let image = HybridOneKeyImage()
789
+ let imageView = try XCTUnwrap(image.view as? SDAnimatedImageView)
790
+ imageView.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
791
+
792
+ image.sourceUri = "https://example.com/round.png"
793
+ image.round = true
794
+ image.afterUpdate()
795
+ imageView.layoutIfNeeded()
796
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
797
+
798
+ image.round = nil
799
+ image.afterUpdate()
800
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
801
+
802
+ image.round = false
803
+ image.afterUpdate()
804
+ XCTAssertEqual(imageView.layer.cornerRadius, 0)
805
+ }
806
+
807
+ func testRecycleKeepsRoundMaskUntilVisibleViewDetaches() throws {
808
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
809
+ let image = HybridOneKeyImage()
810
+ let imageView = try XCTUnwrap(image.view as? SDAnimatedImageView)
811
+ imageView.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
812
+ window.addSubview(imageView)
813
+ image.sourceUri = "https://example.com/round.png"
814
+ image.round = true
815
+ image.afterUpdate()
816
+ imageView.layoutIfNeeded()
817
+
818
+ // Fabric resets props in setter order before the view leaves the hierarchy.
819
+ image.round = false
820
+ image.sourceUri = nil
821
+ image.afterUpdate()
822
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
823
+
824
+ image.prepareForRecycle()
825
+
826
+ XCTAssertEqual(imageView.layer.cornerRadius, 20)
827
+
828
+ image.sourceUri = "https://example.com/reused.png"
829
+ image.afterUpdate()
830
+ XCTAssertEqual(imageView.layer.cornerRadius, 0)
831
+ }
595
832
  }
@@ -44,7 +44,9 @@ export function OneKeyImage({
44
44
  autoplay,
45
45
  recyclingKey,
46
46
  optimizeTos = true,
47
+ round = false,
47
48
  resizeWidth,
49
+ resizeHeight,
48
50
  overscan = 1.1,
49
51
  loadingStrategy = OneKeyImageLoadingStrategy.STATIC,
50
52
  placeholderColor,
@@ -59,7 +61,7 @@ export function OneKeyImage({
59
61
  }) {
60
62
  const normalized = useMemo(() => normalizeSource(source), [source]);
61
63
  const resolvedCachePolicy = cachePolicy ?? normalized?.cachePolicy ?? OneKeyImageCachePolicy.MEMORY_DISK;
62
- const identity = `${normalized?.uri ?? ''}|${JSON.stringify(normalized?.headers ?? {})}|${resolvedCachePolicy}|${recyclingKey ?? ''}|${contentFit}|${optimizeTos ? '1' : '0'}|${resizeWidth ?? ''}|${overscan}`;
64
+ const identity = `${normalized?.uri ?? ''}|${JSON.stringify(normalized?.headers ?? {})}|${resolvedCachePolicy}|${recyclingKey ?? ''}|${contentFit}|${optimizeTos ? '1' : '0'}|${resizeWidth ?? ''}|${resizeHeight ?? ''}|${overscan}`;
63
65
  const hasSource = normalized != null;
64
66
  const hasOverlay = placeholder != null || fallback != null;
65
67
  const [loadState, setLoadState] = useState({
@@ -198,7 +200,8 @@ export function OneKeyImage({
198
200
  borderStartEndRadius: flattenedStyle.borderStartEndRadius,
199
201
  borderEndStartRadius: flattenedStyle.borderEndStartRadius,
200
202
  borderEndEndRadius: flattenedStyle.borderEndEndRadius,
201
- zIndex: 1
203
+ zIndex: 1,
204
+ ...(round ? ROUND_CLIP : null)
202
205
  } : undefined;
203
206
  const native = /*#__PURE__*/createElement(NativeOneKeyImage, {
204
207
  ...viewProps,
@@ -212,14 +215,16 @@ export function OneKeyImage({
212
215
  autoplay: autoplay ?? Platform.OS !== 'android',
213
216
  recyclingKey,
214
217
  optimizeTos,
218
+ round,
215
219
  resizeWidth,
220
+ resizeHeight,
216
221
  overscan,
217
222
  loadingStrategy: placeholder == null ? loadingStrategy : OneKeyImageLoadingStrategy.NONE,
218
223
  placeholderColor
219
224
  });
220
225
  if (!shouldWrapNative) return native;
221
226
  return /*#__PURE__*/_jsxs(View, {
222
- style: [style, styles.overlayContainer, hasRoundedCorners ? styles.borderlessContainer : undefined],
227
+ style: [style, styles.overlayContainer, hasRoundedCorners ? styles.borderlessContainer : undefined, round ? ROUND_CLIP : undefined],
223
228
  collapsable: false,
224
229
  children: [native, effectiveState === 'loading' && placeholder != null ? /*#__PURE__*/_jsx(View, {
225
230
  pointerEvents: "none",
@@ -255,6 +260,24 @@ export const OneKeyImageCache = {
255
260
  clearDisk: () => nativeCache.clearDisk(),
256
261
  clearAll: () => nativeCache.clearAll()
257
262
  };
263
+
264
+ // `round` clips the native view to a circle: an oval path on Android
265
+ // (`OneKeyImage.kt` `addOval`), a `min(width, height) / 2` corner radius on iOS
266
+ // (`OneKeyImage.swift` `updateRoundMask`). The overlay container has to clip the
267
+ // same way, or a custom placeholder or fallback is painted square over a round
268
+ // image. A percentage radius says that without measuring the view, and covers
269
+ // every per-corner radius the caller's own style may have set.
270
+ const ROUND_CLIP = {
271
+ borderRadius: '50%',
272
+ borderTopLeftRadius: '50%',
273
+ borderTopRightRadius: '50%',
274
+ borderBottomLeftRadius: '50%',
275
+ borderBottomRightRadius: '50%',
276
+ borderStartStartRadius: '50%',
277
+ borderStartEndRadius: '50%',
278
+ borderEndStartRadius: '50%',
279
+ borderEndEndRadius: '50%'
280
+ };
258
281
  const styles = StyleSheet.create({
259
282
  overlayContainer: {
260
283
  overflow: 'hidden'
@@ -138,6 +138,15 @@ namespace margelo::nitro::onekeyimage {
138
138
  static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JBoolean> /* optimizeTos */)>("setOptimizeTos");
139
139
  method(_javaPart, optimizeTos.has_value() ? jni::JBoolean::valueOf(optimizeTos.value()) : nullptr);
140
140
  }
141
+ std::optional<bool> JHybridOneKeyImageSpec::getRound() {
142
+ static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JBoolean>()>("getRound");
143
+ auto __result = method(_javaPart);
144
+ return __result != nullptr ? std::make_optional(static_cast<bool>(__result->value())) : std::nullopt;
145
+ }
146
+ void JHybridOneKeyImageSpec::setRound(std::optional<bool> round) {
147
+ static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JBoolean> /* round */)>("setRound");
148
+ method(_javaPart, round.has_value() ? jni::JBoolean::valueOf(round.value()) : nullptr);
149
+ }
141
150
  std::optional<double> JHybridOneKeyImageSpec::getResizeWidth() {
142
151
  static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JDouble>()>("getResizeWidth");
143
152
  auto __result = method(_javaPart);
@@ -147,6 +156,15 @@ namespace margelo::nitro::onekeyimage {
147
156
  static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JDouble> /* resizeWidth */)>("setResizeWidth");
148
157
  method(_javaPart, resizeWidth.has_value() ? jni::JDouble::valueOf(resizeWidth.value()) : nullptr);
149
158
  }
159
+ std::optional<double> JHybridOneKeyImageSpec::getResizeHeight() {
160
+ static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JDouble>()>("getResizeHeight");
161
+ auto __result = method(_javaPart);
162
+ return __result != nullptr ? std::make_optional(__result->value()) : std::nullopt;
163
+ }
164
+ void JHybridOneKeyImageSpec::setResizeHeight(std::optional<double> resizeHeight) {
165
+ static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JDouble> /* resizeHeight */)>("setResizeHeight");
166
+ method(_javaPart, resizeHeight.has_value() ? jni::JDouble::valueOf(resizeHeight.value()) : nullptr);
167
+ }
150
168
  std::optional<double> JHybridOneKeyImageSpec::getOverscan() {
151
169
  static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JDouble>()>("getOverscan");
152
170
  auto __result = method(_javaPart);
@@ -66,8 +66,12 @@ namespace margelo::nitro::onekeyimage {
66
66
  void setRecyclingKey(const std::optional<std::string>& recyclingKey) override;
67
67
  std::optional<bool> getOptimizeTos() override;
68
68
  void setOptimizeTos(std::optional<bool> optimizeTos) override;
69
+ std::optional<bool> getRound() override;
70
+ void setRound(std::optional<bool> round) override;
69
71
  std::optional<double> getResizeWidth() override;
70
72
  void setResizeWidth(std::optional<double> resizeWidth) override;
73
+ std::optional<double> getResizeHeight() override;
74
+ void setResizeHeight(std::optional<double> resizeHeight) override;
71
75
  std::optional<double> getOverscan() override;
72
76
  void setOverscan(std::optional<double> overscan) override;
73
77
  std::optional<OneKeyImageLoadingStrategy> getLoadingStrategy() override;
@@ -93,11 +93,21 @@ void JHybridOneKeyImageStateUpdater::updateViewProps(jni::alias_ref<jni::JClass>
93
93
  : !newProps->optimizeTos.hasSameValue(oldProps->optimizeTos)) {
94
94
  hybridView->setOptimizeTos(newProps->optimizeTos.get());
95
95
  }
96
+ if (oldProps == nullptr
97
+ ? newProps->round.isProvided()
98
+ : !newProps->round.hasSameValue(oldProps->round)) {
99
+ hybridView->setRound(newProps->round.get());
100
+ }
96
101
  if (oldProps == nullptr
97
102
  ? newProps->resizeWidth.isProvided()
98
103
  : !newProps->resizeWidth.hasSameValue(oldProps->resizeWidth)) {
99
104
  hybridView->setResizeWidth(newProps->resizeWidth.get());
100
105
  }
106
+ if (oldProps == nullptr
107
+ ? newProps->resizeHeight.isProvided()
108
+ : !newProps->resizeHeight.hasSameValue(oldProps->resizeHeight)) {
109
+ hybridView->setResizeHeight(newProps->resizeHeight.get());
110
+ }
101
111
  if (oldProps == nullptr
102
112
  ? newProps->overscan.isProvided()
103
113
  : !newProps->overscan.hasSameValue(oldProps->overscan)) {
@@ -75,12 +75,24 @@ abstract class HybridOneKeyImageSpec: HybridView() {
75
75
  @set:Keep
76
76
  abstract var optimizeTos: Boolean?
77
77
 
78
+ @get:DoNotStrip
79
+ @get:Keep
80
+ @set:DoNotStrip
81
+ @set:Keep
82
+ abstract var round: Boolean?
83
+
78
84
  @get:DoNotStrip
79
85
  @get:Keep
80
86
  @set:DoNotStrip
81
87
  @set:Keep
82
88
  abstract var resizeWidth: Double?
83
89
 
90
+ @get:DoNotStrip
91
+ @get:Keep
92
+ @set:DoNotStrip
93
+ @set:Keep
94
+ abstract var resizeHeight: Double?
95
+
84
96
  @get:DoNotStrip
85
97
  @get:Keep
86
98
  @set:DoNotStrip