@onekeyfe/react-native-image 3.0.105 → 3.0.106
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 +10 -10
- 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/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt +327 -0
- package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt +214 -0
- package/ios/OneKeyAvatarImageLoader.swift +217 -0
- package/ios/OneKeyBlockie.swift +126 -0
- package/ios/OneKeyImage.swift +4 -1
- 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/tests/OneKeyAvatarImageTests.swift +49 -0
- package/package.json +2 -2
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) {
|
|
@@ -140,9 +140,8 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
|
|
|
140
140
|
// The Activity outlives ScreenStack Fragments but still provides bounded
|
|
141
141
|
// background and destruction lifecycle handling for image requests.
|
|
142
142
|
private val activityRequestManager by lazy(LazyThreadSafetyMode.NONE) {
|
|
143
|
-
val activity = context.
|
|
144
|
-
|
|
145
|
-
Glide.with(requireNotNull(activity) { "A FragmentActivity is required to load images" })
|
|
143
|
+
val activity = context.findActivity() ?: context.currentActivity
|
|
144
|
+
Glide.with(requireNotNull(activity) { "An Activity is required to load images" })
|
|
146
145
|
}
|
|
147
146
|
private var loadRunnable: Runnable? = null
|
|
148
147
|
private var currentTarget: CustomViewTarget<OneKeyImageHostView, Drawable>? = null
|
|
@@ -485,7 +484,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
|
|
|
485
484
|
requestManager()
|
|
486
485
|
.asDrawable()
|
|
487
486
|
.load(OneKeyImageModel.build(requestUrl, headersJson))
|
|
488
|
-
.apply(requestOptions(policy))
|
|
487
|
+
.apply(requestOptions(policy, requestUrl))
|
|
489
488
|
.override(decodeDimensions.width, decodeDimensions.height)
|
|
490
489
|
.listener(object : RequestListener<Drawable> {
|
|
491
490
|
override fun onLoadFailed(
|
|
@@ -513,12 +512,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
|
|
|
513
512
|
.into(target)
|
|
514
513
|
}
|
|
515
514
|
|
|
516
|
-
private fun requestOptions(policy: OneKeyImageCachePolicy): RequestOptions {
|
|
515
|
+
private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions {
|
|
517
516
|
val options = RequestOptions()
|
|
517
|
+
.withOneKeyAvatarCache(uri)
|
|
518
518
|
.dontTransform()
|
|
519
519
|
.downsample(OneKeyImageSafeDownsampleStrategy)
|
|
520
520
|
return when (policy) {
|
|
521
|
-
OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(
|
|
521
|
+
OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri))
|
|
522
522
|
OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
523
523
|
OneKeyImageCachePolicy.DISK -> options
|
|
524
524
|
.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
|