From 9295c4fc9a79d1d35a1a91fa464b0f94726da181 Mon Sep 17 00:00:00 2001 From: Syamsul Bahri Date: Mon, 22 Dec 2025 10:15:35 +0700 Subject: [PATCH] feat: migrate image loading from Fresco to Glide for improved performance - Replace Fresco with Glide for image loading and caching - Optimize image handling to achieve a target size of 16KB - Fix type inference error in GenericResourceFetcher.kt - Implement URL sanitization to handle trailing brackets in image URLs - Update KSP dependencies and configurations for Glide --- .../helloworld/android/app/build.gradle.kts | 30 +- .../main/java/com/helloworld/MainActivity.kt | 21 +- .../java/com/helloworld/MainApplication.kt | 13 +- .../providers/GenericResourceFetcher.kt | 14 +- .../helloworld/services/GlideImageService.kt | 391 ++++++++++++++++++ .../java/com/helloworld/services/ImageUI.kt | 53 +++ .../com/helloworld/services/svg/SvgDecoder.kt | 33 ++ .../services/svg/SvgDrawableTranscoder.kt | 33 ++ .../com/helloworld/services/svg/SvgModule.kt | 31 ++ .../android/gradle/libs.versions.toml | 2 + 10 files changed, 580 insertions(+), 41 deletions(-) create mode 100644 packages/helloworld/android/app/src/main/java/com/helloworld/services/GlideImageService.kt create mode 100644 packages/helloworld/android/app/src/main/java/com/helloworld/services/ImageUI.kt create mode 100644 packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDecoder.kt create mode 100644 packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDrawableTranscoder.kt create mode 100644 packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgModule.kt diff --git a/packages/helloworld/android/app/build.gradle.kts b/packages/helloworld/android/app/build.gradle.kts index 2061208..ad0df04 100644 --- a/packages/helloworld/android/app/build.gradle.kts +++ b/packages/helloworld/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.ksp) } android { @@ -85,34 +86,29 @@ dependencies { implementation("com.squareup.retrofit2:retrofit:2.7.0") // lynx dependencies - implementation("org.lynxsdk.lynx:lynx:3.4.1") - implementation("org.lynxsdk.lynx:lynx-jssdk:3.4.1") - implementation("org.lynxsdk.lynx:lynx-trace:3.4.1") + implementation("org.lynxsdk.lynx:lynx:3.5.1") + implementation("org.lynxsdk.lynx:lynx-jssdk:3.5.1") + implementation("org.lynxsdk.lynx:lynx-trace:3.5.1") implementation("org.lynxsdk.lynx:primjs:2.14.1") // integrating image-service - implementation("org.lynxsdk.lynx:lynx-service-image:3.4.1") - - // image-service dependencies, if not added, images cannot be loaded; if the host APP needs to use other image libraries, you can customize the image-service and remove this dependency - implementation("com.facebook.fresco:fresco:2.3.0") - implementation("com.facebook.fresco:animated-gif:2.3.0") - implementation("com.facebook.fresco:animated-webp:2.3.0") - implementation("com.facebook.fresco:webpsupport:2.3.0") - implementation("com.facebook.fresco:animated-base:2.3.0") + implementation("com.github.bumptech.glide:glide:5.0.5") + ksp("com.github.bumptech.glide:ksp:5.0.5") + implementation("com.caverock:androidsvg-aar:1.4") // integrating log-service - implementation("org.lynxsdk.lynx:lynx-service-log:3.4.1") + implementation("org.lynxsdk.lynx:lynx-service-log:3.5.1") // integrating http-service - implementation("org.lynxsdk.lynx:lynx-service-http:3.4.1") + implementation("org.lynxsdk.lynx:lynx-service-http:3.5.1") implementation("com.squareup.okhttp3:okhttp:4.9.0") // add devtool's dependencies - implementation ("org.lynxsdk.lynx:lynx-devtool:3.4.1") - implementation ("org.lynxsdk.lynx:lynx-service-devtool:3.4.1") + implementation ("org.lynxsdk.lynx:lynx-devtool:3.5.1") + implementation ("org.lynxsdk.lynx:lynx-service-devtool:3.5.1") // add xelement's dependencies - implementation ("org.lynxsdk.lynx:xelement:3.4.1") - implementation ("org.lynxsdk.lynx:xelement-input:3.4.1") + implementation ("org.lynxsdk.lynx:xelement:3.5.1") + implementation ("org.lynxsdk.lynx:xelement-input:3.5.1") } \ No newline at end of file diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/MainActivity.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/MainActivity.kt index 2a9cbec..f945737 100644 --- a/packages/helloworld/android/app/src/main/java/com/helloworld/MainActivity.kt +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/MainActivity.kt @@ -4,21 +4,24 @@ import android.app.Activity import android.os.Bundle import com.helloworld.providers.GenericResourceFetcher import com.helloworld.providers.TemplateProvider +import com.helloworld.services.ImageUI import com.lynx.tasm.LynxBooleanOption import com.lynx.tasm.LynxView import com.lynx.tasm.LynxViewBuilder +import com.lynx.tasm.behavior.Behavior +import com.lynx.tasm.behavior.ui.LynxUI import com.lynx.xelement.XElementBehaviors class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - var uri = "" - uri = if (BuildConfig.DEBUG == true) { - "http://10.0.2.2:3000/main.lynx.bundle?fullscreen=true" - } else { - "main.lynx.bundle" - } + var uri = "http://10.63.106.9:3001/main.lynx.bundle?fullscreen=true" +// uri = if (BuildConfig.DEBUG == true) { +// "http://10.63.106.9:3000/main.lynx.bundle?fullscreen=true" +// } else { +// "main.lynx.bundle" +// } val lynxView: LynxView = buildLynxView() setContentView(lynxView) @@ -30,6 +33,12 @@ class MainActivity : Activity() { val viewBuilder: LynxViewBuilder = LynxViewBuilder() viewBuilder.addBehaviors(XElementBehaviors().create()) + viewBuilder.addBehavior(object : Behavior("image") { + override fun createUI(context: com.lynx.tasm.behavior.LynxContext): LynxUI<*> { + return ImageUI(context) + } + }) + viewBuilder.setTemplateProvider(TemplateProvider(this)) viewBuilder.isEnableGenericResourceFetcher = LynxBooleanOption.TRUE viewBuilder.setGenericResourceFetcher(GenericResourceFetcher()) diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/MainApplication.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/MainApplication.kt index a228483..6a59b7e 100644 --- a/packages/helloworld/android/app/src/main/java/com/helloworld/MainApplication.kt +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/MainApplication.kt @@ -4,12 +4,8 @@ import android.app.Application import android.content.Intent import android.os.Handler import android.os.Looper -import com.facebook.drawee.backends.pipeline.Fresco -import com.facebook.imagepipeline.core.ImagePipelineConfig -import com.facebook.imagepipeline.memory.PoolConfig -import com.facebook.imagepipeline.memory.PoolFactory +import com.helloworld.services.GlideImageService import com.lynx.devtoolwrapper.LynxDevtoolGlobalHelper -import com.lynx.service.image.LynxImageService import com.lynx.service.log.LynxLogService import com.lynx.tasm.LynxEnv import com.lynx.tasm.service.LynxServiceCenter @@ -23,12 +19,7 @@ class MainApplication : Application() { } private fun initLynxService() { - val factory = PoolFactory(PoolConfig.newBuilder().build()) - val builder = - ImagePipelineConfig.newBuilder(applicationContext).setPoolFactory(factory) - Fresco.initialize(applicationContext, builder.build()) - - LynxServiceCenter.inst().registerService(LynxImageService.getInstance()) + LynxServiceCenter.inst().registerService(GlideImageService.getInstance()) LynxServiceCenter.inst().registerService(LynxLogService) LynxServiceCenter.inst().registerService(LynxHttpService) } diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/providers/GenericResourceFetcher.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/providers/GenericResourceFetcher.kt index 5d842d2..8502e3d 100644 --- a/packages/helloworld/android/app/src/main/java/com/helloworld/providers/GenericResourceFetcher.kt +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/providers/GenericResourceFetcher.kt @@ -20,9 +20,9 @@ class GenericResourceFetcher : LynxGenericResourceFetcher() { ) { if (request == null) { callback.onResponse( - LynxResourceResponse.onFailed( + LynxResourceResponse.onFailed( Throwable("request is null!") - ) as LynxResourceResponse? + ) ) return } @@ -32,7 +32,7 @@ class GenericResourceFetcher : LynxGenericResourceFetcher() { val call: Call = templateApi.getTemplate(request.url) ?: run { callback.onResponse( - LynxResourceResponse.onFailed(Throwable("create call failed.")) as LynxResourceResponse? + LynxResourceResponse.onFailed(Throwable("create call failed.")) ) return @@ -49,17 +49,17 @@ class GenericResourceFetcher : LynxGenericResourceFetcher() { ) } else { callback.onResponse( - LynxResourceResponse.onFailed(Throwable("response body is null.")) as LynxResourceResponse? + LynxResourceResponse.onFailed(Throwable("response body is null.")) ) } } catch (e: IOException) { e.printStackTrace() - callback.onResponse(LynxResourceResponse.onFailed(e) as LynxResourceResponse?) + callback.onResponse(LynxResourceResponse.onFailed(e)) } } override fun onFailure(call: Call, throwable: Throwable) { - callback.onResponse(LynxResourceResponse.onFailed(throwable) as LynxResourceResponse?) + callback.onResponse(LynxResourceResponse.onFailed(throwable)) } }) } @@ -68,7 +68,7 @@ class GenericResourceFetcher : LynxGenericResourceFetcher() { request: LynxResourceRequest, callback: LynxResourceCallback ) { callback.onResponse( - LynxResourceResponse.onFailed(Throwable("fetchResourcePath not supported.")) as LynxResourceResponse? + LynxResourceResponse.onFailed(Throwable("fetchResourcePath not supported.")) ) } diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/services/GlideImageService.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/services/GlideImageService.kt new file mode 100644 index 0000000..cc7b411 --- /dev/null +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/services/GlideImageService.kt @@ -0,0 +1,391 @@ +package com.helloworld.services + +import android.content.Context +import android.net.Uri +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.text.TextUtils +import android.util.Log +import android.view.View +import android.util.Base64 +import androidx.annotation.Keep +import androidx.annotation.NonNull +import androidx.annotation.Nullable +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.resource.gif.GifDrawable +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.lynx.tasm.behavior.ui.background.BackgroundLayerDrawable +import com.lynx.tasm.image.ImageContent +import com.lynx.tasm.image.ImageErrorCodeUtils +import com.lynx.tasm.image.model.AnimationListener +import com.lynx.tasm.image.model.ImageInfo +import com.lynx.tasm.image.model.ImageLoadListener +import com.lynx.tasm.image.model.ImageRequestInfo +import com.lynx.tasm.service.ILynxImageService + +@Keep +class GlideImageService : ILynxImageService { + + companion object { + private var instance: GlideImageService? = null + + fun getInstance(): GlideImageService { + if (instance == null) { + instance = GlideImageService() + } + return instance!! + } + } + + // Keep strong references to targets to prevent garbage collection + private val activeTargets = mutableSetOf>() + private val targetsByRequest = mutableMapOf>() + + private fun resolveGlideModel(url: String, context: Context): Any { + return when { + url.startsWith("asset://") -> { + val assetPath = url.removePrefix("asset://") + Uri.parse("file:///android_asset/$assetPath") + } + url.startsWith("data:") -> { + try { + val commaIndex = url.indexOf(',') + if (commaIndex > 0) { + val meta = url.substring(0, commaIndex) + val dataPart = url.substring(commaIndex + 1) + val isBase64 = meta.contains(";base64") + if (isBase64) { + Base64.decode(dataPart, Base64.DEFAULT) + } else { + Uri.decode(dataPart).toByteArray() + } + } else { + url + } + } catch (e: Exception) { + url + } + } + else -> { + url + } + } + } + + override fun fetchImage( + @NonNull imageRequestInfo: ImageRequestInfo, + @NonNull loadListener: ImageLoadListener, + @Nullable animationListener: AnimationListener?, + @NonNull context: Context + ) { + val originalUrl = imageRequestInfo.url + val url = originalUrl?.trim()?.removeSuffix("]") + Log.d("GlideImageService", "fetchImage called for $url (original: $originalUrl)") + if (url.isNullOrEmpty()) { + Log.e("GlideImageService", "URL is null or empty") + loadListener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + IllegalArgumentException("URL is null or empty") + ) + return + } + try { + val target = object : CustomTarget(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) { + override fun onResourceReady( + @NonNull resource: Drawable, + @Nullable transition: Transition? + ) { + Log.d("GlideImageService", "Image loaded successfully for $url") + val isAnimated = resource is GifDrawable + // Create a safe drawable copy to prevent recycling + val safeDrawable = when (resource) { + is android.graphics.drawable.BitmapDrawable -> { + val bitmap = resource.bitmap + if (bitmap != null) { + val copiedBitmap = bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, true) + android.graphics.drawable.BitmapDrawable(context.resources, copiedBitmap) + } else { + resource + } + } + else -> resource + } + loadListener.onSuccess( + ImageContent(safeDrawable), + imageRequestInfo, + ImageInfo(safeDrawable.intrinsicWidth, safeDrawable.intrinsicHeight, isAnimated) + ) + // Remove from active targets after success + activeTargets.remove(this) + } + + override fun onLoadCleared(@Nullable placeholder: Drawable?) { + // Remove from active targets when cleared + activeTargets.remove(this) + } + + override fun onLoadFailed(@Nullable errorDrawable: Drawable?) { + Log.e("GlideImageService", "Image load failed for $url") + loadListener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + GlideException("Load failed") + ) + // Remove from active targets after failure + activeTargets.remove(this) + } + } + + // Keep strong reference to prevent GC + activeTargets.add(target) + targetsByRequest[imageRequestInfo] = target + + val model = resolveGlideModel(url ?: "", context) + Glide.with(context) + .load(model) + .into(target) + } catch (e: Exception) { + loadListener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + e + ) + } + } + + override fun startAnimation(@NonNull animatable: Drawable): Boolean { + return if (animatable is GifDrawable) { + animatable.start() + true + } else { + false + } + } + + override fun resumeAnimation(@NonNull animatable: Drawable): Boolean { + return if (animatable is GifDrawable) { + animatable.start() + true + } else { + false + } + } + + override fun pauseAnimation(@NonNull animatable: Drawable): Boolean { + return if (animatable is GifDrawable) { + animatable.stop() + true + } else { + false + } + } + + override fun stopAnimation(@NonNull animatable: Drawable): Boolean { + return if (animatable is GifDrawable) { + animatable.stop() + true + } else { + false + } + } + + override fun prefetchImage( + @NonNull uri: String, + @Nullable callerContext: Any?, + @Nullable params: Map? + ) { + prefetchImage(uri, callerContext, params, null) + } + + override fun prefetchImage( + @NonNull uri: String, + @Nullable callerContext: Any?, + @Nullable params: Map?, + @Nullable loadListener: ImageLoadListener? + ) { + val trimmedUri = uri.trim().removeSuffix("]") + try { + val context = when (callerContext) { + is Context -> callerContext + else -> return + } + + Glide.with(context) + .downloadOnly() + .load(trimmedUri) + .listener(object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + loadListener?.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + e ?: Exception("Prefetch failed") + ) + return false + } + + override fun onResourceReady( + resource: java.io.File, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + // Prefetch success - no callback needed + return false + } + }) + .preload() + } catch (e: Exception) { + loadListener?.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + e + ) + } + } + + override fun decodeImage( + @NonNull imageRequestInfo: ImageRequestInfo, + @NonNull listener: ImageLoadListener + ) { + try { + val context = when (imageRequestInfo.callerContext) { + is Context -> imageRequestInfo.callerContext as Context + else -> { + listener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + IllegalArgumentException("Context required") + ) + return + } + } + + val target = object : CustomTarget(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) { + override fun onResourceReady( + @NonNull resource: Bitmap, + @Nullable transition: Transition? + ) { + val copiedBitmap = resource.copy(resource.config ?: Bitmap.Config.ARGB_8888, true) + val drawable = android.graphics.drawable.BitmapDrawable(context.resources, copiedBitmap) + listener.onSuccess( + ImageContent(drawable), + imageRequestInfo, + ImageInfo(copiedBitmap.width, copiedBitmap.height, false) + ) + // Remove from active targets after success + activeTargets.remove(this) + targetsByRequest.remove(imageRequestInfo) + } + + override fun onLoadCleared(@Nullable placeholder: Drawable?) { + // Remove from active targets when cleared + activeTargets.remove(this) + targetsByRequest.remove(imageRequestInfo) + } + + override fun onLoadFailed(@Nullable errorDrawable: Drawable?) { + listener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + GlideException("Decode failed") + ) + // Remove from active targets after failure + activeTargets.remove(this) + targetsByRequest.remove(imageRequestInfo) + } + } + + // Keep strong reference to prevent GC + activeTargets.add(target) + targetsByRequest[imageRequestInfo] = target + + val model = resolveGlideModel(imageRequestInfo.url, context) + Glide.with(context) + .asBitmap() + .load(model) + .into(target) + } catch (e: Exception) { + listener.onFailure( + ImageErrorCodeUtils.LYNX_IMAGE_UNKNOWN_EXCEPTION, + e + ) + } + } + + override fun releaseImage(@NonNull imageRequestInfo: ImageRequestInfo) { + // Glide handles memory automatically + // Clear any active targets if needed + val target = targetsByRequest.remove(imageRequestInfo) + if (target != null) { + val context = (imageRequestInfo.callerContext as? Context) ?: return + Glide.with(context).clear(target) + activeTargets.remove(target) + } + } + + override fun releaseAnimDrawable(@NonNull drawable: Drawable) { + if (drawable is GifDrawable) { + drawable.stop() + } + } + + override fun canParseUrl(@NonNull url: String): Boolean { + return !TextUtils.isEmpty(url) && + (url.startsWith("http") || url.startsWith("https") || + url.startsWith("file://") || url.startsWith("content://") || + url.startsWith("asset://") || url.startsWith("data:") || + url.startsWith("android.resource://")) + } + + @Nullable + fun createBackgroundImageDrawable( + ): BackgroundLayerDrawable? { + // Not implemented for Glide - return null + return null + } + + + + // Deprecated methods - use Object instead of Any to match Java interface + @Deprecated("Deprecated method") + override fun setCustomImageDecoder(@NonNull builder: Any) { + // Deprecated - no implementation needed + } + + @Deprecated("Deprecated method") + @Nullable + override fun getImageSRPostProcessor(): Any? { + return null // Deprecated - return null + } + + @Deprecated("Deprecated method") + override fun setImageSRSize(@NonNull request: Any, @NonNull view: View) { + // Deprecated - no implementation needed + } + + @Deprecated("Deprecated method") + override fun setImageCacheChoice(@NonNull cacheChoice: String, @NonNull builder: Any) { + // Deprecated - no implementation needed + } + + @Deprecated("Deprecated method") + override fun setImagePlaceHolderHash( + @NonNull hierarchy: Any, + @NonNull request: Any, + @NonNull scaleType: Any, + @NonNull hash: String, + @Nullable metaData: String?, + width: Int, + height: Int, + radius: Int, + iterations: Int, + isPreView: Boolean + ) { + // Deprecated - no implementation needed + } +} diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/services/ImageUI.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/services/ImageUI.kt new file mode 100644 index 0000000..82e32f7 --- /dev/null +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/services/ImageUI.kt @@ -0,0 +1,53 @@ +package com.helloworld.services + +import android.content.Context +import android.net.Uri +import android.util.Base64 +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.lynx.tasm.behavior.LynxContext +import com.lynx.tasm.behavior.LynxProp +import com.lynx.tasm.behavior.ui.LynxUI + +class ImageUI(context: LynxContext) : LynxUI(context) { + + override fun createView(context: Context): ImageView { + return ImageView(context) + } + + @LynxProp(name = "src") + fun setSrc(url: String?) { + if (url.isNullOrEmpty()) return + val trimmedUrl = url.trim().removeSuffix("]") + val model = resolveModel(trimmedUrl) + Glide.with(mView.context).load(model).into(mView) + } + + private fun resolveModel(url: String): Any { + return when { + url.startsWith("asset://") -> { + val assetPath = url.removePrefix("asset://") + Uri.parse("file:///android_asset/$assetPath") + } + url.startsWith("android.resource://") -> { + Uri.parse(url) + } + url.startsWith("data:") -> { + val commaIndex = url.indexOf(',') + if (commaIndex > 0) { + val meta = url.substring(0, commaIndex) + val dataPart = url.substring(commaIndex + 1) + val isBase64 = meta.contains(";base64") + if (isBase64) { + Base64.decode(dataPart, Base64.DEFAULT) + } else { + Uri.decode(dataPart).toByteArray() + } + } else { + url + } + } + else -> url + } + } +} diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDecoder.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDecoder.kt new file mode 100644 index 0000000..63bed86 --- /dev/null +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDecoder.kt @@ -0,0 +1,33 @@ +package com.helloworld.services.svg + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.SimpleResource +import com.caverock.androidsvg.SVG +import com.caverock.androidsvg.SVGParseException +import java.io.IOException +import java.io.InputStream + +/** + * Decodes an [SVG] from an [InputStream]. + */ +class SvgDecoder : ResourceDecoder { + override fun handles(source: InputStream, options: Options): Boolean { + return true + } + + override fun decode( + source: InputStream, + width: Int, + height: Int, + options: Options + ): Resource? { + try { + val svg = SVG.getFromInputStream(source) + return SimpleResource(svg) + } catch (e: SVGParseException) { + throw IOException("Cannot load SVG from stream", e) + } + } +} diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDrawableTranscoder.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDrawableTranscoder.kt new file mode 100644 index 0000000..1dd7a4f --- /dev/null +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgDrawableTranscoder.kt @@ -0,0 +1,33 @@ +package com.helloworld.services.svg + +import android.graphics.Picture +import android.graphics.drawable.PictureDrawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.SimpleResource +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder +import com.caverock.androidsvg.SVG + +/** + * Convert the [SVG]'s internal representation to an Android-compatible one ([Picture]). + */ +class SvgDrawableTranscoder : ResourceTranscoder { + override fun transcode( + toTranscode: Resource, + options: Options + ): Resource? { + val svg = toTranscode.get() + + // Handle SVGs that don't have an explicit width or height by using the viewBox + if (svg.documentWidth < 0 && svg.documentViewBox != null) { + svg.documentWidth = svg.documentViewBox.width() + } + if (svg.documentHeight < 0 && svg.documentViewBox != null) { + svg.documentHeight = svg.documentViewBox.height() + } + + val picture = svg.renderToPicture() + val drawable = PictureDrawable(picture) + return SimpleResource(drawable) + } +} diff --git a/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgModule.kt b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgModule.kt new file mode 100644 index 0000000..3d2aa67 --- /dev/null +++ b/packages/helloworld/android/app/src/main/java/com/helloworld/services/svg/SvgModule.kt @@ -0,0 +1,31 @@ +package com.helloworld.services.svg + +import android.content.Context +import android.graphics.drawable.PictureDrawable +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.module.AppGlideModule +import com.caverock.androidsvg.SVG +import java.io.InputStream + +/** + * Module for the SVG sample app. + */ +@GlideModule +class SvgModule : AppGlideModule() { + override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry + ) { + registry + .register(SVG::class.java, PictureDrawable::class.java, SvgDrawableTranscoder()) + .append(InputStream::class.java, SVG::class.java, SvgDecoder()) + } + + // Disable manifest parsing to avoid adding similar modules twice. + override fun isManifestParsingEnabled(): Boolean { + return false + } +} diff --git a/packages/helloworld/android/gradle/libs.versions.toml b/packages/helloworld/android/gradle/libs.versions.toml index d5f66b5..3c42e67 100644 --- a/packages/helloworld/android/gradle/libs.versions.toml +++ b/packages/helloworld/android/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.5.0" kotlin = "1.9.0" +ksp = "1.9.0-1.0.13" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" @@ -28,4 +29,5 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }