From 4169dc34c08f5c1c4ff9882730bc366909c4bc29 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Tue, 8 Aug 2023 23:09:59 +0200 Subject: [PATCH] Composite semi-transparent avatars over a solid background (#3874) Avatars that are semi-transparent are a problem when viewing a thread, as the line that connects different statuses in the same thread is drawn underneath the avatar and is visible. Fix this with a CompositeWithOpaqueBackground Glide transformation that: 1. Extracts the alpha channel from the avatar image 2. Converts the alpha to a 1bpp mask 3. Draws that mask on a new bitmap, with the appropriate background colour 4. Draws the original bitmap on top of that So any partially transparent areas of the original image are drawn over a solid background colour, so anything drawn under them will not appear. --- .../tusky/adapter/StatusBaseViewHolder.java | 8 +- .../conversation/ConversationViewHolder.java | 2 +- .../util/CompositeWithOpaqueBackground.kt | 168 ++++++++++++++++++ .../tusky/util/ImageLoadingHelper.kt | 29 ++- 4 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index ca9d5f32f..5435dc8f3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -52,6 +52,7 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; @@ -67,6 +68,7 @@ import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.NumberFormat; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -328,14 +330,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, - statusDisplayOptions.animateAvatars()); + statusDisplayOptions.animateAvatars(), null); avatarRadius = avatarRadius36dp; } ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - + statusDisplayOptions.animateAvatars(), + Collections.singletonList(new CompositeWithOpaqueBackground(avatar))); } protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 722a9f3c5..5dc0bf982 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { ImageView avatarView = avatars[i]; if (i < accounts.size()) { ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, - avatarRadius48dp, statusDisplayOptions.animateAvatars()); + avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt new file mode 100644 index 000000000..f7d12b6fe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.util.Log +import android.util.TypedValue +import android.view.View +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.util.Util +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.security.MessageDigest + +/** + * Set an opaque background behind the non-transparent areas of a bitmap. + * + * Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255). + * + * Displaying those can be a problem if there is anything drawn under them, as it will show + * through the image. + * + * Fix this, by: + * + * - Creating a mask that matches the partially transparent areas of the image + * - Creating a new bitmap that, in the areas that match the mask, contains the same background + * drawable as the [ImageView]. + * - Composite the original image over the top + * + * So the partially transparent areas on the original image are composited over the original + * background, the fully transparent areas on the original image are left transparent. + */ +class CompositeWithOpaqueBackground(val view: View) : BitmapTransformation() { + override fun equals(other: Any?): Boolean { + if (other is CompositeWithOpaqueBackground) { + return other.view == view + } + return false + } + + override fun hashCode() = Util.hashCode(ID.hashCode(), view.hashCode()) + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + messageDigest.update(ByteBuffer.allocate(4).putInt(view.hashCode()).array()) + } + + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int + ): Bitmap { + // If the input bitmap has no alpha channel then there's nothing to do + if (!toTransform.hasAlpha()) return toTransform + + Log.d(TAG, "toTransform: ${toTransform.width} ${toTransform.height}") + // Get the background drawable for this view, falling back to the given attribute + val backgroundDrawable = view.getFirstNonNullBackgroundOrAttr(android.R.attr.colorBackground) + backgroundDrawable ?: return toTransform + + // Convert the background to a bitmap. + val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) + when (backgroundDrawable) { + is ColorDrawable -> backgroundBitmap.eraseColor(backgroundDrawable.color) + else -> { + val backgroundCanvas = Canvas(backgroundBitmap) + backgroundDrawable.setBounds(0, 0, outWidth, outHeight) + backgroundDrawable.draw(backgroundCanvas) + } + } + + // Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp + // TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any + // useful documentation covering paints and mask filters. + val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply { + val canvas = Canvas(this) + canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT) + } + + val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val paintShader = Paint() + paintShader.isAntiAlias = true + paintShader.shader = shader + paintShader.style = Paint.Style.FILL_AND_STROKE + + // Write the background to a new bitmap, masked to just the non-transparent areas of the + // original image + val dest = pool.get(outWidth, outHeight, toTransform.config) + val canvas = Canvas(dest) + canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader) + + // Finally, write the original bitmap over the top + canvas.drawBitmap(toTransform, 0f, 0f, null) + + // Clean up intermediate bitmaps + pool.put(maskBitmap) + pool.put(backgroundBitmap) + + return dest + } + + companion object { + @Suppress("unused") + private const val TAG = "CompositeWithOpaqueBackground" + private val ID = CompositeWithOpaqueBackground::class.qualifiedName!! + private val ID_BYTES = ID.toByteArray(Charset.forName("UTF-8")) + + /** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */ + private val EXTRACT_MASK_PAINT = Paint().apply { + colorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 255f, 0f + ) + ) + ) + isAntiAlias = false + } + + /** + * @param attr attribute reference for the default drawable if no background is set on + * this view or any of its ancestors. + * @return The first non-null background drawable from this view, or its ancestors, + * falling back to the attribute resource given by `attr` if none of the views have a + * background. + */ + fun View.getFirstNonNullBackgroundOrAttr(@AttrRes attr: Int): Drawable? = + background ?: (parent as? View)?.getFirstNonNullBackgroundOrAttr(attr) ?: run { + val v = TypedValue() + context.theme.resolveAttribute(attr, v, true) + // TODO: On API 29 can use v.isColorType here + if (v.type >= TypedValue.TYPE_FIRST_COLOR_INT && v.type <= TypedValue.TYPE_LAST_COLOR_INT) { + ColorDrawable(v.data) + } else { + ContextCompat.getDrawable(context, v.resourceId) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1430801c2..0979f5964 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -3,39 +3,50 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.widget.ImageView import androidx.annotation.Px import com.bumptech.glide.Glide +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() -fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { +fun loadAvatar( + url: String?, + imageView: ImageView, + @Px radius: Int, + animate: Boolean, + transforms: List>? = null +) { if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) .into(imageView) } else { + val multiTransformation = MultiTransformation( + buildList { + transforms?.let { this.addAll(it) } + add(centerCropTransformation) + add(RoundedCorners(radius)) + } + ) + if (animate) { Glide.with(imageView) .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } else { Glide.with(imageView) .asBitmap() .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) }