Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions dependencyVersion.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ ext {
ver = [
kotlin : "1.7.20",

coroutines : "1.6.4",

// build tools
buildTools : [
android_gradle : "7.3.1",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions line-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -309,6 +314,3 @@ nexusStaging {
username = name
password = pw
}



Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ImageView, Job>())

private val memoryCache: LruCache<String, Bitmap>

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<String, Bitmap>(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)
}



}
23 changes: 23 additions & 0 deletions line-sdk/src/main/java/com/linecorp/linesdk/image/ImageLoader.java
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
}