@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
@@ -12,8 +12,9 @@ Pod::Spec.new do |s|
12
12
  s.platforms = { :ios => min_ios_version_supported }
13
13
  s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" }
14
14
 
15
- s.source_files = ["ios/**/*.{swift,m,mm}", "cpp/**/*.{hpp,cpp}"]
15
+ s.source_files = ["ios/**/*.{h,swift,m,mm}", "cpp/**/*.{hpp,cpp}"]
16
16
  s.exclude_files = "ios/tests/**/*"
17
+ s.public_header_files = ["ios/OneKeyImageCoderBridge.h"]
17
18
 
18
19
  s.dependency "React-jsi"
19
20
  s.dependency "React-callinvoker"
@@ -0,0 +1,264 @@
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
+ package com.margelo.nitro.onekeyimage
21
+
22
+ import java.io.ByteArrayOutputStream
23
+ import java.io.DataOutputStream
24
+ import java.nio.ByteBuffer
25
+ import java.nio.charset.CodingErrorAction
26
+ import java.util.concurrent.CancellationException
27
+ import java.util.concurrent.FutureTask
28
+ import java.util.concurrent.atomic.AtomicBoolean
29
+ import java.util.zip.CRC32
30
+ import java.util.zip.Deflater
31
+ import java.util.zip.DeflaterOutputStream
32
+ import java.util.zip.DataFormatException
33
+ import java.util.zip.Inflater
34
+ import kotlin.math.floor
35
+
36
+ internal data class OneKeyBlockieAvatarModel(val uri: String)
37
+
38
+ internal object OneKeyBlockieAvatar {
39
+ const val URI_PREFIX = "onekey-avatar://blockie/v1/"
40
+ const val SIZE = 128
41
+ const val MAX_PNG_BYTES = SIZE * (SIZE / 4 + 1) + 128
42
+
43
+ fun isAvatarUri(uri: String): Boolean = uri.startsWith("onekey-avatar:")
44
+
45
+ fun decodeSeed(uri: String, isCancelled: () -> Boolean = { false }): String {
46
+ require(uri.startsWith(URI_PREFIX)) { "Unsupported avatar URI version" }
47
+ val encoded = uri.substring(URI_PREFIX.length)
48
+ require(encoded.isNotEmpty()) { "Avatar seed is empty" }
49
+ val bytes = ByteArrayOutputStream(encoded.length)
50
+ var index = 0
51
+ while (index < encoded.length) {
52
+ if (index % 256 == 0 && isCancelled()) throw CancellationException("Avatar generation cancelled")
53
+ val char = encoded[index]
54
+ if (char == '%') {
55
+ require(index + 2 < encoded.length) { "Invalid avatar percent encoding" }
56
+ val high = encoded[index + 1].digitToIntOrNull(16)
57
+ val low = encoded[index + 2].digitToIntOrNull(16)
58
+ require(high != null && low != null) { "Invalid avatar percent encoding" }
59
+ bytes.write((high shl 4) or low)
60
+ index += 3
61
+ } else {
62
+ require(char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char in "-_.!~*'()") {
63
+ "Invalid avatar URI component"
64
+ }
65
+ bytes.write(char.code)
66
+ index += 1
67
+ }
68
+ }
69
+ // The app already applies JavaScript lowercase; JVM Unicode casing differs.
70
+ return Charsets.UTF_8.newDecoder()
71
+ .onMalformedInput(CodingErrorAction.REPORT)
72
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
73
+ .decode(ByteBuffer.wrap(bytes.toByteArray()))
74
+ .toString()
75
+ }
76
+
77
+ // Only our fixed v1 encoder format is accepted, never arbitrary user PNG data.
78
+ fun isValidPng(bytes: ByteArray, isCancelled: () -> Boolean = { false }): Boolean {
79
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
80
+ if (bytes.size !in 93..MAX_PNG_BYTES) return false
81
+ val input = ByteBuffer.wrap(bytes)
82
+ if (input.long != 0x89504e470d0a1a0aUL.toLong()) return false
83
+ fun chunk(expectedType: String, expectedLength: Int? = null): ByteArray? {
84
+ if (input.remaining() < 12) return null
85
+ val length = input.int
86
+ if (length < 0 || length > input.remaining() - 8) return null
87
+ if (expectedLength != null && length != expectedLength) return null
88
+ val type = ByteArray(4).also(input::get)
89
+ if (!type.contentEquals(expectedType.toByteArray(Charsets.US_ASCII))) return null
90
+ val data = ByteArray(length).also(input::get)
91
+ val crc = CRC32().apply { update(type); update(data) }
92
+ if (input.int != crc.value.toInt()) return null
93
+ return data
94
+ }
95
+ val header = chunk("IHDR", 13) ?: return false
96
+ if (!header.contentEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128, 2, 3, 0, 0, 0))) return false
97
+ chunk("PLTE", 9) ?: return false
98
+ if (chunk("tRNS", 3)?.contentEquals(byteArrayOf(-1, -1, -1)) != true) return false
99
+ val compressed = chunk("IDAT") ?: return false
100
+ chunk("IEND", 0) ?: return false
101
+ if (input.hasRemaining()) return false
102
+
103
+ val expectedBytes = SIZE * (SIZE / 4 + 1)
104
+ val pixels = ByteArray(expectedBytes + 1)
105
+ val inflater = Inflater()
106
+ var count = 0
107
+ try {
108
+ inflater.setInput(compressed)
109
+ while (!inflater.finished() && count <= expectedBytes) {
110
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
111
+ val decoded = inflater.inflate(pixels, count, pixels.size - count)
112
+ if (decoded == 0) return false
113
+ count += decoded
114
+ }
115
+ if (!inflater.finished() || inflater.remaining != 0 || count != expectedBytes) return false
116
+ } catch (_: DataFormatException) {
117
+ return false
118
+ } finally {
119
+ inflater.end()
120
+ }
121
+ repeat(SIZE) { row ->
122
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
123
+ val offset = row * 33
124
+ if (pixels[offset] != 0.toByte()) return false
125
+ for (index in 1..32) {
126
+ val value = pixels[offset + index].toInt() and 0xff
127
+ if (
128
+ (value and 3) == 3 || ((value shr 2) and 3) == 3 ||
129
+ ((value shr 4) and 3) == 3 || ((value shr 6) and 3) == 3
130
+ ) return false
131
+ }
132
+ }
133
+ return true
134
+ }
135
+
136
+ fun png(uri: String, isCancelled: () -> Boolean = { false }): ByteArray {
137
+ fun checkCancelled() {
138
+ if (isCancelled()) throw CancellationException("Avatar generation cancelled")
139
+ }
140
+ checkCancelled()
141
+ val seed = decodeSeed(uri, isCancelled)
142
+ val state = IntArray(4)
143
+ seed.forEachIndexed { index, char ->
144
+ if (index % 256 == 0) checkCancelled()
145
+ val slot = index % 4
146
+ // Kotlin Int overflow and signed shr preserve JavaScript's bitwise PRNG.
147
+ state[slot] = (state[slot] shl 5) - state[slot] + char.code
148
+ }
149
+ fun random(): Double {
150
+ val t = state[0] xor (state[0] shl 11)
151
+ state[0] = state[1]
152
+ state[1] = state[2]
153
+ state[2] = state[3]
154
+ state[3] = state[3] xor (state[3] shr 19) xor t xor (t shr 8)
155
+ return (state[3].toLong() and 0xffffffffL).toDouble() / 2147483648.0
156
+ }
157
+ fun hue(p: Double, q: Double, value: Double): Double {
158
+ var t = value
159
+ if (t < 0) t += 1
160
+ if (t > 1) t -= 1
161
+ return when {
162
+ t < 1.0 / 6.0 -> p + (q - p) * 6 * t
163
+ t < 1.0 / 2.0 -> q
164
+ t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6
165
+ else -> p
166
+ }
167
+ }
168
+ fun color(): ByteArray {
169
+ val h = floor(random() * 360) / 360
170
+ val saturation = (random() * 60 + 40) / 100
171
+ val lightness = ((random() + random() + random() + random()) * 25) / 100
172
+ val q = if (lightness < 0.5) lightness * (1 + saturation)
173
+ else lightness + saturation - lightness * saturation
174
+ val p = 2 * lightness - q
175
+ return doubleArrayOf(
176
+ hue(p, q, h + 1.0 / 3.0), hue(p, q, h), hue(p, q, h - 1.0 / 3.0),
177
+ ).map { floor(it * 255 + 0.5).toInt().toByte() }.toByteArray()
178
+ }
179
+ val foreground = color()
180
+ val background = color()
181
+ val spot = color()
182
+ val pixels = ByteArray(SIZE * 33)
183
+ repeat(8) { row ->
184
+ checkCancelled()
185
+ val line = ByteArray(33)
186
+ repeat(4) { column ->
187
+ val value = floor(random() * 2.3).toInt()
188
+ val paletteIndex = if (value == 0) 0 else if (value == 1) 1 else 2
189
+ val packed = (paletteIndex * 0x55).toByte()
190
+ line.fill(packed, 1 + column * 4, 1 + (column + 1) * 4)
191
+ line.fill(packed, 1 + (7 - column) * 4, 1 + (8 - column) * 4)
192
+ }
193
+ repeat(16) { line.copyInto(pixels, (row * 16 + it) * 33) }
194
+ }
195
+ val compressed = ByteArrayOutputStream()
196
+ val deflater = Deflater(Deflater.BEST_SPEED)
197
+ try {
198
+ DeflaterOutputStream(compressed, deflater).use { it.write(pixels) }
199
+ } finally {
200
+ deflater.end()
201
+ }
202
+ checkCancelled()
203
+ val result = ByteArrayOutputStream()
204
+ DataOutputStream(result).use { output ->
205
+ output.write(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10))
206
+ fun chunk(type: String, data: ByteArray) {
207
+ val typeBytes = type.toByteArray(Charsets.US_ASCII)
208
+ output.writeInt(data.size)
209
+ output.write(typeBytes)
210
+ output.write(data)
211
+ val crc = CRC32().apply { update(typeBytes); update(data) }
212
+ output.writeInt(crc.value.toInt())
213
+ }
214
+ chunk("IHDR", byteArrayOf(0, 0, 0, 128.toByte(), 0, 0, 0, 128.toByte(), 2, 3, 0, 0, 0))
215
+ chunk("PLTE", background + foreground + spot)
216
+ chunk("tRNS", byteArrayOf(-1, -1, -1))
217
+ chunk("IDAT", compressed.toByteArray())
218
+ chunk("IEND", byteArrayOf())
219
+ }
220
+ return result.toByteArray()
221
+ }
222
+ }
223
+
224
+ /** Only overlapping source fetches are retained; Glide owns all lasting caches. */
225
+ internal class OneKeyAvatarInFlight(
226
+ private val generate: (String, () -> Boolean) -> ByteArray,
227
+ ) {
228
+ internal class Work(uri: String, generate: (String, () -> Boolean) -> ByteArray) {
229
+ val cancelled = AtomicBoolean(false)
230
+ val task = FutureTask { generate(uri, cancelled::get) }
231
+ var references = 0
232
+ }
233
+
234
+ private val pending = mutableMapOf<String, Work>()
235
+
236
+ internal inner class Lease(private val uri: String, internal val work: Work) {
237
+ private val released = AtomicBoolean(false)
238
+
239
+ fun bytes(): ByteArray {
240
+ work.task.run()
241
+ return work.task.get()
242
+ }
243
+
244
+ fun release() {
245
+ if (!released.compareAndSet(false, true)) return
246
+ synchronized(pending) {
247
+ work.references -= 1
248
+ if (work.references == 0) {
249
+ if (pending[uri] === work) pending.remove(uri)
250
+ if (!work.task.isDone) {
251
+ work.cancelled.set(true)
252
+ work.task.cancel(false)
253
+ }
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ fun acquire(uri: String): Lease = synchronized(pending) {
260
+ val work = pending.getOrPut(uri) { Work(uri, generate) }
261
+ work.references += 1
262
+ Lease(uri, work)
263
+ }
264
+ }
@@ -0,0 +1,173 @@
1
+ package com.margelo.nitro.onekeyimage
2
+
3
+ import com.bumptech.glide.Priority
4
+ import com.bumptech.glide.load.DataSource
5
+ import com.bumptech.glide.load.Option
6
+ import com.bumptech.glide.load.Options
7
+ import com.bumptech.glide.load.data.DataFetcher
8
+ import com.bumptech.glide.load.engine.DiskCacheStrategy
9
+ import com.bumptech.glide.load.model.ModelLoader
10
+ import com.bumptech.glide.load.model.ModelLoaderFactory
11
+ import com.bumptech.glide.load.model.MultiModelLoaderFactory
12
+ import com.bumptech.glide.signature.ObjectKey
13
+ import com.bumptech.glide.request.RequestOptions
14
+ import java.io.ByteArrayInputStream
15
+ import java.io.ByteArrayOutputStream
16
+ import java.io.File
17
+ import java.io.IOException
18
+ import java.nio.ByteBuffer
19
+ import java.util.concurrent.ExecutionException
20
+ import java.util.concurrent.CancellationException
21
+
22
+ // Only source PNGs are persisted; transformed resources can have other dimensions.
23
+ internal fun oneKeyImageMemoryDiskStrategy(uri: String): DiskCacheStrategy =
24
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) DiskCacheStrategy.DATA else DiskCacheStrategy.AUTOMATIC
25
+
26
+ internal val oneKeyAvatarCacheFileOption: Option<Boolean> =
27
+ Option.memory("onekey-image.blockie-source-cache-v1", false)
28
+
29
+ internal fun RequestOptions.withOneKeyAvatarCache(uri: String): RequestOptions =
30
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) set(oneKeyAvatarCacheFileOption, true) else this
31
+
32
+ internal class OneKeyAvatarCacheFileLoaderFactory : ModelLoaderFactory<File, ByteBuffer> {
33
+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<File, ByteBuffer> =
34
+ OneKeyAvatarCacheFileLoader()
35
+
36
+ override fun teardown() = Unit
37
+ }
38
+
39
+ private class OneKeyAvatarCacheFileLoader : ModelLoader<File, ByteBuffer> {
40
+ override fun handles(model: File): Boolean = true
41
+
42
+ override fun buildLoadData(
43
+ model: File,
44
+ width: Int,
45
+ height: Int,
46
+ options: Options,
47
+ ): ModelLoader.LoadData<ByteBuffer>? =
48
+ if (options.get(oneKeyAvatarCacheFileOption) == true) {
49
+ ModelLoader.LoadData(ObjectKey(model), OneKeyAvatarCacheFileFetcher(model))
50
+ } else null
51
+ }
52
+
53
+ // Serialize validation/removal so two failed reads cannot delete a newly repaired file.
54
+ private val avatarCacheFileLock = Any()
55
+
56
+ private class OneKeyAvatarCacheFileFetcher(private val file: File) : DataFetcher<ByteBuffer> {
57
+ @Volatile
58
+ private var cancelled = false
59
+
60
+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
61
+ val bytes = try {
62
+ synchronized(avatarCacheFileLock) {
63
+ checkCancelled()
64
+ val bytes = readBounded()
65
+ if (bytes == null || !OneKeyBlockieAvatar.isValidPng(bytes) { cancelled }) {
66
+ checkCancelled()
67
+ if (file.exists() && !file.delete()) throw IOException("Cannot remove invalid avatar cache entry")
68
+ // Glide's journal sees the missing clean file; SOURCE can write it again.
69
+ throw IOException("Invalid avatar cache entry removed")
70
+ }
71
+ bytes
72
+ }
73
+ } catch (error: Exception) {
74
+ if (!cancelled) callback.onLoadFailed(error)
75
+ return
76
+ }
77
+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(bytes))
78
+ }
79
+
80
+ private fun readBounded(): ByteArray? {
81
+ if (file.length() > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null
82
+ return file.inputStream().use { input ->
83
+ val output = ByteArrayOutputStream()
84
+ val buffer = ByteArray(1024)
85
+ while (true) {
86
+ checkCancelled()
87
+ val count = input.read(buffer)
88
+ if (count < 0) break
89
+ if (output.size() + count > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null
90
+ output.write(buffer, 0, count)
91
+ }
92
+ output.toByteArray()
93
+ }
94
+ }
95
+
96
+ private fun checkCancelled() {
97
+ if (cancelled) throw CancellationException("Avatar cache read cancelled")
98
+ }
99
+
100
+ override fun cancel() { cancelled = true }
101
+ override fun cleanup() = cancel()
102
+ override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
103
+ override fun getDataSource(): DataSource = DataSource.LOCAL
104
+ }
105
+
106
+ internal class OneKeyBlockieAvatarLoaderFactory : ModelLoaderFactory<OneKeyBlockieAvatarModel, ByteBuffer> {
107
+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<OneKeyBlockieAvatarModel, ByteBuffer> =
108
+ OneKeyBlockieAvatarLoader()
109
+
110
+ override fun teardown() = Unit
111
+ }
112
+
113
+ private val avatarRequests = OneKeyAvatarInFlight { uri, isCancelled ->
114
+ OneKeyImageSafety.requireEncodedLength(uri.length.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES)
115
+ val png = OneKeyBlockieAvatar.png(uri, isCancelled)
116
+ OneKeyImageSafety.requireEncodedLength(png.size.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES)
117
+ OneKeyEncodedImageInspector.inspect(ByteArrayInputStream(png))
118
+ png
119
+ }
120
+
121
+ private class OneKeyBlockieAvatarLoader : ModelLoader<OneKeyBlockieAvatarModel, ByteBuffer> {
122
+ override fun handles(model: OneKeyBlockieAvatarModel): Boolean = true
123
+
124
+ override fun buildLoadData(
125
+ model: OneKeyBlockieAvatarModel,
126
+ width: Int,
127
+ height: Int,
128
+ options: Options,
129
+ ): ModelLoader.LoadData<ByteBuffer> = ModelLoader.LoadData(
130
+ OneKeyImageSafetyVersionedKey(ObjectKey(model)),
131
+ OneKeyBlockieAvatarFetcher(model.uri),
132
+ )
133
+ }
134
+
135
+ internal class OneKeyBlockieAvatarFetcher(
136
+ private val uri: String,
137
+ private val requests: OneKeyAvatarInFlight = avatarRequests,
138
+ ) : DataFetcher<ByteBuffer> {
139
+ @Volatile
140
+ private var cancelled = false
141
+ private var lease: OneKeyAvatarInFlight.Lease? = null
142
+
143
+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
144
+ val request = synchronized(this) {
145
+ if (cancelled) return
146
+ requests.acquire(uri).also { lease = it }
147
+ }
148
+ // Glide invokes loadData on its source executor, never on the UI thread.
149
+ val png = try {
150
+ request.bytes()
151
+ } catch (error: Exception) {
152
+ val cause = if (error is ExecutionException) error.cause else error
153
+ if (!cancelled) callback.onLoadFailed(cause as? Exception ?: IOException("Avatar generation failed"))
154
+ return
155
+ }
156
+ // Glide may cancel while holding its EngineJob lock; do not call back under ours.
157
+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(png))
158
+ }
159
+
160
+ override fun cancel() = release()
161
+ override fun cleanup() = release()
162
+
163
+ private fun release() {
164
+ val request = synchronized(this) {
165
+ cancelled = true
166
+ lease.also { lease = null }
167
+ }
168
+ request?.release()
169
+ }
170
+
171
+ override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
172
+ override fun getDataSource(): DataSource = DataSource.LOCAL
173
+ }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.onekeyimage
2
2
 
