@onekeyfe/react-native-image 3.0.104 → 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.
@@ -12,8 +12,9 @@ Pod::Spec.new do |s|
12
12
  s.platforms = { :ios => min_ios_version_supported }
13
13
  s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" }
14
14
 
15
- s.source_files = ["ios/**/*.{swift,m,mm}", "cpp/**/*.{hpp,cpp}"]
15
+ s.source_files = ["ios/**/*.{h,swift,m,mm}", "cpp/**/*.{hpp,cpp}"]
16
16
  s.exclude_files = "ios/tests/**/*"
17
+ s.public_header_files = ["ios/OneKeyImageCoderBridge.h"]
17
18
 
18
19
  s.dependency "React-jsi"
19
20
  s.dependency "React-callinvoker"
@@ -0,0 +1,264 @@
1
+ // OneKey patch: Render the versioned local avatar URI without a JS PNG payload.
2
+ // Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT):
3
+ // https://github.com/MyCryptoHQ/ethereum-blockies-base64
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ // The above copyright notice and this permission notice shall be included in
11
+ // all copies or substantial portions of the Software.
12
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
14
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
16
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
18
+ // THE SOFTWARE.
19
+
20
+ package com.margelo.nitro.onekeyimage
21
+
22
+ import java.io.ByteArrayOutputStream
23
+ import java.io.DataOutputStream
24
+ import java.nio.ByteBuffer
25
+ import java.nio.charset.CodingErrorAction
26
+ import java.util.concurrent.CancellationException
27
+ import java.util.concurrent.FutureTask
28
+ import java.util.concurrent.atomic.AtomicBoolean
29
+ import java.util.zip.CRC32
30
+ import java.util.zip.Deflater
31
+ import java.util.zip.DeflaterOutputStream
32
+ import java.util.zip.DataFormatException
33
+ import java.util.zip.Inflater
34
+ import kotlin.math.floor
35
+
36
+ internal data class OneKeyBlockieAvatarModel(val uri: String)
37
+
38
+ internal object OneKeyBlockieAvatar {
39
+ const val URI_PREFIX = "onekey-avatar://blockie/v1/"
40
+ const val SIZE = 128
41
+ const val MAX_PNG_BYTES = SIZE * (SIZE / 4 + 1) + 128
42
+
43
+ fun isAvatarUri(uri: String): Boolean = uri.startsWith("onekey-avatar:")
44
+
45
+ fun decodeSeed(uri: String, isCancelled: () -> Boolean = { false }): String {
46
+ require(uri.startsWith(URI_PREFIX)) { "Unsupported avatar URI version" }
47
+ val encoded = uri.substring(URI_PREFIX.length)
48
+ require(encoded.isNotEmpty()) { "Avatar seed is empty" }
49
+ val bytes = ByteArrayOutputStream(encoded.length)
50
+ var index = 0
51
+ while (index < encoded.length) {
52
+ if (index % 256 == 0 && isCancelled()) throw CancellationException("Avatar generation cancelled")
53
+ val char = encoded[index]
54
+ if (char == '%') {
55
+ require(index + 2 < encoded.length) { "Invalid avatar percent encoding" }
56
+ val high = encoded[index + 1].digitToIntOrNull(16)
57
+ val low = encoded[index + 2].digitToIntOrNull(16)
58
+ require(high != null && low != null) { "Invalid avatar percent encoding" }
59
+ bytes.write((high shl 4) or low)
60
+ index += 3
61
+ } else {
62
+ require(char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char in "-_.!~*'()") {
63
+ "Invalid avatar URI component"
64
+ }
65
+ bytes.write(char.code)
66
+ index += 1
67
+ }
68
+ }
69
+ // The app already applies JavaScript lowercase; JVM Unicode casing differs.
70
+ return Charsets.UTF_8.newDecoder()
71
+ .onMalformedInput(CodingErrorAction.REPORT)
72
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
73
+ .decode(ByteBuffer.wrap(bytes.toByteArray()))
74
+ .toString()
75
+ }
76
+
77
+ // Only our fixed v1 encoder format is accepted, never arbitrary user PNG data.
78
+ fun isValidPng(bytes: ByteArray, isCancelled: () -> Boolean = { false }): Boolean {
79
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
80
+ if (bytes.size !in 93..MAX_PNG_BYTES) return false
81
+ val input = ByteBuffer.wrap(bytes)
82
+ if (input.long != 0x89504e470d0a1a0aUL.toLong()) return false
83
+ fun chunk(expectedType: String, expectedLength: Int? = null): ByteArray? {
84
+ if (input.remaining() < 12) return null
85
+ val length = input.int
86
+ if (length < 0 || length > input.remaining() - 8) return null
87
+ if (expectedLength != null && length != expectedLength) return null
88
+ val type = ByteArray(4).also(input::get)
89
+ if (!type.contentEquals(expectedType.toByteArray(Charsets.US_ASCII))) return null
90
+ val data = ByteArray(length).also(input::get)
91
+ val crc = CRC32().apply { update(type); update(data) }
92
+ if (input.int != crc.value.toInt()) return null
93
+ return data
94
+ }
95
+ val header = chunk("IHDR", 13) ?: return false
96
+ if (!header.contentEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128, 2, 3, 0, 0, 0))) return false
97
+ chunk("PLTE", 9) ?: return false
98
+ if (chunk("tRNS", 3)?.contentEquals(byteArrayOf(-1, -1, -1)) != true) return false
99
+ val compressed = chunk("IDAT") ?: return false
100
+ chunk("IEND", 0) ?: return false
101
+ if (input.hasRemaining()) return false
102
+
103
+ val expectedBytes = SIZE * (SIZE / 4 + 1)
104
+ val pixels = ByteArray(expectedBytes + 1)
105
+ val inflater = Inflater()
106
+ var count = 0
107
+ try {
108
+ inflater.setInput(compressed)
109
+ while (!inflater.finished() && count <= expectedBytes) {
110
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
111
+ val decoded = inflater.inflate(pixels, count, pixels.size - count)
112
+ if (decoded == 0) return false
113
+ count += decoded
114
+ }
115
+ if (!inflater.finished() || inflater.remaining != 0 || count != expectedBytes) return false
116
+ } catch (_: DataFormatException) {
117
+ return false
118
+ } finally {
119
+ inflater.end()
120
+ }
121
+ repeat(SIZE) { row ->
122
+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled")
123
+ val offset = row * 33
124
+ if (pixels[offset] != 0.toByte()) return false
125
+ for (index in 1..32) {
126
+ val value = pixels[offset + index].toInt() and 0xff
127
+ if (
128
+ (value and 3) == 3 || ((value shr 2) and 3) == 3 ||
129
+ ((value shr 4) and 3) == 3 || ((value shr 6) and 3) == 3
130
+ ) return false
131
+ }
132
+ }
133
+ return true
134
+ }
135
+
136
+ fun png(uri: String, isCancelled: () -> Boolean = { false }): ByteArray {
137
+ fun checkCancelled() {
138
+ if (isCancelled()) throw CancellationException("Avatar generation cancelled")
139
+ }
140
+ checkCancelled()
141
+ val seed = decodeSeed(uri, isCancelled)
142
+ val state = IntArray(4)
143
+ seed.forEachIndexed { index, char ->
144
+ if (index % 256 == 0) checkCancelled()
145
+ val slot = index % 4
146
+ // Kotlin Int overflow and signed shr preserve JavaScript's bitwise PRNG.
147
+ state[slot] = (state[slot] shl 5) - state[slot] + char.code
148
+ }
149
+ fun random(): Double {
150
+ val t = state[0] xor (state[0] shl 11)
151
+ state[0] = state[1]
152
+ state[1] = state[2]
153
+ state[2] = state[3]
154
+ state[3] = state[3] xor (state[3] shr 19) xor t xor (t shr 8)
155
+ return (state[3].toLong() and 0xffffffffL).toDouble() / 2147483648.0
156
+ }
157
+ fun hue(p: Double, q: Double, value: Double): Double {
158
+ var t = value
159
+ if (t < 0) t += 1
160
+ if (t > 1) t -= 1
161
+ return when {
162
+ t < 1.0 / 6.0 -> p + (q - p) * 6 * t
163
+ t < 1.0 / 2.0 -> q
164
+ t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6
165
+ else -> p
166
+ }
167
+ }
168
+ fun color(): ByteArray {
169
+ val h = floor(random() * 360) / 360
170
+ val saturation = (random() * 60 + 40) / 100
171
+ val lightness = ((random() + random() + random() + random()) * 25) / 100
172
+ val q = if (lightness < 0.5) lightness * (1 + saturation)
173
+ else lightness + saturation - lightness * saturation
174
+ val p = 2 * lightness - q
175
+ return doubleArrayOf(
176
+ hue(p, q, h + 1.0 / 3.0), hue(p, q, h), hue(p, q, h - 1.0 / 3.0),
177
+ ).map { floor(it * 255 + 0.5).toInt().toByte() }.toByteArray()
178
+ }
179
+ val foreground = color()
180
+ val background = color()
181
+ val spot = color()
182
+ val pixels = ByteArray(SIZE * 33)
183
+ repeat(8) { row ->
184
+ checkCancelled()
185
+ val line = ByteArray(33)
186
+ repeat(4) { column ->
187
+ val value = floor(random() * 2.3).toInt()
188
+ val paletteIndex = if (value == 0) 0 else if (value == 1) 1 else 2
189
+ val packed = (paletteIndex * 0x55).toByte()
190
+ line.fill(packed, 1 + column * 4, 1 + (column + 1) * 4)
191
+ line.fill(packed, 1 + (7 - column) * 4, 1 + (8 - column) * 4)
192
+ }
193
+ repeat(16) { line.copyInto(pixels, (row * 16 + it) * 33) }
194
+ }
195
+ val compressed = ByteArrayOutputStream()
196
+ val deflater = Deflater(Deflater.BEST_SPEED)
197
+ try {
198
+ DeflaterOutputStream(compressed, deflater).use { it.write(pixels) }
199
+ } finally {
200
+ deflater.end()
201
+ }
202
+ checkCancelled()
203
+ val result = ByteArrayOutputStream()
204
+ DataOutputStream(result).use { output ->
205
+ output.write(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10))
206
+ fun chunk(type: String, data: ByteArray) {
207
+ val typeBytes = type.toByteArray(Charsets.US_ASCII)
208
+ output.writeInt(data.size)
209
+ output.write(typeBytes)
210
+ output.write(data)
211
+ val crc = CRC32().apply { update(typeBytes); update(data) }
212
+ output.writeInt(crc.value.toInt())
213
+ }
214
+ chunk("IHDR", byteArrayOf(0, 0, 0, 128.toByte(), 0, 0, 0, 128.toByte(), 2, 3, 0, 0, 0))
215
+ chunk("PLTE", background + foreground + spot)
216
+ chunk("tRNS", byteArrayOf(-1, -1, -1))
217
+ chunk("IDAT", compressed.toByteArray())
218
+ chunk("IEND", byteArrayOf())
219
+ }
220
+ return result.toByteArray()
221
+ }
222
+ }
223
+
224
+ /** Only overlapping source fetches are retained; Glide owns all lasting caches. */
225
+ internal class OneKeyAvatarInFlight(
226
+ private val generate: (String, () -> Boolean) -> ByteArray,
227
+ ) {
228
+ internal class Work(uri: String, generate: (String, () -> Boolean) -> ByteArray) {
229
+ val cancelled = AtomicBoolean(false)
230
+ val task = FutureTask { generate(uri, cancelled::get) }
231
+ var references = 0
232
+ }
233
+
234
+ private val pending = mutableMapOf<String, Work>()
235
+
236
+ internal inner class Lease(private val uri: String, internal val work: Work) {
237
+ private val released = AtomicBoolean(false)
238
+
239
+ fun bytes(): ByteArray {
240
+ work.task.run()
241
+ return work.task.get()
242
+ }
243
+
244
+ fun release() {
245
+ if (!released.compareAndSet(false, true)) return
246
+ synchronized(pending) {
247
+ work.references -= 1
248
+ if (work.references == 0) {
249
+ if (pending[uri] === work) pending.remove(uri)
250
+ if (!work.task.isDone) {
251
+ work.cancelled.set(true)
252
+ work.task.cancel(false)
253
+ }
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ fun acquire(uri: String): Lease = synchronized(pending) {
260
+ val work = pending.getOrPut(uri) { Work(uri, generate) }
261
+ work.references += 1
262
+ Lease(uri, work)
263
+ }
264
+ }
@@ -0,0 +1,173 @@
1
+ package com.margelo.nitro.onekeyimage
2
+
3
+ import com.bumptech.glide.Priority
4
+ import com.bumptech.glide.load.DataSource
5
+ import com.bumptech.glide.load.Option
6
+ import com.bumptech.glide.load.Options
7
+ import com.bumptech.glide.load.data.DataFetcher
8
+ import com.bumptech.glide.load.engine.DiskCacheStrategy
9
+ import com.bumptech.glide.load.model.ModelLoader
10
+ import com.bumptech.glide.load.model.ModelLoaderFactory
11
+ import com.bumptech.glide.load.model.MultiModelLoaderFactory
12
+ import com.bumptech.glide.signature.ObjectKey
13
+ import com.bumptech.glide.request.RequestOptions
14
+ import java.io.ByteArrayInputStream
15
+ import java.io.ByteArrayOutputStream
16
+ import java.io.File
17
+ import java.io.IOException
18
+ import java.nio.ByteBuffer
19
+ import java.util.concurrent.ExecutionException
20
+ import java.util.concurrent.CancellationException
21
+
22
+ // Only source PNGs are persisted; transformed resources can have other dimensions.
23
+ internal fun oneKeyImageMemoryDiskStrategy(uri: String): DiskCacheStrategy =
24
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) DiskCacheStrategy.DATA else DiskCacheStrategy.AUTOMATIC
25
+
26
+ internal val oneKeyAvatarCacheFileOption: Option<Boolean> =
27
+ Option.memory("onekey-image.blockie-source-cache-v1", false)
28
+
29
+ internal fun RequestOptions.withOneKeyAvatarCache(uri: String): RequestOptions =
30
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) set(oneKeyAvatarCacheFileOption, true) else this
31
+
32
+ internal class OneKeyAvatarCacheFileLoaderFactory : ModelLoaderFactory<File, ByteBuffer> {
33
+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<File, ByteBuffer> =
34
+ OneKeyAvatarCacheFileLoader()
35
+
36
+ override fun teardown() = Unit
37
+ }
38
+
39
+ private class OneKeyAvatarCacheFileLoader : ModelLoader<File, ByteBuffer> {
40
+ override fun handles(model: File): Boolean = true
41
+
42
+ override fun buildLoadData(
43
+ model: File,
44
+ width: Int,
45
+ height: Int,
46
+ options: Options,
47
+ ): ModelLoader.LoadData<ByteBuffer>? =
48
+ if (options.get(oneKeyAvatarCacheFileOption) == true) {
49
+ ModelLoader.LoadData(ObjectKey(model), OneKeyAvatarCacheFileFetcher(model))
50
+ } else null
51
+ }
52
+
53
+ // Serialize validation/removal so two failed reads cannot delete a newly repaired file.
54
+ private val avatarCacheFileLock = Any()
55
+
56
+ private class OneKeyAvatarCacheFileFetcher(private val file: File) : DataFetcher<ByteBuffer> {
57
+ @Volatile
58
+ private var cancelled = false
59
+
60
+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
61
+ val bytes = try {
62
+ synchronized(avatarCacheFileLock) {
63
+ checkCancelled()
64
+ val bytes = readBounded()
65
+ if (bytes == null || !OneKeyBlockieAvatar.isValidPng(bytes) { cancelled }) {
66
+ checkCancelled()
67
+ if (file.exists() && !file.delete()) throw IOException("Cannot remove invalid avatar cache entry")
68
+ // Glide's journal sees the missing clean file; SOURCE can write it again.
69
+ throw IOException("Invalid avatar cache entry removed")
70
+ }
71
+ bytes
72
+ }
73
+ } catch (error: Exception) {
74
+ if (!cancelled) callback.onLoadFailed(error)
75
+ return
76
+ }
77
+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(bytes))
78
+ }
79
+
80
+ private fun readBounded(): ByteArray? {
81
+ if (file.length() > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null
82
+ return file.inputStream().use { input ->
83
+ val output = ByteArrayOutputStream()
84
+ val buffer = ByteArray(1024)
85
+ while (true) {
86
+ checkCancelled()
87
+ val count = input.read(buffer)
88
+ if (count < 0) break
89
+ if (output.size() + count > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null
90
+ output.write(buffer, 0, count)
91
+ }
92
+ output.toByteArray()
93
+ }
94
+ }
95
+
96
+ private fun checkCancelled() {
97
+ if (cancelled) throw CancellationException("Avatar cache read cancelled")
98
+ }
99
+
100
+ override fun cancel() { cancelled = true }
101
+ override fun cleanup() = cancel()
102
+ override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
103
+ override fun getDataSource(): DataSource = DataSource.LOCAL
104
+ }
105
+
106
+ internal class OneKeyBlockieAvatarLoaderFactory : ModelLoaderFactory<OneKeyBlockieAvatarModel, ByteBuffer> {
107
+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<OneKeyBlockieAvatarModel, ByteBuffer> =
108
+ OneKeyBlockieAvatarLoader()
109
+
110
+ override fun teardown() = Unit
111
+ }
112
+
113
+ private val avatarRequests = OneKeyAvatarInFlight { uri, isCancelled ->
114
+ OneKeyImageSafety.requireEncodedLength(uri.length.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES)
115
+ val png = OneKeyBlockieAvatar.png(uri, isCancelled)
116
+ OneKeyImageSafety.requireEncodedLength(png.size.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES)
117
+ OneKeyEncodedImageInspector.inspect(ByteArrayInputStream(png))
118
+ png
119
+ }
120
+
121
+ private class OneKeyBlockieAvatarLoader : ModelLoader<OneKeyBlockieAvatarModel, ByteBuffer> {
122
+ override fun handles(model: OneKeyBlockieAvatarModel): Boolean = true
123
+
124
+ override fun buildLoadData(
125
+ model: OneKeyBlockieAvatarModel,
126
+ width: Int,
127
+ height: Int,
128
+ options: Options,
129
+ ): ModelLoader.LoadData<ByteBuffer> = ModelLoader.LoadData(
130
+ OneKeyImageSafetyVersionedKey(ObjectKey(model)),
131
+ OneKeyBlockieAvatarFetcher(model.uri),
132
+ )
133
+ }
134
+
135
+ internal class OneKeyBlockieAvatarFetcher(
136
+ private val uri: String,
137
+ private val requests: OneKeyAvatarInFlight = avatarRequests,
138
+ ) : DataFetcher<ByteBuffer> {
139
+ @Volatile
140
+ private var cancelled = false
141
+ private var lease: OneKeyAvatarInFlight.Lease? = null
142
+
143
+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in ByteBuffer>) {
144
+ val request = synchronized(this) {
145
+ if (cancelled) return
146
+ requests.acquire(uri).also { lease = it }
147
+ }
148
+ // Glide invokes loadData on its source executor, never on the UI thread.
149
+ val png = try {
150
+ request.bytes()
151
+ } catch (error: Exception) {
152
+ val cause = if (error is ExecutionException) error.cause else error
153
+ if (!cancelled) callback.onLoadFailed(cause as? Exception ?: IOException("Avatar generation failed"))
154
+ return
155
+ }
156
+ // Glide may cancel while holding its EngineJob lock; do not call back under ours.
157
+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(png))
158
+ }
159
+
160
+ override fun cancel() = release()
161
+ override fun cleanup() = release()
162
+
163
+ private fun release() {
164
+ val request = synchronized(this) {
165
+ cancelled = true
166
+ lease.also { lease = null }
167
+ }
168
+ request?.release()
169
+ }
170
+
171
+ override fun getDataClass(): Class<ByteBuffer> = ByteBuffer::class.java
172
+ override fun getDataSource(): DataSource = DataSource.LOCAL
173
+ }
@@ -1,5 +1,6 @@
1
1
  package com.margelo.nitro.onekeyimage
