@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.
- package/OneKeyImage.podspec +2 -1
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt +264 -0
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt +173 -0
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +23 -11
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +2 -1
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt +11 -0
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +5 -0
- package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyTosUrl.kt +10 -11
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt +327 -0
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt +214 -0
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +10 -1
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyTosUrlTest.kt +42 -10
- package/ios/OneKeyAvatarImageLoader.swift +217 -0
- package/ios/OneKeyBlockie.swift +126 -0
- package/ios/OneKeyImage.swift +14 -2
- package/ios/OneKeyImageCache.swift +11 -1
- package/ios/OneKeyImageCoderBridge.h +14 -0
- package/ios/OneKeyImageCoderBridge.m +25 -0
- package/ios/OneKeyImageRequestContext.swift +14 -15
- package/ios/OneKeyImageReusableView.swift +6 -1
- package/ios/OneKeyTosURL.swift +13 -14
- package/ios/tests/OneKeyAvatarImageTests.swift +49 -0
- package/ios/tests/OneKeyTosURLTests.swift +43 -3
- package/lib/module/index.js +3 -1
- package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
- package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
- package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
- package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
- package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
- package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
- package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
- package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
- package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
- package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
- package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
- package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
- package/lib/typescript/src/OneKeyImage.nitro.d.ts +2 -0
- package/lib/typescript/src/index.d.ts +1 -1
- package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
- package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
- package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
- package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
- package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
- package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
- package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
- package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
- package/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
- package/package.json +2 -2
- package/src/OneKeyImage.nitro.ts +2 -0
- package/src/index.tsx +3 -1
package/OneKeyImage.podspec
CHANGED
|
@@ -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.
|
|
28
|
+
private fun Context.findActivity(): Activity? {
|
|
29
29
|
var currentContext: Context? = this
|
|
30
30
|
while (currentContext is ContextWrapper) {
|
|
31
|
-
if (currentContext is
|
|
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?
|
|
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.
|
|
144
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
10
|
-
|
|
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
|
|
45
|
-
val
|
|
46
|
-
val
|
|
47
|
-
|
|
48
|
-
|
|
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 {
|