3
+ import android.app.Activity
3
4
  import android.content.Context
4
5
  import android.content.ContextWrapper
5
6
  import android.graphics.Canvas
@@ -9,7 +10,6 @@ import android.graphics.drawable.Animatable
9
10
  import android.graphics.drawable.Drawable
10
11
  import android.view.View
11
12
  import android.widget.ImageView
12
- import androidx.fragment.app.FragmentActivity
13
13
  import com.bumptech.glide.Glide
14
14
  import com.bumptech.glide.load.DataSource
15
15
  import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -25,15 +25,15 @@ import com.margelo.nitro.skeleton.OneKeySkeletonRenderer
25
25
  import com.margelo.nitro.views.RecyclableView
26
26
  import kotlin.math.ceil
27
27
 
28
- private fun Context.findFragmentActivity(): FragmentActivity? {
28
+ private fun Context.findActivity(): Activity? {
29
29
  var currentContext: Context? = this
30
30
  while (currentContext is ContextWrapper) {
31
- if (currentContext is FragmentActivity) return currentContext
31
+ if (currentContext is Activity) return currentContext
32
32
  val baseContext = currentContext.baseContext
33
33
  if (baseContext === currentContext) return null
34
34
  currentContext = baseContext
35
35
  }
36
- return currentContext as? FragmentActivity
36
+ return currentContext as? Activity
37
37
  }
38
38
 
39
39
  private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(context) {
@@ -118,6 +118,7 @@ internal fun oneKeyImageRequestSignature(
118
118
  width: Int,
119
119
  height: Int,
120
120
  density: Float,
121
+ resizeWidth: Double? = null,
121
122
  ): String = listOf(
122
123
  rawUrl,
123
124
  sourceHeadersJson.orEmpty(),
@@ -126,6 +127,7 @@ internal fun oneKeyImageRequestSignature(
126
127
  (contentFit ?: OneKeyImageContentFit.COVER).name,
127
128
  optimizeTos.toString(),
128
129
  overscan.toString(),
130
+ resizeWidth.toString(),
129
131
  width.toString(),
130
132
  height.toString(),
131
133
  density.toString(),
@@ -140,9 +142,8 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
140
142
  // The Activity outlives ScreenStack Fragments but still provides bounded
141
143
  // background and destruction lifecycle handling for image requests.
142
144
  private val activityRequestManager by lazy(LazyThreadSafetyMode.NONE) {
143
- val activity = context.findFragmentActivity()
144
- ?: (context.currentActivity as? FragmentActivity)
145
- Glide.with(requireNotNull(activity) { "A FragmentActivity is required to load images" })
145
+ val activity = context.findActivity() ?: context.currentActivity
146
+ Glide.with(requireNotNull(activity) { "An Activity is required to load images" })
146
147
  }
147
148
  private var loadRunnable: Runnable? = null
148
149
  private var currentTarget: CustomViewTarget<OneKeyImageHostView, Drawable>? = null
@@ -224,6 +225,12 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
224
225
  field = value
225
226
  requestIdentityChanged()
226
227
  }
228
+ override var resizeWidth: Double? = null
229
+ set(value) {
230
+ if (field == value) return
231
+ field = value
232
+ requestIdentityChanged()
233
+ }
227
234
  override var loadingStrategy: OneKeyImageLoadingStrategy? = OneKeyImageLoadingStrategy.STATIC
228
235
  set(value) {
229
236
  field = value
@@ -307,6 +314,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
307
314
  autoplay = false
308
315
  recyclingKey = null
309
316
  optimizeTos = true
317
+ resizeWidth = null
310
318
  overscan = 1.1
311
319
  loadingStrategy = OneKeyImageLoadingStrategy.STATIC
312
320
  onLoadStart = null
@@ -360,6 +368,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
360
368
  width = hostView.width,
361
369
  height = hostView.height,
362
370
  density = hostView.resources.displayMetrics.density,
371
+ resizeWidth = resizeWidth,
363
372
  )
364
373
  if (!force && signature == lastSignature) return
365
374
  lastSignature = signature
@@ -371,9 +380,11 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
371
380
  val customIdentity = OneKeyImageModel.headers(sourceHeadersJson).isNotEmpty()
372
381
  val optimizedUrl = if (optimizeTos != false) {
373
382
  val density = hostView.resources.displayMetrics.density.coerceAtLeast(1f)
383
+ val displaySize = resizeWidth?.takeIf { it.isFinite() && it > 0.0 }
384
+ ?: (maxOf(hostView.width, hostView.height) / density.toDouble())
374
385
  OneKeyTosUrl.optimized(
375
386
  rawUrl,
376
- ceil(maxOf(hostView.width, hostView.height) / density.toDouble()).toInt(),
387
+ ceil(displaySize).toInt(),
377
388
  density,
378
389
  overscan ?: 1.1,
379
390
  customIdentity,
@@ -485,7 +496,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
485
496
  requestManager()
486
497
  .asDrawable()
487
498
  .load(OneKeyImageModel.build(requestUrl, headersJson))
488
- .apply(requestOptions(policy))
499
+ .apply(requestOptions(policy, requestUrl))
489
500
  .override(decodeDimensions.width, decodeDimensions.height)
490
501
  .listener(object : RequestListener<Drawable> {
491
502
  override fun onLoadFailed(
@@ -513,12 +524,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
513
524
  .into(target)
514
525
  }
515
526
 
516
- private fun requestOptions(policy: OneKeyImageCachePolicy): RequestOptions {
527
+ private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions {
517
528
  val options = RequestOptions()
529
+ .withOneKeyAvatarCache(uri)
518
530
  .dontTransform()
519
531
  .downsample(OneKeyImageSafeDownsampleStrategy)
520
532
  return when (policy) {
521
- OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
533
+ OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri))
522
534
  OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE)
523
535
  OneKeyImageCachePolicy.DISK -> options
524
536
  .diskCacheStrategy(DiskCacheStrategy.DATA)
@@ -22,11 +22,12 @@ class HybridOneKeyImageCache : HybridOneKeyImageCacheSpec() {
22
22
  return@forEach
23
23
  }
24
24
  val baseOptions = RequestOptions()
25
+ .withOneKeyAvatarCache(source.uri)
25
26
  .dontTransform()
26
27
  .downsample(OneKeyImageSafeDownsampleStrategy)
27
28
  val options = when (source.cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK) {
28
29
  OneKeyImageCachePolicy.MEMORY_DISK -> baseOptions
29
- .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
30
+ .diskCacheStrategy(oneKeyImageMemoryDiskStrategy(source.uri))
30
31
  OneKeyImageCachePolicy.MEMORY -> baseOptions
31
32
  .diskCacheStrategy(DiskCacheStrategy.NONE)
32
33
  OneKeyImageCachePolicy.DISK -> baseOptions
@@ -28,6 +28,7 @@ import com.github.penfeizhou.animation.glide.StreamAnimationDecoder
28
28
  import java.io.ByteArrayOutputStream
29
29
  import java.io.ByteArrayInputStream
30
30
  import java.io.IOException
31
+ import java.io.File
31
32
  import java.io.InputStream
32
33
  import java.nio.ByteBuffer
33
34
 
@@ -50,6 +51,16 @@ internal object OneKeyImageGlideRegistry {
50
51
  val glide = Glide.get(appContext)
51
52
  val registry = glide.registry
52
53
 
54
+ registry.prepend(
55
+ File::class.java,
56
+ ByteBuffer::class.java,
57
+ OneKeyAvatarCacheFileLoaderFactory(),
58
+ )
59
+ registry.prepend(
60
+ OneKeyBlockieAvatarModel::class.java,
61
+ ByteBuffer::class.java,
62
+ OneKeyBlockieAvatarLoaderFactory(),
63
+ )
53
64
  registry.prepend(
54
65
  OneKeyImageDataUriModel::class.java,
55
66
  ByteBuffer::class.java,
@@ -25,6 +25,7 @@ internal object OneKeyImageModel {
25
25
  }
26
26
 
27
27
  fun build(uri: String, headersJson: String?): Any {
28
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) return OneKeyBlockieAvatarModel(uri)
28
29
  if (uri.startsWith("data:")) return OneKeyImageDataUriModel(uri)
29
30
  if (!uri.startsWith("http://") && !uri.startsWith("https://")) {
30
31
  return OneKeyImageLocalModel(Uri.parse(uri))
@@ -28,7 +28,12 @@ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context
28
28
  optimizeTos: Boolean,
29
29
  overscan: Double,
30
30
  loadingStrategy: String,
31
+ onLoad: (() -> Unit)? = null,
32
+ onError: (() -> Unit)? = null,
31
33
  ) {
34
+ // OneKey patch: Let reusable cells display their own success and fallback visuals.
35
+ image.onLoad = { _, _, _ -> onLoad?.invoke() }
36
+ image.onError = { _ -> onError?.invoke() }
32
37
  image.sourceHeadersJson = sourceHeadersJson
33
38
  image.variant = when (variant) {
34
39
  "token" -> OneKeyImageVariant.TOKEN
@@ -3,11 +3,12 @@ package com.margelo.nitro.onekeyimage
3
3
  import java.net.URI
4
4
  import java.net.URLDecoder
5
5
  import java.nio.charset.StandardCharsets
6
- import kotlin.math.ceil
7
6
 
8
7
  internal object OneKeyTosUrl {
9
- internal val widthBuckets = intArrayOf(
10
- 32, 40, 48, 64, 96, 128, 160, 200, 256, 320, 480, 640, 960, 1280,
8
+ // Select by layout size first. Density can only choose between this tier's renditions.
9
+ private val sizeTiers = arrayOf(
10
+ Triple(48, 96, 160), Triple(96, 192, 320), Triple(192, 384, 640),
11
+ Triple(384, 768, 1280), Triple(Int.MAX_VALUE, 1280, 1280),
11
12
  )
12
13
 
13
14
  fun optimized(
@@ -41,14 +42,12 @@ internal object OneKeyTosUrl {
41
42
  }
42
43
 
43
44
  internal fun selectWidthBucket(displaySize: Int, density: Float, overscan: Double): Int {
44
- val normalizedDensity = if (density.isFinite()) density.coerceIn(1f, MAX_DPR) else 1f
45
- val normalizedOverscan = if (overscan.isFinite()) overscan.coerceAtLeast(1.0) else 1.0
46
- val requestedPixels = ceil(displaySize * normalizedDensity * normalizedOverscan)
47
- if (!requestedPixels.isFinite() || requestedPixels >= widthBuckets.last()) {
48
- return widthBuckets.last()
49
- }
50
- val requested = requestedPixels.toInt()
51
- return widthBuckets.firstOrNull { it >= requested } ?: widthBuckets.last()
45
+ val tier = sizeTiers.first { displaySize <= it.first }
46
+ val normalizedDensity = if (density.isFinite()) density.coerceIn(1f, MAX_DPR).toDouble() else 1.0
47
+ val normalizedOverscan = if (overscan.isFinite()) overscan.coerceAtLeast(1.0) else 1.1
48
+ // Fixed renditions already account for the default margin. Custom margins stay within the tier.
49
+ val requestedDensity = normalizedDensity * (normalizedOverscan / 1.1)
50
+ return if (requestedDensity > 2) tier.third else tier.second
52
51
  }
53
52
 
54
53
  private fun unsupportedExtension(path: String): Boolean {