@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
package com.margelo.nitro.onekeyimage
|
|
2
|
+
|
|
3
|
+
import androidx.core.util.Pools
|
|
4
|
+
import com.bumptech.glide.Priority
|
|
5
|
+
import com.bumptech.glide.disklrucache.DiskLruCache
|
|
6
|
+
import com.bumptech.glide.load.DataSource
|
|
7
|
+
import com.bumptech.glide.load.EncodeStrategy
|
|
8
|
+
import com.bumptech.glide.load.Options
|
|
9
|
+
import com.bumptech.glide.load.data.DataFetcher
|
|
10
|
+
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
11
|
+
import com.bumptech.glide.load.model.MultiModelLoaderFactory
|
|
12
|
+
import org.junit.Assert.assertArrayEquals
|
|
13
|
+
import org.junit.Assert.assertEquals
|
|
14
|
+
import org.junit.Assert.assertFalse
|
|
15
|
+
import org.junit.Assert.assertNotEquals
|
|
16
|
+
import org.junit.Assert.assertNull
|
|
17
|
+
import org.junit.Assert.assertTrue
|
|
18
|
+
import org.junit.Test
|
|
19
|
+
import java.io.IOException
|
|
20
|
+
import java.io.File
|
|
21
|
+
import java.nio.file.Files
|
|
22
|
+
import java.nio.ByteBuffer
|
|
23
|
+
import java.security.MessageDigest
|
|
24
|
+
import java.util.concurrent.CountDownLatch
|
|
25
|
+
import java.util.concurrent.Executors
|
|
26
|
+
import java.util.concurrent.TimeUnit
|
|
27
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
28
|
+
|
|
29
|
+
class OneKeyBlockieAvatarLoaderTest {
|
|
30
|
+
private val uri = "onekey-avatar://blockie/v1/0x1234"
|
|
31
|
+
|
|
32
|
+
private class Callback : DataFetcher.DataCallback<ByteBuffer> {
|
|
33
|
+
var data: ByteBuffer? = null
|
|
34
|
+
var error: Exception? = null
|
|
35
|
+
override fun onDataReady(data: ByteBuffer?) { this.data = data }
|
|
36
|
+
override fun onLoadFailed(error: Exception) { this.error = error }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun memoryDiskAvatarRequestsCacheTheOriginalLocalPngAcrossSizes() {
|
|
41
|
+
val strategy = oneKeyImageMemoryDiskStrategy(uri)
|
|
42
|
+
assertTrue(strategy.isDataCacheable(DataSource.LOCAL))
|
|
43
|
+
assertTrue(strategy.decodeCachedData())
|
|
44
|
+
assertFalse(strategy.decodeCachedResource())
|
|
45
|
+
assertFalse(strategy.isResourceCacheable(false, DataSource.LOCAL, EncodeStrategy.TRANSFORMED))
|
|
46
|
+
assertFalse(strategy.isDataCacheable(DataSource.DATA_DISK_CACHE))
|
|
47
|
+
assertFalse(DiskCacheStrategy.ALL.isDataCacheable(DataSource.LOCAL))
|
|
48
|
+
assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("https://example.com/image.png"))
|
|
49
|
+
assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("data:image/png;base64,AA=="))
|
|
50
|
+
assertFalse(DiskCacheStrategy.AUTOMATIC.isDataCacheable(DataSource.LOCAL))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test
|
|
54
|
+
fun originalPngCacheKeyIsStableAcrossRequestedDecodeDimensions() {
|
|
55
|
+
val factory = MultiModelLoaderFactory(Pools.SynchronizedPool<List<Throwable>>(1))
|
|
56
|
+
val loader = OneKeyBlockieAvatarLoaderFactory().build(factory)
|
|
57
|
+
val first = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 96, 96, Options())!!
|
|
58
|
+
val second = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 128, 128, Options())!!
|
|
59
|
+
val other = loader.buildLoadData(OneKeyBlockieAvatarModel(uri + "0"), 96, 96, Options())!!
|
|
60
|
+
assertEquals(first.sourceKey, second.sourceKey)
|
|
61
|
+
assertNotEquals(first.sourceKey, other.sourceKey)
|
|
62
|
+
val firstDigest = MessageDigest.getInstance("SHA-256").also(first.sourceKey::updateDiskCacheKey).digest()
|
|
63
|
+
val secondDigest = MessageDigest.getInstance("SHA-256").also(second.sourceKey::updateDiskCacheKey).digest()
|
|
64
|
+
assertArrayEquals(firstDigest, secondDigest)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private fun cacheFetcher(file: File): DataFetcher<ByteBuffer> {
|
|
68
|
+
val factory = MultiModelLoaderFactory(Pools.SynchronizedPool<List<Throwable>>(1))
|
|
69
|
+
val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory)
|
|
70
|
+
return loader.buildLoadData(file, 96, 96, Options().set(oneKeyAvatarCacheFileOption, true))!!.fetcher
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun cacheValidationIsNotRegisteredForOrdinaryImageRequests() {
|
|
75
|
+
val factory = MultiModelLoaderFactory(Pools.SynchronizedPool<List<Throwable>>(1))
|
|
76
|
+
val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory)
|
|
77
|
+
assertNull(loader.buildLoadData(File("ordinary-network-image"), 96, 96, Options()))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Test
|
|
81
|
+
fun validCachedSourceIsReturnedWithoutRemovingOrRewritingIt() {
|
|
82
|
+
val directory = Files.createTempDirectory("avatar-cache-valid").toFile()
|
|
83
|
+
val file = File(directory, "avatar.0")
|
|
84
|
+
val bytes = OneKeyBlockieAvatar.png(uri)
|
|
85
|
+
try {
|
|
86
|
+
file.writeBytes(bytes)
|
|
87
|
+
val modified = file.lastModified()
|
|
88
|
+
val fetcher = cacheFetcher(file)
|
|
89
|
+
val callback = Callback()
|
|
90
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
91
|
+
fetcher.cleanup()
|
|
92
|
+
assertNull(callback.error)
|
|
93
|
+
assertArrayEquals(bytes, callback.data!!.array())
|
|
94
|
+
assertEquals(modified, file.lastModified())
|
|
95
|
+
assertArrayEquals(bytes, file.readBytes())
|
|
96
|
+
} finally {
|
|
97
|
+
directory.deleteRecursively()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun corruptEntryRemovalAllowsRealGlideJournalRewriteAndRestartReuse() {
|
|
103
|
+
val directory = Files.createTempDirectory("avatar-cache-journal").toFile()
|
|
104
|
+
val bytes = OneKeyBlockieAvatar.png(uri)
|
|
105
|
+
try {
|
|
106
|
+
DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { disk ->
|
|
107
|
+
disk.edit("avatar").also { editor ->
|
|
108
|
+
editor.getFile(0).writeBytes(bytes.copyOf(bytes.size - 1))
|
|
109
|
+
editor.commit()
|
|
110
|
+
}
|
|
111
|
+
val file = disk.get("avatar")!!.getFile(0)
|
|
112
|
+
val fetcher = cacheFetcher(file)
|
|
113
|
+
val callback = Callback()
|
|
114
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
115
|
+
fetcher.cleanup()
|
|
116
|
+
assertTrue(callback.error is IOException)
|
|
117
|
+
assertNull(callback.data)
|
|
118
|
+
assertFalse(file.exists())
|
|
119
|
+
// DiskLruCacheWrapper.put uses this same journal lookup before deciding to skip a write.
|
|
120
|
+
assertNull(disk.get("avatar"))
|
|
121
|
+
disk.edit("avatar").also { editor ->
|
|
122
|
+
editor.getFile(0).writeBytes(bytes)
|
|
123
|
+
editor.commit()
|
|
124
|
+
}
|
|
125
|
+
assertArrayEquals(bytes, disk.get("avatar")!!.getFile(0).readBytes())
|
|
126
|
+
}
|
|
127
|
+
DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { reopened ->
|
|
128
|
+
val fetcher = cacheFetcher(reopened.get("avatar")!!.getFile(0))
|
|
129
|
+
val callback = Callback()
|
|
130
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
131
|
+
fetcher.cleanup()
|
|
132
|
+
assertNull(callback.error)
|
|
133
|
+
assertArrayEquals(bytes, callback.data!!.array())
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
directory.deleteRecursively()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun oversizedCacheIsRejectedAndRemovedBeforeUnboundedRead() {
|
|
142
|
+
val directory = Files.createTempDirectory("avatar-cache-large").toFile()
|
|
143
|
+
val file = File(directory, "avatar.0")
|
|
144
|
+
try {
|
|
145
|
+
file.writeBytes(ByteArray(OneKeyBlockieAvatar.MAX_PNG_BYTES + 1))
|
|
146
|
+
val fetcher = cacheFetcher(file)
|
|
147
|
+
val callback = Callback()
|
|
148
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
149
|
+
fetcher.cleanup()
|
|
150
|
+
assertTrue(callback.error is IOException)
|
|
151
|
+
assertFalse(file.exists())
|
|
152
|
+
} finally {
|
|
153
|
+
directory.deleteRecursively()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@Test
|
|
158
|
+
fun cancelledCacheReadDoesNotDeleteTheFileOrCallBack() {
|
|
159
|
+
val directory = Files.createTempDirectory("avatar-cache-cancel").toFile()
|
|
160
|
+
val file = File(directory, "avatar.0")
|
|
161
|
+
try {
|
|
162
|
+
file.writeBytes(byteArrayOf(1, 2, 3))
|
|
163
|
+
val fetcher = cacheFetcher(file)
|
|
164
|
+
val callback = Callback()
|
|
165
|
+
fetcher.cancel()
|
|
166
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
167
|
+
fetcher.cleanup()
|
|
168
|
+
assertNull(callback.data)
|
|
169
|
+
assertNull(callback.error)
|
|
170
|
+
assertTrue(file.exists())
|
|
171
|
+
} finally {
|
|
172
|
+
directory.deleteRecursively()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@Test
|
|
177
|
+
fun modelDispatchDoesNotDecodeTheUriOrParseHeadersOnMain() {
|
|
178
|
+
val model = OneKeyImageModel.build("onekey-avatar://blockie/v1/%INVALID", "invalid JSON")
|
|
179
|
+
assertEquals(OneKeyBlockieAvatarModel("onekey-avatar://blockie/v1/%INVALID"), model)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@Test
|
|
183
|
+
fun defaultFetcherGeneratesAValidatedPngAndReportsLocalData() {
|
|
184
|
+
val fetcher = OneKeyBlockieAvatarFetcher(uri)
|
|
185
|
+
val callback = Callback()
|
|
186
|
+
try {
|
|
187
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
188
|
+
assertNull(callback.error)
|
|
189
|
+
assertEquals(DataSource.LOCAL, fetcher.dataSource)
|
|
190
|
+
assertArrayEquals(OneKeyBlockieAvatar.png(uri), callback.data!!.array())
|
|
191
|
+
} finally {
|
|
192
|
+
fetcher.cleanup()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
fun malformedUriReportsFailureWithoutReturningImageData() {
|
|
198
|
+
val fetcher = OneKeyBlockieAvatarFetcher("onekey-avatar://blockie/v2/seed")
|
|
199
|
+
val callback = Callback()
|
|
200
|
+
try {
|
|
201
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
202
|
+
assertNull(callback.data)
|
|
203
|
+
assertTrue(callback.error is IllegalArgumentException)
|
|
204
|
+
} finally {
|
|
205
|
+
fetcher.cleanup()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Test
|
|
210
|
+
fun cancelledBeforeLoadingDoesNotGenerateOrCallBack() {
|
|
211
|
+
val generated = AtomicInteger()
|
|
212
|
+
val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ ->
|
|
213
|
+
generated.incrementAndGet()
|
|
214
|
+
byteArrayOf(1)
|
|
215
|
+
})
|
|
216
|
+
val callback = Callback()
|
|
217
|
+
fetcher.cancel()
|
|
218
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
219
|
+
fetcher.cleanup()
|
|
220
|
+
assertEquals(0, generated.get())
|
|
221
|
+
assertNull(callback.data)
|
|
222
|
+
assertNull(callback.error)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
fun cancellationSuppressesLateCallbacksAndReleasesTheWorkForAFreshRequest() {
|
|
227
|
+
val started = CountDownLatch(1)
|
|
228
|
+
val complete = CountDownLatch(1)
|
|
229
|
+
val generated = AtomicInteger()
|
|
230
|
+
val requests = OneKeyAvatarInFlight { _, cancelled ->
|
|
231
|
+
if (generated.incrementAndGet() == 1) {
|
|
232
|
+
started.countDown()
|
|
233
|
+
check(complete.await(3, TimeUnit.SECONDS))
|
|
234
|
+
assertTrue(cancelled())
|
|
235
|
+
}
|
|
236
|
+
byteArrayOf(1)
|
|
237
|
+
}
|
|
238
|
+
val fetcher = OneKeyBlockieAvatarFetcher(uri, requests)
|
|
239
|
+
val callback = Callback()
|
|
240
|
+
val executor = Executors.newSingleThreadExecutor()
|
|
241
|
+
try {
|
|
242
|
+
val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) }
|
|
243
|
+
assertTrue(started.await(3, TimeUnit.SECONDS))
|
|
244
|
+
fetcher.cancel()
|
|
245
|
+
fetcher.cleanup()
|
|
246
|
+
complete.countDown()
|
|
247
|
+
loaded.get(3, TimeUnit.SECONDS)
|
|
248
|
+
assertNull(callback.data)
|
|
249
|
+
assertNull(callback.error)
|
|
250
|
+
val fresh = OneKeyBlockieAvatarFetcher(uri, requests)
|
|
251
|
+
val next = Callback()
|
|
252
|
+
fresh.loadData(Priority.NORMAL, next)
|
|
253
|
+
fresh.cleanup()
|
|
254
|
+
assertArrayEquals(byteArrayOf(1), next.data!!.array())
|
|
255
|
+
assertEquals(2, generated.get())
|
|
256
|
+
} finally {
|
|
257
|
+
fetcher.cleanup()
|
|
258
|
+
complete.countDown()
|
|
259
|
+
executor.shutdownNow()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Test
|
|
264
|
+
fun cancellationNeverWaitsForAGlideCallbackHoldingItsOwnLock() {
|
|
265
|
+
val callbackStarted = CountDownLatch(1)
|
|
266
|
+
val callbackComplete = CountDownLatch(1)
|
|
267
|
+
val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) })
|
|
268
|
+
val executor = Executors.newFixedThreadPool(2)
|
|
269
|
+
val callback = object : DataFetcher.DataCallback<ByteBuffer> {
|
|
270
|
+
override fun onDataReady(data: ByteBuffer?) {
|
|
271
|
+
callbackStarted.countDown()
|
|
272
|
+
check(callbackComplete.await(3, TimeUnit.SECONDS))
|
|
273
|
+
}
|
|
274
|
+
override fun onLoadFailed(error: Exception) { throw error }
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) }
|
|
278
|
+
assertTrue(callbackStarted.await(3, TimeUnit.SECONDS))
|
|
279
|
+
executor.submit { fetcher.cancel() }.get(1, TimeUnit.SECONDS)
|
|
280
|
+
callbackComplete.countDown()
|
|
281
|
+
loaded.get(3, TimeUnit.SECONDS)
|
|
282
|
+
} finally {
|
|
283
|
+
callbackComplete.countDown()
|
|
284
|
+
fetcher.cleanup()
|
|
285
|
+
executor.shutdownNow()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@Test
|
|
290
|
+
fun glideCallbackFailureIsNotReportedAsASecondGenerationFailure() {
|
|
291
|
+
val failures = AtomicInteger()
|
|
292
|
+
val expected = IllegalStateException("Synthetic callback failure")
|
|
293
|
+
val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) })
|
|
294
|
+
val callback = object : DataFetcher.DataCallback<ByteBuffer> {
|
|
295
|
+
override fun onDataReady(data: ByteBuffer?) { throw expected }
|
|
296
|
+
override fun onLoadFailed(error: Exception) { failures.incrementAndGet() }
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
fetcher.loadData(Priority.NORMAL, callback)
|
|
300
|
+
org.junit.Assert.fail("Callback exception must propagate")
|
|
301
|
+
} catch (error: IllegalStateException) {
|
|
302
|
+
assertEquals(expected, error)
|
|
303
|
+
assertEquals(0, failures.get())
|
|
304
|
+
} finally {
|
|
305
|
+
fetcher.cleanup()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@Test
|
|
310
|
+
fun failedGenerationIsReleasedSoRetryCanSucceed() {
|
|
311
|
+
val generated = AtomicInteger()
|
|
312
|
+
val requests = OneKeyAvatarInFlight { _, _ ->
|
|
313
|
+
if (generated.incrementAndGet() == 1) throw IOException("Synthetic generation failure")
|
|
314
|
+
byteArrayOf(1)
|
|
315
|
+
}
|
|
316
|
+
val failed = OneKeyBlockieAvatarFetcher(uri, requests)
|
|
317
|
+
val first = Callback()
|
|
318
|
+
failed.loadData(Priority.NORMAL, first)
|
|
319
|
+
failed.cleanup()
|
|
320
|
+
assertTrue(first.error is IOException)
|
|
321
|
+
val retried = OneKeyBlockieAvatarFetcher(uri, requests)
|
|
322
|
+
val second = Callback()
|
|
323
|
+
retried.loadData(Priority.NORMAL, second)
|
|
324
|
+
retried.cleanup()
|
|
325
|
+
assertArrayEquals(byteArrayOf(1), second.data!!.array())
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
package com.margelo.nitro.onekeyimage
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertArrayEquals
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertFalse
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Assert.fail
|
|
8
|
+
import org.junit.Test
|
|
9
|
+
import java.io.ByteArrayOutputStream
|
|
10
|
+
import java.io.DataOutputStream
|
|
11
|
+
import java.nio.ByteBuffer
|
|
12
|
+
import java.util.zip.CRC32
|
|
13
|
+
import java.util.zip.DeflaterOutputStream
|
|
14
|
+
import java.util.concurrent.CancellationException
|
|
15
|
+
import java.util.concurrent.CountDownLatch
|
|
16
|
+
import java.util.concurrent.Executors
|
|
17
|
+
import java.util.concurrent.TimeUnit
|
|
18
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
19
|
+
|
|
20
|
+
class OneKeyBlockieAvatarTest {
|
|
21
|
+
@Test
|
|
22
|
+
fun percentDecodePreservesJavaScriptNormalizedUtf16WithoutRecasing() {
|
|
23
|
+
assertEquals("i\u0307中🙂/#+", OneKeyBlockieAvatar.decodeSeed(
|
|
24
|
+
"onekey-avatar://blockie/v1/i%CC%87%E4%B8%AD%F0%9F%99%82%2F%23%2B",
|
|
25
|
+
))
|
|
26
|
+
assertEquals("İ", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%C4%B0"))
|
|
27
|
+
assertEquals("\u0000", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%00"))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun invalidProtocolPercentEncodingAndUtf8AreRejected() {
|
|
32
|
+
listOf(
|
|
33
|
+
"onekey-avatar://blockie/v2/seed", "onekey-avatar://blockie/v1/",
|
|
34
|
+
"onekey-avatar://blockie/v1/%", "onekey-avatar://blockie/v1/%GG",
|
|
35
|
+
"onekey-avatar://blockie/v1/%C0%AF", "onekey-avatar://blockie/v1/%ED%A0%80",
|
|
36
|
+
"onekey-avatar://blockie/v1/seed?query", "onekey-avatar://blockie/v1/a/b",
|
|
37
|
+
"onekey-avatar://blockie/v1/a+b", "onekey-avatar://blockie/v1/中",
|
|
38
|
+
).forEach { uri ->
|
|
39
|
+
try {
|
|
40
|
+
OneKeyBlockieAvatar.decodeSeed(uri)
|
|
41
|
+
fail("Invalid avatar URI was accepted")
|
|
42
|
+
} catch (_: Exception) { }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun generationIsDeterministicAndEmits128pxIndexedPng() {
|
|
48
|
+
val uri = "onekey-avatar://blockie/v1/0x1234"
|
|
49
|
+
val first = OneKeyBlockieAvatar.png(uri)
|
|
50
|
+
assertArrayEquals(first, OneKeyBlockieAvatar.png(uri))
|
|
51
|
+
assertArrayEquals(byteArrayOf(-119, 80, 78, 71, 13, 10, 26, 10), first.copyOfRange(0, 8))
|
|
52
|
+
assertArrayEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128), first.copyOfRange(16, 24))
|
|
53
|
+
assertTrue(first.size < 1024)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test(expected = CancellationException::class)
|
|
57
|
+
fun cancellationStopsBeforeGeneration() {
|
|
58
|
+
OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/seed") { true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Test(expected = CancellationException::class)
|
|
62
|
+
fun cancellationAlsoStopsLargeSeedDecoding() {
|
|
63
|
+
val checks = AtomicInteger()
|
|
64
|
+
OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/" + "a".repeat(8192)) {
|
|
65
|
+
checks.incrementAndGet() > 3
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun replaceChunk(png: ByteArray, type: String, data: ByteArray): ByteArray {
|
|
70
|
+
val input = ByteBuffer.wrap(png)
|
|
71
|
+
input.position(8)
|
|
72
|
+
while (input.hasRemaining()) {
|
|
73
|
+
val start = input.position()
|
|
74
|
+
val length = input.int
|
|
75
|
+
val chunkType = ByteArray(4).also(input::get).toString(Charsets.US_ASCII)
|
|
76
|
+
val end = input.position() + length + 4
|
|
77
|
+
if (chunkType == type) {
|
|
78
|
+
val chunk = ByteArrayOutputStream()
|
|
79
|
+
DataOutputStream(chunk).use {
|
|
80
|
+
val name = type.toByteArray(Charsets.US_ASCII)
|
|
81
|
+
it.writeInt(data.size)
|
|
82
|
+
it.write(name)
|
|
83
|
+
it.write(data)
|
|
84
|
+
it.writeInt(CRC32().apply { update(name); update(data) }.value.toInt())
|
|
85
|
+
}
|
|
86
|
+
return png.copyOfRange(0, start) + chunk.toByteArray() + png.copyOfRange(end, png.size)
|
|
87
|
+
}
|
|
88
|
+
input.position(end)
|
|
89
|
+
}
|
|
90
|
+
throw IllegalArgumentException("Test PNG chunk missing")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fun compressedPixels(size: Int, invalidFilter: Boolean = false): ByteArray {
|
|
94
|
+
val result = ByteArrayOutputStream()
|
|
95
|
+
DeflaterOutputStream(result).use { output ->
|
|
96
|
+
repeat(size) { index -> output.write(if (invalidFilter && index == 0) 1 else 0) }
|
|
97
|
+
}
|
|
98
|
+
return result.toByteArray()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun completeFixedFormatPngPassesIntegrityValidation() {
|
|
103
|
+
listOf("seed", "0x1234", "%E4%B8%AD%F0%9F%99%82").forEach {
|
|
104
|
+
assertTrue(OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + it)))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Test
|
|
109
|
+
fun corruptedOrTruncatedChunksAndTrailingBytesAreRejected() {
|
|
110
|
+
val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")
|
|
111
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf(png.size - 1)))
|
|
112
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(png + byteArrayOf(0)))
|
|
113
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { it[45] = (it[45].toInt() xor 1).toByte() }))
|
|
114
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { ByteBuffer.wrap(it).putInt(8, Int.MAX_VALUE) }))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Test
|
|
118
|
+
fun validCrcCannotHideWrongDimensionsOrMalformedCompressedData() {
|
|
119
|
+
val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")
|
|
120
|
+
val header = png.copyOfRange(16, 29).also { it[3] = 96 }
|
|
121
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IHDR", header)))
|
|
122
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", byteArrayOf(1, 2, 3))))
|
|
123
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33 - 1))))
|
|
124
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33, true))))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@Test
|
|
128
|
+
fun decompressionIsBoundedEvenWhenEveryChunkCrcIsValid() {
|
|
129
|
+
val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")
|
|
130
|
+
val bomb = replaceChunk(png, "IDAT", compressedPixels(1024 * 1024))
|
|
131
|
+
assertTrue(bomb.size < OneKeyBlockieAvatar.MAX_PNG_BYTES)
|
|
132
|
+
assertFalse(OneKeyBlockieAvatar.isValidPng(bomb))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@Test(expected = CancellationException::class)
|
|
136
|
+
fun cancellationStopsCachedPngValidation() {
|
|
137
|
+
OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")) { true }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun overlappingSizeRequestsShareOneGenerationAndDoNotRetainCompletedImages() {
|
|
142
|
+
val generated = AtomicInteger()
|
|
143
|
+
val started = CountDownLatch(1)
|
|
144
|
+
val complete = CountDownLatch(1)
|
|
145
|
+
val requests = OneKeyAvatarInFlight { _, _ ->
|
|
146
|
+
generated.incrementAndGet()
|
|
147
|
+
started.countDown()
|
|
148
|
+
check(complete.await(3, TimeUnit.SECONDS))
|
|
149
|
+
byteArrayOf(1, 2, 3)
|
|
150
|
+
}
|
|
151
|
+
val leases = List(8) { requests.acquire("uri") }
|
|
152
|
+
val executor = Executors.newFixedThreadPool(8)
|
|
153
|
+
try {
|
|
154
|
+
val values = leases.map { lease -> executor.submit<ByteArray> { lease.bytes() } }
|
|
155
|
+
assertTrue(started.await(3, TimeUnit.SECONDS))
|
|
156
|
+
complete.countDown()
|
|
157
|
+
values.forEach { assertArrayEquals(byteArrayOf(1, 2, 3), it.get(3, TimeUnit.SECONDS)) }
|
|
158
|
+
assertEquals(1, generated.get())
|
|
159
|
+
leases.forEach { it.release() }
|
|
160
|
+
val fresh = requests.acquire("uri")
|
|
161
|
+
assertArrayEquals(byteArrayOf(1, 2, 3), fresh.bytes())
|
|
162
|
+
fresh.release()
|
|
163
|
+
assertEquals(2, generated.get())
|
|
164
|
+
} finally {
|
|
165
|
+
complete.countDown()
|
|
166
|
+
leases.forEach { it.release() }
|
|
167
|
+
executor.shutdownNow()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@Test
|
|
172
|
+
fun cancellingOneConsumerKeepsTheOtherConsumerAlive() {
|
|
173
|
+
val requests = OneKeyAvatarInFlight { _, cancelled ->
|
|
174
|
+
assertFalse(cancelled())
|
|
175
|
+
byteArrayOf(7)
|
|
176
|
+
}
|
|
177
|
+
val cancelled = requests.acquire("uri")
|
|
178
|
+
val active = requests.acquire("uri")
|
|
179
|
+
cancelled.release()
|
|
180
|
+
cancelled.release()
|
|
181
|
+
assertArrayEquals(byteArrayOf(7), active.bytes())
|
|
182
|
+
active.release()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
@Test
|
|
186
|
+
fun cancellingEveryConsumerStopsWorkAndAllowsAFreshRequest() {
|
|
187
|
+
val started = CountDownLatch(1)
|
|
188
|
+
val stopped = CountDownLatch(1)
|
|
189
|
+
val generated = AtomicInteger()
|
|
190
|
+
val requests = OneKeyAvatarInFlight { _, cancelled ->
|
|
191
|
+
if (generated.incrementAndGet() == 1) {
|
|
192
|
+
started.countDown()
|
|
193
|
+
while (!cancelled()) Thread.yield()
|
|
194
|
+
stopped.countDown()
|
|
195
|
+
throw CancellationException("Cancelled")
|
|
196
|
+
}
|
|
197
|
+
byteArrayOf(9)
|
|
198
|
+
}
|
|
199
|
+
val lease = requests.acquire("uri")
|
|
200
|
+
val executor = Executors.newSingleThreadExecutor()
|
|
201
|
+
try {
|
|
202
|
+
executor.submit { try { lease.bytes() } catch (_: CancellationException) { } }
|
|
203
|
+
assertTrue(started.await(3, TimeUnit.SECONDS))
|
|
204
|
+
lease.release()
|
|
205
|
+
assertTrue(stopped.await(3, TimeUnit.SECONDS))
|
|
206
|
+
val fresh = requests.acquire("uri")
|
|
207
|
+
assertArrayEquals(byteArrayOf(9), fresh.bytes())
|
|
208
|
+
fresh.release()
|
|
209
|
+
} finally {
|
|
210
|
+
lease.release()
|
|
211
|
+
executor.shutdownNow()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|