@granite-js/image 1.0.12 → 1.0.13

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @granite-js/image
2
2
 
3
+ ## 1.0.13
4
+
5
+ ### Patch Changes
6
+
7
+ - e26b4ea: refactor(android): improve GraniteImage code quality - fix bugs, reduce complexity, introduce DI
8
+ - eddab4b: feat(android): implement clearMemoryCache/clearDiskCache for GraniteImage Android
9
+
3
10
  ## 1.0.12
4
11
 
5
12
  ## 1.0.11
@@ -4,6 +4,8 @@ import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.PorterDuff
6
6
  import android.graphics.PorterDuffColorFilter
7
+ import android.graphics.drawable.BitmapDrawable
8
+ import android.net.Uri
7
9
  import android.util.Log
8
10
  import android.view.View
9
11
  import android.widget.ImageView
@@ -12,34 +14,26 @@ import run.granite.image.GraniteImagePriority
12
14
  import run.granite.image.GraniteImageCachePolicy
13
15
  import run.granite.image.GraniteImageProgressCallback
14
16
  import run.granite.image.GraniteImageCompletionCallback
17
+ import coil.Coil
15
18
  import coil.load
16
19
  import coil.request.CachePolicy
17
20
  import coil.request.ErrorResult
21
+ import coil.request.ImageRequest
18
22
  import coil.request.SuccessResult
19
23
 
20
24
  /**
21
25
  * GraniteImageProvider implementation using Coil.
22
26
  */
