diff --git a/dependencyVersion.gradle b/dependencyVersion.gradle index 7a9dba8..b20e2a2 100644 --- a/dependencyVersion.gradle +++ b/dependencyVersion.gradle @@ -3,6 +3,8 @@ ext { ver = [ kotlin : "1.7.20", + coroutines : "1.6.4", + // build tools buildTools : [ android_gradle : "7.3.1", @@ -18,6 +20,7 @@ ext { browser : "1.2.0", constraintlayout: "1.1.3", core : "1.2.0", + collection : "1.1.0", exifinterface : "1.2.0", legacy : "1.0.0", lifecycle : "2.4.0", @@ -38,14 +41,11 @@ ext { butterknife : "10.2.1", - picasso : "2.8", - reactivex : [ rxjava : "2.2.19", rxandroid: "2.1.1" ], - spongycastle: "1.58.0.0", jjwt : "0.11.1", diff --git a/line-sdk/build.gradle b/line-sdk/build.gradle index 5a83d9d..bdc1c5e 100644 --- a/line-sdk/build.gradle +++ b/line-sdk/build.gradle @@ -62,11 +62,16 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${ver.kotlin}" + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${ver.coroutines}" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:${ver.coroutines}" + implementation "androidx.annotation:annotation:${ver.androidx.annotation}" implementation "androidx.appcompat:appcompat:${ver.androidx.appcompat}" implementation "androidx.browser:browser:${ver.androidx.browser}" implementation "androidx.constraintlayout:constraintlayout:${ver.androidx.constraintlayout}" implementation "androidx.core:core-ktx:${ver.androidx.core}" + implementation "androidx.collection:collection-ktx:${ver.androidx.collection}" implementation "androidx.exifinterface:exifinterface:${ver.androidx.exifinterface}" implementation "androidx.legacy:legacy-support-v4:${ver.androidx.legacy}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${ver.androidx.lifecycle}" @@ -83,7 +88,7 @@ dependencies { implementation("com.madgag.spongycastle:prov:${ver.spongycastle}") { exclude group: 'junit', module: 'junit' } - implementation "com.squareup.picasso:picasso:${ver.picasso}" + implementation fileTree(include: ['*.jar'], dir: 'libs') // for tests @@ -309,6 +314,3 @@ nexusStaging { username = name password = pw } - - - diff --git a/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/TargetListAdapter.java b/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/TargetListAdapter.java index 95f7ed1..9be3297 100644 --- a/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/TargetListAdapter.java +++ b/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/TargetListAdapter.java @@ -11,7 +11,7 @@ import androidx.recyclerview.widget.RecyclerView; import com.linecorp.linesdk.R; -import com.squareup.picasso.Picasso; +import com.linecorp.linesdk.image.LineSdkImageConfig; import java.util.ArrayList; import java.util.List; @@ -54,9 +54,13 @@ public void bind(TargetUser targetUser, OnSelectedChangeListener listener) { int placeholderResId = (targetUser.getType() == TargetUser.Type.FRIEND)? R.drawable.friend_thumbnail : R.drawable.group_thumbnail; - Picasso.get().load(targetUser.getPictureUri()) - .placeholder(placeholderResId) - .into(imageView); + String imageUrl = targetUser.getPictureUri() != null ? + targetUser.getPictureUri().toString() : null; + LineSdkImageConfig.getImageLoader().loadImage( + imageUrl, + imageView, + placeholderResId + ); } private SpannableString createHighlightTextSpan(String text, String toBeHighLighted) { diff --git a/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/UserThumbnailView.java b/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/UserThumbnailView.java index 5803fe0..6339433 100644 --- a/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/UserThumbnailView.java +++ b/line-sdk/src/main/java/com/linecorp/linesdk/dialog/internal/UserThumbnailView.java @@ -6,7 +6,7 @@ import android.widget.TextView; import com.linecorp.linesdk.R; -import com.squareup.picasso.Picasso; +import com.linecorp.linesdk.image.LineSdkImageConfig; public class UserThumbnailView extends ConstraintLayout { @@ -24,7 +24,13 @@ public void setTargetUser(TargetUser targetUser) { int thumbnailResId = (targetUser.getType() == TargetUser.Type.FRIEND) ? R.drawable.friend_thumbnail : R.drawable.group_thumbnail; - Picasso.get().load(targetUser.getPictureUri()).placeholder(thumbnailResId).into(imageView); + String imageUrl = targetUser.getPictureUri() != null ? + targetUser.getPictureUri().toString() : null; + LineSdkImageConfig.getImageLoader().loadImage( + imageUrl, + imageView, + thumbnailResId + ); } private void init() { diff --git a/line-sdk/src/main/java/com/linecorp/linesdk/image/DefaultImageLoader.kt b/line-sdk/src/main/java/com/linecorp/linesdk/image/DefaultImageLoader.kt new file mode 100644 index 0000000..40d8eec --- /dev/null +++ b/line-sdk/src/main/java/com/linecorp/linesdk/image/DefaultImageLoader.kt @@ -0,0 +1,182 @@ +package com.linecorp.linesdk.image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.util.Log +import android.widget.ImageView +import androidx.collection.LruCache +import androidx.exifinterface.media.ExifInterface +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.lang.ref.WeakReference +import java.net.HttpURLConnection +import java.net.URL +import java.util.Collections +import java.util.WeakHashMap + +internal object DefaultImageLoader : ImageLoader { + private const val TAG = "DefaultImageLoader" + private const val BUFFER_SIZE = 8192 + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val tasks = Collections.synchronizedMap(WeakHashMap()) + + private val memoryCache: LruCache + + init { + val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() + // Use 1/8th of the available memory for this memory cache. + val cacheSize = maxMemory / 8 + memoryCache = object : LruCache(cacheSize) { + override fun sizeOf(key: String, bitmap: Bitmap): Int { + // The cache size will be measured in kilobytes rather than number of items. + return bitmap.byteCount / 1024 + } + } + } + + override fun loadImage(url: String?, imageView: ImageView, placeholderResId: Int) { + imageView.setImageResource(placeholderResId) + + tasks[imageView]?.cancel() + + if (url.isNullOrBlank()) { + return + } + + val cachedBitmap = memoryCache.get(url) + if (cachedBitmap != null) { + imageView.setImageBitmap(cachedBitmap) + return + } + + // Get target dimensions for sampling. Use screen dimensions as fallback if ImageView not laid out yet. + val targetWidth = if (imageView.width > 0) imageView.width else imageView.context.resources.displayMetrics.widthPixels + val targetHeight = if (imageView.height > 0) imageView.height else imageView.context.resources.displayMetrics.heightPixels + + val imageViewRef = WeakReference(imageView) + + val job = coroutineScope.launch { + try { + val bitmap = downloadAndProcessImage(url, targetWidth, targetHeight) + + imageViewRef.get()?.let { targetImageView -> + if (bitmap != null) { + memoryCache.put(url, bitmap) + targetImageView.setImageBitmap(bitmap) + } + } + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Error loading image from URL: $url", e) + } + } finally { + imageViewRef.get()?.let { tasks.remove(it) } + } + } + tasks[imageView] = job + } + + private suspend fun downloadAndProcessImage(url: String, targetWidth: Int, targetHeight: Int): Bitmap? = withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + val imageUrl = URL(url) + connection = imageUrl.openConnection() as HttpURLConnection + connection.doInput = true + connection.connect() + + // We need to read the input stream into memory, not just the bitmap directly, + // to handle EXIF data and image rotation correctly. + val byteArrayOutputStream = ByteArrayOutputStream() + connection.inputStream.use { input -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + byteArrayOutputStream.write(buffer, 0, len) + } + } + + val imageData = byteArrayOutputStream.toByteArray() + + // First pass: Decode image bounds to get original dimensions + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeByteArray(imageData, 0, imageData.size, options) + + // Calculate inSampleSize + options.inSampleSize = calculateInSampleSize(options, targetWidth, targetHeight) + + // Second pass: Decode actual bitmap with inSampleSize set + options.inJustDecodeBounds = false + var bitmap: Bitmap? = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, options) + + // Read EXIF orientation + val orientation = ByteArrayInputStream(imageData).use { exifInput -> + ExifInterface(exifInput).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } + + if (bitmap != null) { + bitmap = rotateBitmap(bitmap, orientation) + } + bitmap + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Error loading image from URL: $url", e) + } + null + } finally { + connection?.disconnect() + } + } + + private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + private fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap { + if (orientation == ExifInterface.ORIENTATION_NORMAL) { + return bitmap + } + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90F) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180F) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270F) + else -> { + Log.w(TAG, "Unsupported EXIF orientation: $orientation") + return bitmap + } + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + + + +} diff --git a/line-sdk/src/main/java/com/linecorp/linesdk/image/ImageLoader.java b/line-sdk/src/main/java/com/linecorp/linesdk/image/ImageLoader.java new file mode 100644 index 0000000..5e5789e --- /dev/null +++ b/line-sdk/src/main/java/com/linecorp/linesdk/image/ImageLoader.java @@ -0,0 +1,23 @@ +package com.linecorp.linesdk.image; + +import android.widget.ImageView; + +/** + * Interface for loading images into ImageViews. + * This allows apps to provide their own image loading implementation + * (e.g., using Glide, Picasso, Coil) or use the default implementation + * provided by the LINE SDK. + * + * @see com.linecorp.linesdk.image.LineSdkImageConfig#setImageLoader(ImageLoader) + */ +public interface ImageLoader { + + /** + * Load an image from URL into ImageView with placeholder. + * + * @param url Image URL to load (may be null or empty) + * @param imageView Target ImageView to load the image into + * @param placeholderResId Resource ID for placeholder image while loading + */ + void loadImage(String url, ImageView imageView, int placeholderResId); +} diff --git a/line-sdk/src/main/java/com/linecorp/linesdk/image/LineSdkImageConfig.java b/line-sdk/src/main/java/com/linecorp/linesdk/image/LineSdkImageConfig.java new file mode 100644 index 0000000..4ca3ccb --- /dev/null +++ b/line-sdk/src/main/java/com/linecorp/linesdk/image/LineSdkImageConfig.java @@ -0,0 +1,42 @@ +package com.linecorp.linesdk.image; + +/** + * Configuration class for image loading in LINE SDK. + * This class allows apps to provide their own image loading implementation + * or use the default implementation provided by the LINE SDK. + */ +public final class LineSdkImageConfig { + private LineSdkImageConfig() { + // This class cannot be instantiated. + } + + private static volatile ImageLoader imageLoader = DefaultImageLoader.INSTANCE; + + /** + * Set a custom image loader implementation. + * Call this method in your Application.onCreate() before using LINE SDK + * features that display user images (e.g., friend picker dialog). + * + * @param loader Custom ImageLoader implementation, or null to use default + */ + public static void setImageLoader(ImageLoader loader) { + imageLoader = loader != null ? loader : DefaultImageLoader.INSTANCE; + } + + /** + * Get the currently configured image loader. + * @return Current ImageLoader instance (never null) + */ + public static ImageLoader getImageLoader() { + return imageLoader; + } + + /** + * Reset to default image loader. + * This is mainly useful for testing or if you want to switch back + * to the default implementation. + */ + public static void resetToDefault() { + imageLoader = DefaultImageLoader.INSTANCE; + } +}