2
2
 
3
+ import android.app.Activity
3
4
  import android.content.Context
4
5
  import android.content.ContextWrapper
5
6
  import android.graphics.Canvas
@@ -9,7 +10,6 @@ import android.graphics.drawable.Animatable
9
10
  import android.graphics.drawable.Drawable
10
11
  import android.view.View
11
12
  import android.widget.ImageView
12
- import androidx.fragment.app.FragmentActivity
13
13
  import com.bumptech.glide.Glide
14
14
  import com.bumptech.glide.load.DataSource
15
15
  import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -25,15 +25,15 @@ import com.margelo.nitro.skeleton.OneKeySkeletonRenderer
25
25
  import com.margelo.nitro.views.RecyclableView
26
26
  import kotlin.math.ceil
27
27
 
28
- private fun Context.findFragmentActivity(): FragmentActivity? {
28
+ private fun Context.findActivity(): Activity? {
29
29
  var currentContext: Context? = this
30
30
  while (currentContext is ContextWrapper) {
31
- if (currentContext is FragmentActivity) return currentContext
31
+ if (currentContext is Activity) return currentContext
32
32
  val baseContext = currentContext.baseContext
33
33
  if (baseContext === currentContext) return null
34
34
  currentContext = baseContext
35
35
  }
36
- return currentContext as? FragmentActivity
36
+ return currentContext as? Activity
37
37
  }
38
38
 
39
39
  private class OneKeyImageHostView(context: ThemedReactContext) : ImageView(context) {
@@ -139,10 +139,9 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
139
139
  private val hostView = OneKeyImageHostView(context)
140
140
  // The Activity outlives ScreenStack Fragments but still provides bounded
141
141
  // background and destruction lifecycle handling for image requests.
142
- private val requestManager by lazy(LazyThreadSafetyMode.NONE) {
143
- val activity = context.findFragmentActivity()
144
- ?: (context.currentActivity as? FragmentActivity)
145
- Glide.with(requireNotNull(activity) { "A FragmentActivity is required to load images" })
142
+ private val activityRequestManager by lazy(LazyThreadSafetyMode.NONE) {
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
@@ -156,6 +155,18 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
156
155
  private var pendingDisplayGeneration: Long? = null
157
156
  private var fallbackRunnable: Runnable? = null
158
157
 
158
+ /**
159
+ * Native reusable containers own their request cleanup explicitly, so their
160
+ * requests must not inherit a transient React Native screen Fragment lifecycle.
161
+ */
162
+ internal var usesApplicationRequestManager = false
163
+
164
+ private fun requestManager() = if (usesApplicationRequestManager) {
165
+ Glide.with(context.applicationContext)
166
+ } else {
167
+ activityRequestManager
168
+ }
169
+
159
170
  override val view: View = hostView
160
171
 
161
172
  override var sourceUri: String? = null
@@ -470,10 +481,10 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
470
481
  }
471
482
  }
472
483
  currentTarget = target
473
- requestManager
484
+ requestManager()
474
485
  .asDrawable()
475
486
  .load(OneKeyImageModel.build(requestUrl, headersJson))
476
- .apply(requestOptions(policy))
487
+ .apply(requestOptions(policy, requestUrl))
477
488
  .override(decodeDimensions.width, decodeDimensions.height)
478
489
  .listener(object : RequestListener<Drawable> {
479
490
  override fun onLoadFailed(
@@ -501,12 +512,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
501
512
  .into(target)
502
513
  }
503
514
 
504
- private fun requestOptions(policy: OneKeyImageCachePolicy): RequestOptions {
515
+ private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions {
505
516
  val options = RequestOptions()
517
+ .withOneKeyAvatarCache(uri)
506
518
  .dontTransform()
507
519
  .downsample(OneKeyImageSafeDownsampleStrategy)
508
520
  return when (policy) {
509
- OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
521
+ OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri))
510
522
  OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE)
511
523
  OneKeyImageCachePolicy.DISK -> options
512
524
  .diskCacheStrategy(DiskCacheStrategy.DATA)
@@ -547,7 +559,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) :
547
559
  // Clear the field first so onResourceCleared from our own cancellation
548
560
  // cannot erase state belonging to the next generation/request.
549
561
  currentTarget = null
550
- requestManager.clear(target)
562
+ requestManager().clear(target)
551
563
  }
552
564
 
553
565
  private fun scheduleOnDisplay(requestGeneration: Long) {
@@ -22,11 +22,12 @@ class HybridOneKeyImageCache : HybridOneKeyImageCacheSpec() {
22
22
  return@forEach
23
23
  }
24
24
  val baseOptions = RequestOptions()
25
+ .withOneKeyAvatarCache(source.uri)
25
26
  .dontTransform()
26
27
  .downsample(OneKeyImageSafeDownsampleStrategy)
27
28
  val options = when (source.cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK) {
28
29
  OneKeyImageCachePolicy.MEMORY_DISK -> baseOptions
29
- .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
30
+ .diskCacheStrategy(oneKeyImageMemoryDiskStrategy(source.uri))
30
31
  OneKeyImageCachePolicy.MEMORY -> baseOptions
31
32
  .diskCacheStrategy(DiskCacheStrategy.NONE)
32
33
  OneKeyImageCachePolicy.DISK -> baseOptions
@@ -28,6 +28,7 @@ import com.github.penfeizhou.animation.glide.StreamAnimationDecoder
28
28
  import java.io.ByteArrayOutputStream
29
29
  import java.io.ByteArrayInputStream
30
30
  import java.io.IOException
31
+ import java.io.File
31
32
  import java.io.InputStream
32
33
  import java.nio.ByteBuffer
33
34
 
@@ -50,6 +51,16 @@ internal object OneKeyImageGlideRegistry {
50
51
  val glide = Glide.get(appContext)
51
52
  val registry = glide.registry
52
53
 
54
+ registry.prepend(
55
+ File::class.java,
56
+ ByteBuffer::class.java,
57
+ OneKeyAvatarCacheFileLoaderFactory(),
58
+ )
59
+ registry.prepend(
60
+ OneKeyBlockieAvatarModel::class.java,
61
+ ByteBuffer::class.java,
62
+ OneKeyBlockieAvatarLoaderFactory(),
63
+ )
53
64
  registry.prepend(
54
65
  OneKeyImageDataUriModel::class.java,
55
66
  ByteBuffer::class.java,
@@ -25,6 +25,7 @@ internal object OneKeyImageModel {
25
25
  }
26
26
 
27
27
  fun build(uri: String, headersJson: String?): Any {
28
+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) return OneKeyBlockieAvatarModel(uri)
28
29
  if (uri.startsWith("data:")) return OneKeyImageDataUriModel(uri)
29
30
  if (!uri.startsWith("http://") && !uri.startsWith("https://")) {
30
31
  return OneKeyImageLocalModel(Uri.parse(uri))
@@ -0,0 +1,76 @@
1
+ package com.margelo.nitro.onekeyimage
2
+
3
+ import android.widget.FrameLayout
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+
6
+ /** A native-only OneKeyImage host for reusable container views such as list cells. */
7
+ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context) {
8
+ private val image = HybridOneKeyImage(context).apply {
9
+ usesApplicationRequestManager = true
10
+ }
11
+
12
+ init {
13
+ addView(
14
+ image.view,
15
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
16
+ )
17
+ clipChildren = true
18
+ }
19
+
20
+ fun configure(
21
+ sourceUri: String?,
22
+ sourceHeadersJson: String?,
23
+ variant: String,
24
+ contentFit: String,
25
+ cachePolicy: String,
26
+ autoplay: Boolean,
27
+ recyclingKey: String,
28
+ optimizeTos: Boolean,
29
+ overscan: Double,
30
+ loadingStrategy: String,
31
+ onLoad: (() -> Unit)? = null,
32
+ onError: (() -> Unit)? = null,
33
+ ) {
34
+ // OneKey patch: Let reusable cells display their own success and fallback visuals.
35
+ image.onLoad = { _, _, _ -> onLoad?.invoke() }
36
+ image.onError = { _ -> onError?.invoke() }
37
+ image.sourceHeadersJson = sourceHeadersJson
38
+ image.variant = when (variant) {
39
+ "token" -> OneKeyImageVariant.TOKEN
40
+ "network" -> OneKeyImageVariant.NETWORK
41
+ "avatar" -> OneKeyImageVariant.AVATAR
42
+ else -> OneKeyImageVariant.GENERIC
43
+ }
44
+ image.contentFit = when (contentFit) {
45
+ "contain" -> OneKeyImageContentFit.CONTAIN
46
+ "fill" -> OneKeyImageContentFit.FILL
47
+ "center" -> OneKeyImageContentFit.CENTER
48
+ else -> OneKeyImageContentFit.COVER
49
+ }
50
+ image.cachePolicy = when (cachePolicy) {
51
+ "memory" -> OneKeyImageCachePolicy.MEMORY
52
+ "disk" -> OneKeyImageCachePolicy.DISK
53
+ "none" -> OneKeyImageCachePolicy.NONE
54
+ else -> OneKeyImageCachePolicy.MEMORY_DISK
55
+ }
56
+ image.autoplay = autoplay
57
+ image.recyclingKey = recyclingKey
58
+ image.optimizeTos = optimizeTos
59
+ image.overscan = overscan
60
+ image.loadingStrategy = when (loadingStrategy) {
61
+ "skeleton" -> OneKeyImageLoadingStrategy.SKELETON
62
+ "none" -> OneKeyImageLoadingStrategy.NONE
63
+ else -> OneKeyImageLoadingStrategy.STATIC
64
+ }
65
+ image.sourceUri = sourceUri
66
+ image.afterUpdate()
67
+ }
68
+
69
+ fun prepareForReuse() {
70
+ image.prepareForRecycle()
71
+ }
72
+
73
+ fun dispose() {
74
+ image.onDropView()
75
+ }
76
+ }