23
27
  class CoilImageProvider : GraniteImageProvider {
24
- companion object {
25
- private const val TAG = "CoilImageProvider"
26
- }
27
-
28
- override fun createImageView(context: Context): View {
29
- return ImageView(context).apply {
30
- setBackgroundColor(android.graphics.Color.LTGRAY)
31
- }
32
- }
33
-
34
28
  override fun loadImage(url: String, into: View, scaleType: ImageView.ScaleType) {
35
29
  loadImage(url, into, scaleType, null, GraniteImagePriority.NORMAL, GraniteImageCachePolicy.DISK, null, null, null)
36
30
  }
37
31
 
38
32
  private fun isValidImageUrl(url: String): Boolean {
39
33
  return try {
40
- val uri = android.net.Uri.parse(url)
34
+ val uri = Uri.parse(url)
41
35
  val scheme = uri.scheme?.lowercase()
42
- scheme == "http" || scheme == "https"
36
+ scheme == "http" || scheme == "https" || scheme == "file" || scheme == "content"
43
37
  } catch (e: Exception) {
44
38
  false
45
39
  }
@@ -56,22 +50,7 @@ class CoilImageProvider : GraniteImageProvider {
56
50
  progressCallback: GraniteImageProgressCallback?,
57
51
  completionCallback: GraniteImageCompletionCallback?
58
52
  ) {
59
- val imageView: ImageView? = if (into != null) {
60
- if (into !is ImageView) {
61
- Log.e(TAG, "View is not an ImageView")
62
- completionCallback?.invoke(null, Exception("View is not an ImageView"), 0, 0)
63
- return
64
- }
65
- into.scaleType = scaleType
66
- into
67
- } else {
68
- null
69
- }
70
-
71
- if (imageView == null) {
72
- completionCallback?.invoke(null, Exception("No view provided"), 0, 0)
73
- return
74
- }
53
+ val imageView = validateImageView(into, scaleType, completionCallback) ?: return
75
54
 
76
55
  if (!isValidImageUrl(url)) {
77
56
  Log.e(TAG, "Invalid URL: $url")
@@ -80,54 +59,74 @@ class CoilImageProvider : GraniteImageProvider {
80
59
  }
81
60
 
82
61
  imageView.load(url) {
83
- // Add headers if provided
84
- headers?.forEach { (key, value) ->
85
- addHeader(key, value)
86
- }
62
+ headers?.forEach { (key, value) -> addHeader(key, value) }
63
+ applyCachePolicy(cachePolicy)
64
+ applyPlaceholder(imageView.context, defaultSource)
65
+ listener(
66
+ onStart = { Log.d(TAG, "Loading started: $url") },
67
+ onSuccess = { _, result -> handleSuccess(result, url, completionCallback) },
68
+ onError = { _, result -> handleError(result, completionCallback) }
69
+ )
70
+ }
71
+ }
87
72
 
88
- // Apply cache policy
89
- when (cachePolicy) {
90
- GraniteImageCachePolicy.NONE -> {
91
- memoryCachePolicy(CachePolicy.DISABLED)
92
- diskCachePolicy(CachePolicy.DISABLED)
93
- }
94
- GraniteImageCachePolicy.MEMORY -> {
95
- memoryCachePolicy(CachePolicy.ENABLED)
96
- diskCachePolicy(CachePolicy.DISABLED)
97
- }
98
- GraniteImageCachePolicy.DISK -> {
99
- memoryCachePolicy(CachePolicy.ENABLED)
100
- diskCachePolicy(CachePolicy.ENABLED)
101
- }
102
- }
73
+ private fun validateImageView(
74
+ into: View?,
75
+ scaleType: ImageView.ScaleType,
76
+ completionCallback: GraniteImageCompletionCallback?
77
+ ): ImageView? {
78
+ if (into == null) {
79
+ completionCallback?.invoke(null, Exception("No view provided"), 0, 0)
80
+ return null
81
+ }
82
+ if (into !is ImageView) {
83
+ Log.e(TAG, "View is not an ImageView")
84
+ completionCallback?.invoke(null, Exception("View is not an ImageView"), 0, 0)
85
+ return null
86
+ }
87
+ into.scaleType = scaleType
88
+ return into
89
+ }
103
90
 
104
- // Apply default source (placeholder)
105
- if (!defaultSource.isNullOrEmpty()) {
106
- val resourceId = imageView.context.resources.getIdentifier(defaultSource, "drawable", imageView.context.packageName)
107
- if (resourceId != 0) {
108
- placeholder(resourceId)
109
- }
91
+ private fun ImageRequest.Builder.applyCachePolicy(cachePolicy: GraniteImageCachePolicy) {
92
+ when (cachePolicy) {
93
+ GraniteImageCachePolicy.NONE -> {
94
+ memoryCachePolicy(CachePolicy.DISABLED)
95
+ diskCachePolicy(CachePolicy.DISABLED)
96
+ }
97
+ GraniteImageCachePolicy.MEMORY -> {
98
+ memoryCachePolicy(CachePolicy.ENABLED)
99
+ diskCachePolicy(CachePolicy.DISABLED)
110
100
  }
101
+ GraniteImageCachePolicy.DISK -> {
102
+ memoryCachePolicy(CachePolicy.ENABLED)
103
+ diskCachePolicy(CachePolicy.ENABLED)
104
+ }
105
+ }
106
+ }
111
107
 
112
- listener(
113
- onStart = {
114
- Log.d(TAG, "Loading started: $url")
115
- },
116
- onSuccess = { _, result ->
117
- val bitmap = (result.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
118
- val width = bitmap?.width ?: 0
119
- val height = bitmap?.height ?: 0
120
- Log.d(TAG, "Loaded with Coil: $url")
121
- completionCallback?.invoke(bitmap, null, width, height)
122
- },
123
- onError = { _, result ->
124
- Log.e(TAG, "Error loading image: ${result.throwable.message}")
125
- completionCallback?.invoke(null, result.throwable as? Exception, 0, 0)
126
- }
127
- )
108
+ private fun ImageRequest.Builder.applyPlaceholder(context: Context, defaultSource: String?) {
109
+ if (!defaultSource.isNullOrEmpty()) {
110
+ val resourceId = context.resources.getIdentifier(defaultSource, "drawable", context.packageName)
111
+ if (resourceId != 0) placeholder(resourceId)
128
112
  }
129
113
  }
130
114
 
115
+ private fun handleSuccess(
116
+ result: SuccessResult,
117
+ url: String,
118
+ completionCallback: GraniteImageCompletionCallback?
119
+ ) {
120
+ val bitmap = (result.drawable as? BitmapDrawable)?.bitmap
121
+ Log.d(TAG, "Loaded with Coil: $url")
122
+ completionCallback?.invoke(bitmap, null, bitmap?.width ?: 0, bitmap?.height ?: 0)
123
+ }
124
+
125
+ private fun handleError(result: ErrorResult, completionCallback: GraniteImageCompletionCallback?) {
126
+ Log.e(TAG, "Error loading image: ${result.throwable.message}")
127
+ completionCallback?.invoke(null, result.throwable as? Exception, 0, 0)
128
+ }
129
+
131
130
  override fun cancelLoad(view: View) {
132
131
  if (view is ImageView) {
133
132
  view.load(null as String?)
@@ -153,4 +152,18 @@ class CoilImageProvider : GraniteImageProvider {
153
152
  // Coil preload is not directly supported without a context
154
153
  onCompletion?.invoke(false, 0, 0, "Preload not supported without context")
155
154
  }
155
+
156
+ override fun clearMemoryCache(context: Context) {
157
+ Coil.imageLoader(context).memoryCache?.clear()
158
+ Log.d(TAG, "Memory cache cleared (Coil 2.x)")
159
+ }
160
+
161
+ override fun clearDiskCache(context: Context) {
162
+ // Coil 2.x: no public API for diskCache — no-op
163
+ Log.d(TAG, "Disk cache clear not supported (Coil 2.x)")
164
+ }
165
+
166
+ companion object {
167
+ private const val TAG = "CoilImageProvider"
168
+ }
156
169
  }
@@ -4,15 +4,11 @@ import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.PorterDuff
6
6
  import android.graphics.PorterDuffColorFilter
7
- import android.graphics.drawable.Drawable
7
+ import android.os.Handler
8
+ import android.os.Looper
8
9
  import android.util.Log
9
10
  import android.view.View
10
11
  import android.widget.ImageView
11
- import run.granite.image.GraniteImageProvider
12
- import run.granite.image.GraniteImagePriority
13
- import run.granite.image.GraniteImageCachePolicy
14
- import run.granite.image.GraniteImageProgressCallback
15
- import run.granite.image.GraniteImageCompletionCallback
16
12
  import com.bumptech.glide.Glide
17
13
  import com.bumptech.glide.load.DataSource
18
14
  import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -22,21 +18,17 @@ import com.bumptech.glide.load.model.LazyHeaders
22
18
  import com.bumptech.glide.request.RequestListener
23
19
  import com.bumptech.glide.request.target.Target
24
20
  import com.bumptech.glide.Priority as GlidePriority
21
+ import run.granite.image.GraniteImageProvider
22
+ import run.granite.image.GraniteImagePriority
23
+ import run.granite.image.GraniteImageCachePolicy
24
+ import run.granite.image.GraniteImageProgressCallback
25
+ import run.granite.image.GraniteImageCompletionCallback
26
+ import java.util.concurrent.Executors
25
27
 
26
28
  /**
27
29
  * GraniteImageProvider implementation using Glide.
28
30
  */
29
31
  class GlideImageProvider : GraniteImageProvider {
30
- companion object {
31
- private const val TAG = "GlideImageProvider"
32
- }
33
-
34
- override fun createImageView(context: Context): View {
35
- return ImageView(context).apply {
36
- setBackgroundColor(android.graphics.Color.LTGRAY)
37
- }
38
- }
39
-
40
32
  override fun loadImage(url: String, into: View, scaleType: ImageView.ScaleType) {
41
33
  loadImage(url, into, scaleType, null, GraniteImagePriority.NORMAL, GraniteImageCachePolicy.DISK, null, null, null)
42
34
  }
@@ -52,91 +44,94 @@ class GlideImageProvider : GraniteImageProvider {
52
44
  progressCallback: GraniteImageProgressCallback?,
53
45
  completionCallback: GraniteImageCompletionCallback?
54
46
  ) {
55
- val imageView: ImageView? = if (into != null) {
56
- if (into !is ImageView) {
57
- Log.e(TAG, "View is not an ImageView")
58
- completionCallback?.invoke(null, Exception("View is not an ImageView"), 0, 0)
59
- return
60
- }
61
- into.scaleType = scaleType
62
- into
63
- } else {
64
- null
47
+ val imageView = validateImageView(into, scaleType, completionCallback) ?: return
48
+ val context = imageView.context ?: run {
49
+ completionCallback?.invoke(null, Exception("Glide requires a view context for image loading"), 0, 0)
50
+ return
65
51
  }
66
52
 
67
- // Build GlideUrl with headers if provided
68
- val glideUrl = if (headers != null && headers.isNotEmpty()) {
69
- val headersBuilder = LazyHeaders.Builder()
70
- headers.forEach { (key, value) ->
71
- headersBuilder.addHeader(key, value)
72
- }
73
- GlideUrl(url, headersBuilder.build())
74
- } else {
75
- GlideUrl(url)
76
- }
53
+ val glideUrl = buildGlideUrl(url, headers)
54
+ val requestBuilder = configureRequest(context, glideUrl, priority, cachePolicy, defaultSource)
55
+ .listener(createRequestListener(url, completionCallback))
77
56
 
78
- val context = imageView?.context ?: return
57
+ requestBuilder.into(imageView)
58
+ }
79
59
 
80
- // Build request
81
- var requestBuilder = Glide.with(context)
82
- .asBitmap()
83
- .load(glideUrl)
60
+ private fun validateImageView(
61
+ into: View?,
62
+ scaleType: ImageView.ScaleType,
63
+ completionCallback: GraniteImageCompletionCallback?
64
+ ): ImageView? {
65
+ if (into == null) return null
66
+ if (into !is ImageView) {
67
+ Log.e(TAG, "View is not an ImageView")
68
+ completionCallback?.invoke(null, Exception("View is not an ImageView"), 0, 0)
69
+ return null
70
+ }
71
+ into.scaleType = scaleType
72
+ return into
73
+ }
84
74
 
85
- // Apply priority
86
- requestBuilder = when (priority) {
87
- GraniteImagePriority.LOW -> requestBuilder.priority(GlidePriority.LOW)
88
- GraniteImagePriority.NORMAL -> requestBuilder.priority(GlidePriority.NORMAL)
89
- GraniteImagePriority.HIGH -> requestBuilder.priority(GlidePriority.HIGH)
75
+ private fun buildGlideUrl(url: String, headers: Map<String, String>?): GlideUrl {
76
+ if (headers.isNullOrEmpty()) return GlideUrl(url)
77
+ val headersBuilder = LazyHeaders.Builder()
78
+ headers.forEach { (key, value) -> headersBuilder.addHeader(key, value) }
79
+ return GlideUrl(url, headersBuilder.build())
80
+ }
81
+
82
+ private fun configureRequest(
83
+ context: Context,
84
+ glideUrl: GlideUrl,
85
+ priority: GraniteImagePriority,
86
+ cachePolicy: GraniteImageCachePolicy,
87
+ defaultSource: String?
88
+ ): com.bumptech.glide.RequestBuilder<Bitmap> {
89
+ var builder = Glide.with(context).asBitmap().load(glideUrl)
90
+
91
+ builder = when (priority) {
92
+ GraniteImagePriority.LOW -> builder.priority(GlidePriority.LOW)
93
+ GraniteImagePriority.NORMAL -> builder.priority(GlidePriority.NORMAL)
94
+ GraniteImagePriority.HIGH -> builder.priority(GlidePriority.HIGH)
90
95
  }
91
96
 
92
- // Apply cache policy
93
- requestBuilder = when (cachePolicy) {
94
- GraniteImageCachePolicy.NONE -> requestBuilder.diskCacheStrategy(DiskCacheStrategy.NONE).skipMemoryCache(true)
95
- GraniteImageCachePolicy.MEMORY -> requestBuilder.diskCacheStrategy(DiskCacheStrategy.NONE)
96
- GraniteImageCachePolicy.DISK -> requestBuilder.diskCacheStrategy(DiskCacheStrategy.ALL)
97
+ builder = when (cachePolicy) {
98
+ GraniteImageCachePolicy.NONE -> builder.diskCacheStrategy(DiskCacheStrategy.NONE).skipMemoryCache(true)
99
+ GraniteImageCachePolicy.MEMORY -> builder.diskCacheStrategy(DiskCacheStrategy.NONE)
100
+ GraniteImageCachePolicy.DISK -> builder.diskCacheStrategy(DiskCacheStrategy.ALL)
97
101
  }
98
102
 
99
- // Apply default source (placeholder)
100
103
  if (!defaultSource.isNullOrEmpty()) {
101
- // Try to load as drawable resource first, then as URL
102
104
  val resourceId = context.resources.getIdentifier(defaultSource, "drawable", context.packageName)
103
- if (resourceId != 0) {
104
- requestBuilder = requestBuilder.placeholder(resourceId)
105
- }
105
+ if (resourceId != 0) builder = builder.placeholder(resourceId)
106
106
  }
107
107
 
108
- // Add listener for completion callback
109
- requestBuilder = requestBuilder.listener(object : RequestListener<Bitmap> {
110
- override fun onLoadFailed(
111
- e: GlideException?,
112
- model: Any?,
113
- target: Target<Bitmap>,
114
- isFirstResource: Boolean
115
- ): Boolean {
116
- Log.e(TAG, "Error loading image: ${e?.message}")
117
- completionCallback?.invoke(null, e, 0, 0)
118
- return false
119
- }
108
+ return builder
109
+ }
120
110
 
121
- override fun onResourceReady(
122
- resource: Bitmap,
123
- model: Any,
124
- target: Target<Bitmap>?,
125
- dataSource: DataSource,
126
- isFirstResource: Boolean
127
- ): Boolean {
128
- val cacheTypeStr = when (dataSource) {
129
- DataSource.MEMORY_CACHE -> "Memory"
130
- DataSource.DATA_DISK_CACHE, DataSource.RESOURCE_DISK_CACHE -> "Disk"
131
- else -> "Network"
132
- }
133
- Log.d(TAG, "Loaded with Glide ($cacheTypeStr): $url")
134
- completionCallback?.invoke(resource, null, resource.width, resource.height)
135
- return false
136
- }
137
- })
111
+ private fun createRequestListener(
112
+ url: String,
113
+ completionCallback: GraniteImageCompletionCallback?
114
+ ): RequestListener<Bitmap> = object : RequestListener<Bitmap> {
115
+ override fun onLoadFailed(
116
+ e: GlideException?, model: Any?, target: Target<Bitmap>, isFirstResource: Boolean
117
+ ): Boolean {
118
+ Log.e(TAG, "Error loading image: ${e?.message}")
119
+ completionCallback?.invoke(null, e, 0, 0)
120
+ return false
121
+ }
138
122
 
139
- requestBuilder.into(imageView)
123
+ override fun onResourceReady(
124
+ resource: Bitmap, model: Any, target: Target<Bitmap>?, dataSource: DataSource, isFirstResource: Boolean
125
+ ): Boolean {
126
+ val cacheType = when (dataSource) {
127
+ DataSource.MEMORY_CACHE -> "Memory"
128
+ DataSource.DATA_DISK_CACHE, DataSource.RESOURCE_DISK_CACHE -> "Disk"
129
+ else -> "Network"
130
+ }
131
+ Log.d(TAG, "Loaded with Glide ($cacheType): $url")
132
+ completionCallback?.invoke(resource, null, resource.width, resource.height)
133
+ return false
134
+ }
140
135
  }
141
136
 
142
137
  override fun cancelLoad(view: View) {
@@ -165,4 +160,32 @@ class GlideImageProvider : GraniteImageProvider {
165
160
  // This would need an Application context passed during initialization
166
161
  onCompletion?.invoke(false, 0, 0, "Preload not supported without context")
167
162
  }
163
+
164
+ override fun clearMemoryCache(context: Context) {
165
+ // Glide: clearMemory() must be called on the main thread
166
+ if (Looper.myLooper() == Looper.getMainLooper()) {
167
+ Glide.get(context).clearMemory()
168
+ } else {
169
+ Handler(Looper.getMainLooper()).post {
170
+ Glide.get(context).clearMemory()
171
+ }
172
+ }
173
+ Log.d(TAG, "Memory cache cleared (Glide)")
174
+ }
175
+
176
+ override fun clearDiskCache(context: Context) {
177
+ // Glide: clearDiskCache() must be called on a background thread
178
+ if (Looper.myLooper() != Looper.getMainLooper()) {
179
+ Glide.get(context).clearDiskCache()
180
+ } else {
181
+ Executors.newSingleThreadExecutor().execute {
182
+ Glide.get(context).clearDiskCache()
183
+ }
184
+ }
185
+ Log.d(TAG, "Disk cache cleared (Glide)")
186
+ }
187
+
188
+ companion object {
189
+ private const val TAG = "GlideImageProvider"
190
+ }
168
191
  }
@@ -1,6 +1,7 @@
1
1
  package run.granite.image
2
2
 
3
3
  import android.content.Context
4
+ import android.graphics.Bitmap
4
5
  import android.graphics.Color
5
6
  import android.graphics.PorterDuff
6
7
  import android.graphics.PorterDuffColorFilter
@@ -10,11 +11,9 @@ import android.view.View
10
11
  import android.widget.FrameLayout
11
12
  import android.widget.ImageView
12
13
  import android.widget.TextView
13
- import com.facebook.react.bridge.Arguments
14
14
  import com.facebook.react.bridge.ReactContext
15
- import com.facebook.react.bridge.WritableMap
16
15
  import com.facebook.react.uimanager.UIManagerHelper
17
- import com.facebook.react.uimanager.events.EventDispatcher
16
+ import com.facebook.react.uimanager.events.Event
18
17
  import org.json.JSONObject
19
18
 
20
19
  /**
@@ -22,9 +21,7 @@ import org.json.JSONObject
22
21
  * It delegates actual image loading to the registered GraniteImageProvider.
23
22
  */
24
23
  class GraniteImage(context: Context) : FrameLayout(context) {
25
- companion object {
26
- private const val TAG = "GraniteImage"
27
- }
24
+ internal var providerResolver: () -> GraniteImageProvider? = { GraniteImageRegistry.provider }
28
25
 
29
26
  private var containerView: View? = null
30
27
  private var currentUri: String? = null
@@ -42,46 +39,25 @@ class GraniteImage(context: Context) : FrameLayout(context) {
42
39
  Log.d(TAG, "GraniteImage initialized")
43
40
  }
44
41
 
45
- // Event dispatching methods using modern EventDispatcher
46
- private fun getEventDispatcher(): EventDispatcher? {
47
- val reactContext = context as? ReactContext ?: return null
48
- return UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
42
+ private fun dispatchEvent(event: Event<*>) {
43
+ val reactContext = context as? ReactContext ?: return
44
+ UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent(event)
49
45
  }
50
46
 
51
- private fun emitLoadStart() {
52
- Log.d(TAG, "emitLoadStart")
53
- getEventDispatcher()?.dispatchEvent(
54
- GraniteImageLoadStartEvent(UIManagerHelper.getSurfaceId(this), id)
55
- )
56
- }
47
+ private fun emitLoadStart() =
48
+ dispatchEvent(GraniteImageLoadStartEvent(UIManagerHelper.getSurfaceId(this), id))
57
49
 
58
- private fun emitProgress(loaded: Int, total: Int) {
59
- Log.d(TAG, "emitProgress: $loaded / $total")
60
- getEventDispatcher()?.dispatchEvent(
61
- GraniteImageProgressEvent(UIManagerHelper.getSurfaceId(this), id, loaded, total)
62
- )
63
- }
50
+ private fun emitProgress(loaded: Int, total: Int) =
51
+ dispatchEvent(GraniteImageProgressEvent(UIManagerHelper.getSurfaceId(this), id, loaded, total))
64
52
 
65
- private fun emitLoad(width: Int, height: Int) {
66
- Log.d(TAG, "emitLoad: ${width}x${height}")
67
- getEventDispatcher()?.dispatchEvent(
68
- GraniteImageLoadEvent(UIManagerHelper.getSurfaceId(this), id, width, height)
69
- )
70
- }
53
+ private fun emitLoad(width: Int, height: Int) =
54
+ dispatchEvent(GraniteImageLoadEvent(UIManagerHelper.getSurfaceId(this), id, width, height))
71
55
 
72
- private fun emitError(error: String) {
73
- Log.d(TAG, "emitError: $error")
74
- getEventDispatcher()?.dispatchEvent(
75
- GraniteImageErrorEvent(UIManagerHelper.getSurfaceId(this), id, error)
76
- )
77
- }
56
+ private fun emitError(error: String) =
57
+ dispatchEvent(GraniteImageErrorEvent(UIManagerHelper.getSurfaceId(this), id, error))
78
58
 
79
- private fun emitLoadEnd() {
80
- Log.d(TAG, "emitLoadEnd")
81
- getEventDispatcher()?.dispatchEvent(
82
- GraniteImageLoadEndEvent(UIManagerHelper.getSurfaceId(this), id)
83
- )
84
- }
59
+ private fun emitLoadEnd() =
60
+ dispatchEvent(GraniteImageLoadEndEvent(UIManagerHelper.getSurfaceId(this), id))
85
61
 
86
62
  fun setUri(uri: String?) {
87
63
  if (uri != currentUri) {
@@ -123,14 +99,11 @@ class GraniteImage(context: Context) : FrameLayout(context) {
123
99
  }
124
100
 
125
101
  fun setPriority(priority: String?) {
126
- currentPriority = when (priority) {
127
- "low" -> GraniteImagePriority.LOW
128
- "high" -> GraniteImagePriority.HIGH
129
- else -> GraniteImagePriority.NORMAL
130
- }
102
+ currentPriority = GraniteImagePriority.fromString(priority)
131
103
  }
132
104
 
133
105
  fun setCachePolicy(cachePolicy: String?) {
106
+ // View-side API uses canonical names: "memory", "none", "disk" (default)
134
107
  currentCachePolicy = when (cachePolicy) {
135
108
  "memory" -> GraniteImageCachePolicy.MEMORY
136
109
  "none" -> GraniteImageCachePolicy.NONE
@@ -163,7 +136,7 @@ class GraniteImage(context: Context) : FrameLayout(context) {
163
136
  containerView = null
164
137
  }
165
138
 
166
- val provider = GraniteImageRegistry.provider
139
+ val provider = providerResolver()
167
140
 
168
141
  if (provider == null) {
169
142
  showErrorView("No GraniteImageProvider registered")
@@ -206,37 +179,48 @@ class GraniteImage(context: Context) : FrameLayout(context) {
206
179
  },
207
180
  completionCallback = { bitmap, error, width, height ->
208
181
  post {
209
- if (error != null) {
210
- emitError(error.message ?: "Unknown error")
211
-
212
- // Load fallback image if available
213
- currentFallbackSource?.let { fallback ->
214
- provider.loadImage(
215
- url = fallback,
216
- into = imageView,
217
- scaleType = currentScaleType,
218
- headers = null,
219
- priority = GraniteImagePriority.HIGH,
220
- cachePolicy = GraniteImageCachePolicy.DISK,
221
- defaultSource = null,
222
- progressCallback = null,
223
- completionCallback = null
224
- )
225
- }
226
- } else {
227
- emitLoad(width, height)
228
-
229
- // Apply tint color if set
230
- currentTintColor?.let { color ->
231
- provider.applyTintColor(color, imageView)
232
- }
233
- }
234
- emitLoadEnd()
182
+ handleLoadCompletion(bitmap, error, width, height, imageView, provider)
235
183
  }
236
184
  }
237
185
  )
238
186
  }
239
187
 
188
+ private fun handleLoadCompletion(
189
+ bitmap: Bitmap?,
190
+ error: Exception?,
191
+ width: Int,
192
+ height: Int,
193
+ imageView: View,
194
+ provider: GraniteImageProvider
195
+ ) {
196
+ if (error != null) {
197
+ emitError(error.message ?: "Unknown error")
198
+
199
+ // Load fallback image if available
200
+ currentFallbackSource?.let { fallback ->
201
+ provider.loadImage(
202
+ url = fallback,
203
+ into = imageView,
204
+ scaleType = currentScaleType,
205
+ headers = null,
206
+ priority = GraniteImagePriority.HIGH,
207
+ cachePolicy = GraniteImageCachePolicy.DISK,
208
+ defaultSource = null,
209
+ progressCallback = null,
210
+ completionCallback = null
211
+ )
212
+ }
213
+ } else {
214
+ emitLoad(width, height)
215
+
216
+ // Apply tint color if set
217
+ currentTintColor?.let { color ->
218
+ provider.applyTintColor(color, imageView)
219
+ }
220
+ }
221
+ emitLoadEnd()
222
+ }
223
+
240
224
  private fun showErrorView(message: String) {
241
225
  Log.e(TAG, message)
242
226
 
@@ -266,7 +250,7 @@ class GraniteImage(context: Context) : FrameLayout(context) {
266
250
  }
267
251
 
268
252
  fun cleanup() {
269
- val provider = GraniteImageRegistry.provider
253
+ val provider = providerResolver()
270
254
  containerView?.let {
271
255
  provider?.cancelLoad(it)
272
256
  removeView(it)
@@ -274,4 +258,8 @@ class GraniteImage(context: Context) : FrameLayout(context) {
274
258
  containerView = null
275
259
  currentUri = null
276
260
  }
261
+
262
+ companion object {
263
+ private const val TAG = "GraniteImage"
264
+ }
277
265
  }
@@ -1,14 +1,12 @@
1
1
  package run.granite.image
2
2
 
3
3
  import android.util.Log
4
- import com.facebook.react.bridge.ReadableMap
5
4
  import com.facebook.react.common.MapBuilder
6
5
  import com.facebook.react.module.annotations.ReactModule
7
6
  import com.facebook.react.uimanager.SimpleViewManager
8
7
  import com.facebook.react.uimanager.ThemedReactContext
9
8
  import com.facebook.react.uimanager.ViewManagerDelegate
10
9
  import com.facebook.react.uimanager.annotations.ReactProp
11
- import com.facebook.react.uimanager.events.EventDispatcher
12
10
  import com.facebook.react.viewmanagers.GraniteImageManagerDelegate
13
11
  import com.facebook.react.viewmanagers.GraniteImageManagerInterface
14
12
 
@@ -18,11 +16,6 @@ import com.facebook.react.viewmanagers.GraniteImageManagerInterface
18
16
  */
19
17
  @ReactModule(name = GraniteImageManager.NAME)
20
18
  class GraniteImageManager : SimpleViewManager<GraniteImage>(), GraniteImageManagerInterface<GraniteImage> {
21
- companion object {
22
- const val NAME = "GraniteImage"
23
- private const val TAG = "GraniteImageManager"
24
- }
25
-
26
19
  private val delegate: ViewManagerDelegate<GraniteImage> = GraniteImageManagerDelegate(this)
27
20
 
28
21
  override fun getName(): String = NAME
@@ -97,4 +90,9 @@ class GraniteImageManager : SimpleViewManager<GraniteImage>(), GraniteImageManag
97
90
  .put("onGraniteLoadEnd", MapBuilder.of("registrationName", "onGraniteLoadEnd"))
98
91
  .build()
99
92
  }
93
+
94
+ companion object {
95
+ const val NAME = "GraniteImage"
96
+ private const val TAG = "GraniteImageManager"
97
+ }
100
98
  }
@@ -8,18 +8,23 @@ import com.facebook.react.bridge.ReactMethod
8
8
  import com.facebook.react.module.annotations.ReactModule
9
9
  import org.json.JSONArray
10
10
  import org.json.JSONObject
11
+ import java.util.concurrent.ExecutorService
11
12
  import java.util.concurrent.Executors
12
13
  import java.util.concurrent.atomic.AtomicInteger
13
14
 
14
15
  @ReactModule(name = GraniteImageModule.NAME)
15
- class GraniteImageModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
16
-
17
- companion object {
18
- const val NAME = "GraniteImageModule"
19
- private const val TAG = "GraniteImageModule"
20
- }
21
-
22
- private val executor = Executors.newFixedThreadPool(4)
16
+ class GraniteImageModule(
17
+ reactContext: ReactApplicationContext,
18
+ private val providerResolver: () -> GraniteImageProvider? = { GraniteImageRegistry.provider },
19
+ private val executor: ExecutorService = Executors.newFixedThreadPool(4)
20
+ ) : ReactContextBaseJavaModule(reactContext) {
21
+
22
+ private data class PreloadSource(
23
+ val uri: String,
24
+ val headers: Map<String, String>?,
25
+ val priority: GraniteImagePriority,
26
+ val cachePolicy: GraniteImageCachePolicy
27
+ )
23
28
 
24
29
  override fun getName(): String = NAME
25
30
 
@@ -27,7 +32,7 @@ class GraniteImageModule(reactContext: ReactApplicationContext) : ReactContextBa
27
32
  fun preload(sourcesJson: String, promise: Promise) {
28
33
  Log.d(TAG, "preload called with: $sourcesJson")
29
34
 
30
- val provider = GraniteImageRegistry.provider
35
+ val provider = providerResolver()
31
36
  if (provider == null) {
32
37
  Log.w(TAG, "No provider registered, cannot preload")
33
38
  promise.reject("NO_PROVIDER", "No provider registered, cannot preload")
@@ -38,75 +43,19 @@ class GraniteImageModule(reactContext: ReactApplicationContext) : ReactContextBa
38
43
  try {
39
44
  val sources = JSONArray(sourcesJson)
40
45
  val totalCount = sources.length()
41
- val completedCount = AtomicInteger(0)
42
- val successCount = AtomicInteger(0)
43
- val failCount = AtomicInteger(0)
44
46
 
45
47
  if (totalCount == 0) {
46
48
  promise.resolve(null)
47
49
  return@execute
48
50
  }
49
51
 
50
- for (i in 0 until sources.length()) {
51
- val source = sources.getJSONObject(i)
52
- val uri = source.optString("uri", "")
53
- if (uri.isEmpty()) {
54
- if (completedCount.incrementAndGet() == totalCount) {
55
- Log.d(TAG, "Preload completed: ${successCount.get()} succeeded, ${failCount.get()} failed")
56
- promise.resolve(null)
57
- }
58
- continue
59
- }
60
-
61
- val headersObj = source.optJSONObject("headers")
62
- val headers = mutableMapOf<String, String>()
63
- headersObj?.let {
64
- val keys = it.keys()
65
- while (keys.hasNext()) {
66
- val key = keys.next()
67
- headers[key] = it.getString(key)
68
- }
69
- }
70
-
71
- val priorityStr = source.optString("priority", "normal")
72
- val priority = when (priorityStr) {
73
- "high" -> GraniteImagePriority.HIGH
74
- "low" -> GraniteImagePriority.LOW
75
- else -> GraniteImagePriority.NORMAL
76
- }
77
-
78
- val cacheStr = source.optString("cache", "")
79
- val cachePolicy = when (cacheStr) {
80
- "cacheOnly" -> GraniteImageCachePolicy.DISK
81
- "web" -> GraniteImageCachePolicy.NONE
82
- else -> GraniteImageCachePolicy.DISK
83
- }
84
-
85
- Log.d(TAG, "Preloading: $uri")
86
-
87
- // Call provider's preload method (loadImage with null view for preload)
88
- provider.loadImage(
89
- url = uri,
90
- imageView = null,
91
- contentMode = "cover",
92
- headers = headers.ifEmpty { null },
93
- priority = priority,
94
- cachePolicy = cachePolicy,
95
- onProgress = null,
96
- onCompletion = { success, width, height, error ->
97
- if (success) {
98
- Log.d(TAG, "Preloaded successfully: $uri (${width}x${height})")
99
- successCount.incrementAndGet()
100
- } else {
101
- Log.d(TAG, "Preload failed for $uri: $error")
102
- failCount.incrementAndGet()
103
- }
104
- if (completedCount.incrementAndGet() == totalCount) {
105
- Log.d(TAG, "Preload completed: ${successCount.get()} succeeded, ${failCount.get()} failed")
106
- promise.resolve(null)
107
- }
108
- }
109
- )
52
+ val completedCount = AtomicInteger(0)
53
+ val successCount = AtomicInteger(0)
54
+ val failCount = AtomicInteger(0)
55
+
56
+ for (i in 0 until totalCount) {
57
+ val preloadSource = parsePreloadSource(sources.getJSONObject(i))
58
+ preloadSingle(provider, preloadSource, completedCount, successCount, failCount, totalCount, promise)
110
59
  }
111
60
  } catch (e: Exception) {
112
61
  Log.e(TAG, "Failed to parse sources JSON: ${e.message}")
@@ -115,17 +64,112 @@ class GraniteImageModule(reactContext: ReactApplicationContext) : ReactContextBa
115
64
  }
116
65
  }
117
66
 
67
+ private fun preloadSingle(
68
+ provider: GraniteImageProvider,
69
+ source: PreloadSource,
70
+ completedCount: AtomicInteger,
71
+ successCount: AtomicInteger,
72
+ failCount: AtomicInteger,
73
+ totalCount: Int,
74
+ promise: Promise
75
+ ) {
76
+ if (source.uri.isEmpty()) {
77
+ checkPreloadCompletion(completedCount, successCount, failCount, totalCount, promise)
78
+ return
79
+ }
80
+
81
+ Log.d(TAG, "Preloading: ${source.uri}")
82
+ provider.loadImage(
83
+ url = source.uri,
84
+ imageView = null,
85
+ contentMode = "cover",
86
+ headers = source.headers,
87
+ priority = source.priority,
88
+ cachePolicy = source.cachePolicy,
89
+ onProgress = null,
90
+ onCompletion = { success, width, height, error ->
91
+ if (success) {
92
+ Log.d(TAG, "Preloaded successfully: ${source.uri} (${width}x${height})")
93
+ successCount.incrementAndGet()
94
+ } else {
95
+ Log.d(TAG, "Preload failed for ${source.uri}: $error")
96
+ failCount.incrementAndGet()
97
+ }
98
+ checkPreloadCompletion(completedCount, successCount, failCount, totalCount, promise)
99
+ }
100
+ )
101
+ }
102
+
103
+ private fun checkPreloadCompletion(
104
+ completedCount: AtomicInteger,
105
+ successCount: AtomicInteger,
106
+ failCount: AtomicInteger,
107
+ totalCount: Int,
108
+ promise: Promise
109
+ ) {
110
+ if (completedCount.incrementAndGet() == totalCount) {
111
+ Log.d(TAG, "Preload completed: ${successCount.get()} succeeded, ${failCount.get()} failed")
112
+ promise.resolve(null)
113
+ }
114
+ }
115
+
116
+ private fun parsePreloadSource(source: JSONObject): PreloadSource {
117
+ val uri = source.optString("uri", "")
118
+
119
+ val headersObj = source.optJSONObject("headers")
120
+ val headers = mutableMapOf<String, String>()
121
+ headersObj?.let {
122
+ val keys = it.keys()
123
+ while (keys.hasNext()) {
124
+ val key = keys.next()
125
+ headers[key] = it.getString(key)
126
+ }
127
+ }
128
+
129
+ val priorityStr = source.optString("priority", "normal")
130
+ val priority = GraniteImagePriority.fromString(priorityStr)
131
+
132
+ // Module-side API uses FastImage-compatible names: "cacheOnly", "web"
133
+ // (intentionally different from View-side API which uses "memory", "none")
134
+ val cacheStr = source.optString("cache", "")
135
+ val cachePolicy = when (cacheStr) {
136
+ "cacheOnly" -> GraniteImageCachePolicy.DISK
137
+ "web" -> GraniteImageCachePolicy.NONE
138
+ else -> GraniteImageCachePolicy.DISK
139
+ }
140
+
141
+ return PreloadSource(
142
+ uri = uri,
143
+ headers = headers.ifEmpty { null },
144
+ priority = priority,
145
+ cachePolicy = cachePolicy
146
+ )
147
+ }
148
+
118
149
  @ReactMethod
119
150
  fun clearMemoryCache(promise: Promise) {
120
151
  Log.d(TAG, "clearMemoryCache called")
121
- // Memory cache clearing depends on provider implementation
152
+ val provider = providerResolver()
153
+ val context = reactApplicationContext
154
+ if (provider != null) {
155
+ provider.clearMemoryCache(context)
156
+ }
122
157
  promise.resolve(null)
123
158
  }
124
159
 
125
160
  @ReactMethod
126
161
  fun clearDiskCache(promise: Promise) {
127
162
  Log.d(TAG, "clearDiskCache called")
128
- // Disk cache clearing depends on provider implementation
163
+ val provider = providerResolver()
164
+ val context = reactApplicationContext
165
+ if (provider != null) {
166
+ provider.clearDiskCache(context)
167
+ }
129
168
  promise.resolve(null)
130
169
  }
170
+
171
+ companion object {
172
+ const val NAME = "GraniteImageModule"
173
+ private const val TAG = "GraniteImageModule"
174
+ }
131
175
  }
@@ -10,15 +10,21 @@ import com.facebook.react.uimanager.ViewManager
10
10
  * React Native Package that registers the GraniteImage component and GraniteImageModule.
11
11
  */
12
12
  class GraniteImagePackage : ReactPackage {
13
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
14
+ ensureProviderRegistered()
15
+ return listOf(GraniteImageModule(reactContext))
16
+ }
17
+
18
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
19
+ ensureProviderRegistered()
20
+ return listOf(GraniteImageManager())
21
+ }
22
+
13
23
  companion object {
14
24
  private const val TAG = "GraniteImagePackage"
15
25
  private var providerRegistered = false
16
26
 
17
- init {
18
- registerDefaultProvider()
19
- }
20
-
21
- private fun registerDefaultProvider() {
27
+ internal fun ensureProviderRegistered() {
22
28
  if (providerRegistered) return
23
29
 
24
30
  // Try to auto-register a provider based on available implementations
@@ -48,12 +54,4 @@ class GraniteImagePackage : ReactPackage {
48
54
  }
49
55
  }
50
56
  }
51
-
52
- override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
53
- return listOf(GraniteImageModule(reactContext))
54
- }
55
-
56
- override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
57
- return listOf(GraniteImageManager())
58
- }
59
57
  }
@@ -2,6 +2,7 @@ package run.granite.image
2
2
 
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
+ import android.graphics.Color
5
6
  import android.view.View
6
7
  import android.widget.ImageView
7
8
 
@@ -9,9 +10,18 @@ import android.widget.ImageView
9
10
  * Priority levels for image loading
10
11
  */
11
12
  enum class GraniteImagePriority {
12
- LOW, NORMAL, HIGH
13
+ LOW, NORMAL, HIGH;
14
+
15
+ companion object {
16
+ fun fromString(value: String?): GraniteImagePriority = when (value) {
17
+ "low" -> LOW
18
+ "high" -> HIGH
19
+ else -> NORMAL
20
+ }
21
+ }
13
22
  }
14
23
 
24
+
15
25
  /**
16
26
  * Cache policy for image loading
17
27
  */
@@ -37,8 +47,13 @@ interface GraniteImageProvider {
37
47
  /**
38
48
  * Creates and returns a new View that will be used to display the image.
39
49
  * The returned view should be capable of displaying images (typically ImageView).
50
+ * Default implementation creates an ImageView with a light gray background.
40
51
  */
41
- fun createImageView(context: Context): View
52
+ fun createImageView(context: Context): View {
53
+ return ImageView(context).apply {
54
+ setBackgroundColor(Color.LTGRAY)
55
+ }
56
+ }
42
57
 
43
58
  /**
44
59
  * Loads an image from the given URL into the provided view.
@@ -102,4 +117,20 @@ interface GraniteImageProvider {
102
117
  fun applyTintColor(color: Int, view: View) {
103
118
  // Default implementation - do nothing
104
119
  }
120
+
121
+ /**
122
+ * Clears the memory cache.
123
+ * Default implementation is no-op. Override in provider implementations to clear actual cache.
124
+ */
125
+ fun clearMemoryCache(context: Context) {
126
+ // default: no-op
127
+ }
128
+
129
+ /**
130
+ * Clears the disk cache.
131
+ * Default implementation is no-op. Override in provider implementations to clear actual cache.
132
+ */
133
+ fun clearDiskCache(context: Context) {
134
+ // default: no-op
135
+ }
105
136
  }
@@ -1,6 +1,5 @@
1
1
  package run.granite.image.providers
2
2
 
3
- import android.content.Context
4
3
  import android.graphics.Bitmap
5
4
  import android.graphics.BitmapFactory
6
5
  import android.graphics.PorterDuff
@@ -21,8 +20,9 @@ import okhttp3.CacheControl
21
20
  import okhttp3.OkHttpClient
22
21
  import okhttp3.Request
23
22
  import okhttp3.Response
23
+ import java.io.ByteArrayOutputStream
24
24
  import java.io.IOException
25
- import java.util.WeakHashMap
25
+ import java.util.concurrent.ConcurrentHashMap
26
26
  import java.util.concurrent.TimeUnit
27
27
 
28
28
  /**
@@ -30,23 +30,13 @@ import java.util.concurrent.TimeUnit
30
30
  * This is analogous to iOS's URLSessionImageProvider.
31
31
  */
32
32
  class OkHttpImageProvider : GraniteImageProvider {
33
- companion object {
34
- private const val TAG = "OkHttpImageProvider"
35
- }
36
-
37
33
  private val client = OkHttpClient.Builder()
38
34
  .connectTimeout(30, TimeUnit.SECONDS)
39
35
  .readTimeout(30, TimeUnit.SECONDS)
40
36
  .build()
41
- private val activeCalls = WeakHashMap<View, Call>()
37
+ private val activeCalls = ConcurrentHashMap<View, Call>()
42
38
  private val mainHandler = Handler(Looper.getMainLooper())
43
39
 
44
- override fun createImageView(context: Context): View {
45
- return ImageView(context).apply {
46
- setBackgroundColor(android.graphics.Color.LTGRAY)
47
- }
48
- }
49
-
50
40
  override fun loadImage(url: String, into: View, scaleType: ImageView.ScaleType) {
51
41
  loadImage(url, into, scaleType, null, GraniteImagePriority.NORMAL, GraniteImageCachePolicy.DISK, null, null, null)
52
42
  }
@@ -85,24 +75,7 @@ class OkHttpImageProvider : GraniteImageProvider {
85
75
  }
86
76
  }
87
77
 
88
- val requestBuilder = Request.Builder().url(url)
89
-
90
- // Add headers
91
- headers?.forEach { (key, value) ->
92
- requestBuilder.addHeader(key, value)
93
- }
94
-
95
- // Apply cache policy
96
- when (cachePolicy) {
97
- GraniteImageCachePolicy.NONE -> {
98
- requestBuilder.cacheControl(CacheControl.FORCE_NETWORK)
99
- }
100
- GraniteImageCachePolicy.MEMORY, GraniteImageCachePolicy.DISK -> {
101
- // Use default caching
102
- }
103
- }
104
-
105
- val request = requestBuilder.build()
78
+ val request = buildRequest(url, headers, cachePolicy)
106
79
  val call = client.newCall(request)
107
80
  if (into != null) {
108
81
  activeCalls[into] = call
@@ -120,73 +93,102 @@ class OkHttpImageProvider : GraniteImageProvider {
120
93
  }
121
94
 
122
95
  override fun onResponse(call: Call, response: Response) {
123
- if (into != null) {
124
- activeCalls.remove(into)
125
- }
126
-
127
- if (!response.isSuccessful) {
128
- val error = Exception("HTTP error: ${response.code}")
129
- Log.e(TAG, "HTTP error: ${response.code}")
130
- mainHandler.post {
131
- completionCallback?.invoke(null, error, 0, 0)
96
+ response.use {
97
+ if (into != null) {
98
+ activeCalls.remove(into)
132
99
  }
133
- return
100
+ handleResponse(response, imageView, url, progressCallback, completionCallback)
134
101
  }
102
+ }
103
+ })
104
+ }
135
105
 
136
- val body = response.body
137
- if (body == null) {
138
- val error = Exception("No data received")
139
- Log.e(TAG, "No data received")
140
- mainHandler.post {
141
- completionCallback?.invoke(null, error, 0, 0)
142
- }
143
- return
144
- }
106
+ private fun buildRequest(
107
+ url: String,
108
+ headers: Map<String, String>?,
109
+ cachePolicy: GraniteImageCachePolicy
110
+ ): Request {
111
+ val requestBuilder = Request.Builder().url(url)
145
112
 
146
- // Get content length for progress
147
- val contentLength = body.contentLength()
148
-
149
- // Read bytes with progress reporting
150
- val inputStream = body.byteStream()
151
- val bytes = ByteArray(contentLength.toInt().coerceAtLeast(1024))
152
- var totalBytesRead = 0L
153
- val buffer = ByteArray(8192)
154
- var bytesRead: Int
155
-
156
- try {
157
- val outputStream = java.io.ByteArrayOutputStream()
158
- while (inputStream.read(buffer).also { bytesRead = it } != -1) {
159
- outputStream.write(buffer, 0, bytesRead)
160
- totalBytesRead += bytesRead
161
- if (contentLength > 0) {
162
- progressCallback?.invoke(totalBytesRead, contentLength)
163
- }
164
- }
165
- val imageBytes = outputStream.toByteArray()
166
-
167
- val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
168
- if (bitmap == null) {
169
- val error = Exception("Failed to decode image data")
170
- Log.e(TAG, "Failed to decode image data")
171
- mainHandler.post {
172
- completionCallback?.invoke(null, error, 0, 0)
173
- }
174
- return
175
- }
113
+ // Add headers
114
+ headers?.forEach { (key, value) ->
115
+ requestBuilder.addHeader(key, value)
116
+ }
176
117
 
177
- mainHandler.post {
178
- imageView?.setImageBitmap(bitmap)
179
- Log.d(TAG, "Loaded with OkHttp: $url")
180
- completionCallback?.invoke(bitmap, null, bitmap.width, bitmap.height)
181
- }
182
- } catch (e: Exception) {
183
- Log.e(TAG, "Error reading image data: ${e.message}")
184
- mainHandler.post {
185
- completionCallback?.invoke(null, e, 0, 0)
186
- }
187
- }
118
+ // Apply cache policy
119
+ when (cachePolicy) {
120
+ GraniteImageCachePolicy.NONE -> {
121
+ requestBuilder.cacheControl(CacheControl.FORCE_NETWORK)
188
122
  }
189
- })
123
+ GraniteImageCachePolicy.MEMORY, GraniteImageCachePolicy.DISK -> {
124
+ // Use default caching
125
+ }
126
+ }
127
+
128
+ return requestBuilder.build()
129
+ }
130
+
131
+ private fun handleResponse(
132
+ response: Response,
133
+ imageView: ImageView?,
134
+ url: String,
135
+ progressCallback: GraniteImageProgressCallback?,
136
+ completionCallback: GraniteImageCompletionCallback?
137
+ ) {
138
+ if (!response.isSuccessful) {
139
+ postError(Exception("HTTP error: ${response.code}"), completionCallback)
140
+ return
141
+ }
142
+
143
+ val body = response.body
144
+ if (body == null) {
145
+ postError(Exception("No data received"), completionCallback)
146
+ return
147
+ }
148
+
149
+ try {
150
+ val imageBytes = readResponseBytes(body, progressCallback)
151
+ val bitmap = decodeBitmap(imageBytes)
152
+ if (bitmap == null) {
153
+ postError(Exception("Failed to decode image data"), completionCallback)
154
+ return
155
+ }
156
+ mainHandler.post {
157
+ imageView?.setImageBitmap(bitmap)
158
+ Log.d(TAG, "Loaded with OkHttp: $url")
159
+ completionCallback?.invoke(bitmap, null, bitmap.width, bitmap.height)
160
+ }
161
+ } catch (e: Exception) {
162
+ Log.e(TAG, "Error reading image data: ${e.message}")
163
+ mainHandler.post { completionCallback?.invoke(null, e, 0, 0) }
164
+ }
165
+ }
166
+
167
+ private fun readResponseBytes(
168
+ body: okhttp3.ResponseBody,
169
+ progressCallback: GraniteImageProgressCallback?
170
+ ): ByteArray {
171
+ val contentLength = body.contentLength()
172
+ val inputStream = body.byteStream()
173
+ val outputStream = ByteArrayOutputStream()
174
+ val buffer = ByteArray(8192)
175
+ var totalBytesRead = 0L
176
+ var bytesRead: Int
177
+ while (inputStream.read(buffer).also { bytesRead = it } != -1) {
178
+ outputStream.write(buffer, 0, bytesRead)
179
+ totalBytesRead += bytesRead
180
+ if (contentLength > 0) progressCallback?.invoke(totalBytesRead, contentLength)
181
+ }
182
+ return outputStream.toByteArray()
183
+ }
184
+
185
+ private fun decodeBitmap(imageBytes: ByteArray): Bitmap? {
186
+ return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
187
+ }
188
+
189
+ private fun postError(error: Exception, completionCallback: GraniteImageCompletionCallback?) {
190
+ Log.e(TAG, error.message ?: "Unknown error")
191
+ mainHandler.post { completionCallback?.invoke(null, error, 0, 0) }
190
192
  }
191
193
 
192
194
  override fun cancelLoad(view: View) {
@@ -225,4 +227,23 @@ class OkHttpImageProvider : GraniteImageProvider {
225
227
  }
226
228
  )
227
229
  }
230
+
231
+ override fun clearMemoryCache(context: Context) {
232
+ // OkHttp: no built-in memory cache — no-op
233
+ Log.d(TAG, "Memory cache clear not supported (OkHttp)")
234
+ }
235
+
236
+ override fun clearDiskCache(context: Context) {
237
+ // OkHttp: evict all if client.cache is configured
238
+ try {
239
+ client.cache?.evictAll()
240
+ Log.d(TAG, "Disk cache cleared (OkHttp)")
241
+ } catch (e: Exception) {
242
+ Log.e(TAG, "Failed to clear disk cache: ${e.message}")
243
+ }
244
+ }
245
+
246
+ companion object {
247
+ private const val TAG = "OkHttpImageProvider"
248
+ }
228
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@granite-js/image",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "A pluggable React Native image component that lets you use your existing native image loading infrastructure",
5
5
  "type": "module",
6
6
  "main": "./dist/module/index.js",